Consola: diagnóstico en pantalla — bitácora en vivo del handshake WS/RPC

Nueva DebugLog (singleton observable por Compose) que registra cada paso
de RPC y del WebSocket. El panel se abre solo al fallar; el EOFException sin
respuesta ahora trae una pista interpretada. Antes todo iba solo a Logcat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-06-18 16:44:37 +00:00
co-authored by Claude Opus 4.8
parent 3ae5fa52dc
commit e9d35ce8dd
6 changed files with 152 additions and 2 deletions
+3
View File
@@ -11,3 +11,6 @@ captures/
*.aab *.aab
*.keystore *.keystore
!debug.keystore !debug.keystore
# screenshots personales sueltos en la raíz
/Screenshot_*.jpg
@@ -57,6 +57,10 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
val termTitle: String get() = service?.terminal?.title ?: "" val termTitle: String get() = service?.terminal?.title ?: ""
val activeSessionId: String? get() = service?.terminal?.activeSessionId val activeSessionId: String? get() = service?.terminal?.activeSessionId
// Bitácora de diagnóstico en pantalla (handshake WS, RPC, errores).
val debug: List<String> get() = DebugLog.lines
fun clearDebug() = DebugLog.clear()
private val connection = object : ServiceConnection { private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
val svc = (binder as? ConsolaService.LocalBinder)?.service ?: return val svc = (binder as? ConsolaService.LocalBinder)?.service ?: return
@@ -0,0 +1,39 @@
package com.rizoma.consola
import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Bitácora de diagnóstico en memoria, observable por Compose. Sirve para ver
* **en pantalla** qué pasa con la conexión (handshake WS, RPC, errores) sin
* tener que enchufar `adb logcat`. Es un singleton de proceso: lo escriben
* tanto el cliente de red (hilo de OkHttp) como el servicio/ViewModel, y lo
* lee la UI. Cada línea va también a Logcat por si hace falta el histórico.
*
* Mutar un [mutableStateListOf] desde hilos de fondo es seguro: el estado de
* snapshot de Compose es thread-safe y la recomposición ve copias coherentes.
*/
object DebugLog {
private const val MAX = 300
private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
/** Líneas más recientes al final. La UI las invierte para mostrar. */
val lines = mutableStateListOf<String>()
@Synchronized
fun log(tag: String, msg: String) {
val line = "${fmt.format(Date())} $tag · $msg"
lines.add(line)
while (lines.size > MAX) lines.removeAt(0)
Log.d("Consola/$tag", msg)
}
@Synchronized
fun clear() = lines.clear()
/** Volcado de texto plano, para copiar/compartir. */
fun dump(): String = lines.joinToString("\n")
}
@@ -1,6 +1,7 @@
package com.rizoma.consola.data package com.rizoma.consola.data
import android.util.Log import android.util.Log
import com.rizoma.consola.DebugLog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -35,8 +36,10 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
/** Round-trip de una Request JSON (cuerpos de [Rpc]) → [RpcResponse]. */ /** Round-trip de una Request JSON (cuerpos de [Rpc]) → [RpcResponse]. */
suspend fun rpc(body: String): RpcResponse = withContext(Dispatchers.IO) { suspend fun rpc(body: String): RpcResponse = withContext(Dispatchers.IO) {
val rpcUrl = config.baseUrl.trimEnd('/') + "/rpc"
DebugLog.log("RPC", "POST $rpcUrl (token ${if (config.token.isBlank()) "ausente" else "presente"}) ← ${body.take(80)}")
val req = Request.Builder() val req = Request.Builder()
.url(config.baseUrl.trimEnd('/') + "/rpc") .url(rpcUrl)
.post(body.toRequestBody(json)) .post(body.toRequestBody(json))
.apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") } .apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
.build() .build()
@@ -44,12 +47,15 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
client.newCall(req).execute().use { resp: Response -> client.newCall(req).execute().use { resp: Response ->
val text = resp.body?.string().orEmpty() val text = resp.body?.string().orEmpty()
if (text.isEmpty()) { if (text.isEmpty()) {
DebugLog.log("RPC", "HTTP ${resp.code} con cuerpo vacío")
RpcResponse.Failure("respuesta vacía (HTTP ${resp.code})") RpcResponse.Failure("respuesta vacía (HTTP ${resp.code})")
} else { } else {
DebugLog.log("RPC", "HTTP ${resp.code}${text.take(120)}")
RpcParser.parse(text) RpcParser.parse(text)
} }
} }
}.getOrElse { e -> }.getOrElse { e ->
DebugLog.log("RPC", "FALLO ${e.javaClass.simpleName}: ${e.message ?: "(sin mensaje)"}")
RpcResponse.Failure(e.message ?: "fallo de red") RpcResponse.Failure(e.message ?: "fallo de red")
} }
} }
@@ -85,6 +91,11 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
} }
// URL sin query: para logs y para el mensaje de error sin filtrar el token. // URL sin query: para logs y para el mensaje de error sin filtrar el token.
val safeUrl = url.substringBefore('?') val safeUrl = url.substringBefore('?')
DebugLog.log(
"WS",
"abriendo $safeUrl (${if (session != null) "attach $session" else "spawn $program"}, ${rows}x$cols, " +
"token ${if (config.token.isBlank()) "ausente" else "presente"})",
)
val req = Request.Builder() val req = Request.Builder()
.url(url) .url(url)
.apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") } .apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
@@ -103,15 +114,18 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
val ws = client.newWebSocket(req, object : WebSocketListener() { val ws = client.newWebSocket(req, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
DebugLog.log("WS", "handshake OK (HTTP ${response.code}) → enviando open: ${open.toString().take(120)}")
webSocket.send(open.toString()) webSocket.send(open.toString())
listener.onOpen() listener.onOpen()
} }
override fun onMessage(webSocket: WebSocket, bytes: ByteString) { override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
DebugLog.log("WS", "${bytes.size} bytes")
listener.onBytes(bytes.toByteArray()) listener.onBytes(bytes.toByteArray())
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
DebugLog.log("WS", "← texto: ${text.take(160)}")
val o = runCatching { JSONObject(text) }.getOrNull() ?: return val o = runCatching { JSONObject(text) }.getOrNull() ?: return
when (o.optString("t")) { when (o.optString("t")) {
"session" -> listener.onSessionId(o.optString("id")) "session" -> listener.onSessionId(o.optString("id"))
@@ -121,10 +135,12 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
} }
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
DebugLog.log("WS", "cerrando: code=$code reason=${reason.ifBlank { "—" }}")
webSocket.close(1000, null) webSocket.close(1000, null)
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
DebugLog.log("WS", "cerrado: code=$code reason=${reason.ifBlank { "—" }}")
listener.onClosed(reason.ifBlank { null }) listener.onClosed(reason.ifBlank { null })
} }
@@ -153,6 +169,29 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
append(" · sin respuesta al abrir "); append(safeUrl) append(" · sin respuesta al abrir "); append(safeUrl)
} }
} }
// Pista interpretada: un EOF/conexión cerrada sin respuesta HTTP
// casi siempre es que el server cerró el TCP sin completar el
// upgrade. Las causas típicas, para no adivinar a ciegas.
val hint = when {
response == null && (t is java.io.EOFException ||
t.javaClass.simpleName == "EOFException") ->
"el server cerró la conexión sin responder al upgrade. Revisa: " +
"(1) ¿es ws:// vs wss:// correcto para el puerto? " +
"(2) ¿la ruta /ws/pty existe en el gateway? " +
"(3) ¿hay un proxy/reverse-proxy que no pasa el Upgrade? " +
"(4) ¿el token es válido?"
response?.code == 401 || response?.code == 403 ->
"rechazo de auth: el token del query/header no es válido para el WS"
t is java.net.ConnectException ->
"no se pudo conectar al host:puerto — ¿gateway caído o URL/puerto mal?"
else -> null
}
DebugLog.log("WS", "FALLO contra $safeUrl$detail")
if (!httpBody.isNullOrBlank()) DebugLog.log("WS", "cuerpo: ${httpBody.take(300)}")
hint?.let { DebugLog.log("WS", "pista: $it") }
listener.onError(detail) listener.onError(detail)
listener.onClosed(detail) listener.onClosed(detail)
} }
@@ -28,6 +28,8 @@ fun ConsolaApp(vm: ConsolaViewModel = viewModel()) {
status = vm.termStatus, status = vm.termStatus,
message = vm.termMessage, message = vm.termMessage,
frame = vm.frame, frame = vm.frame,
debug = vm.debug,
onClearDebug = vm::clearDebug,
onSized = vm::onTerminalSized, onSized = vm::onTerminalSized,
onText = vm::sendText, onText = vm::sendText,
onBytes = vm::sendBytes, onBytes = vm::sendBytes,
@@ -11,14 +11,17 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -65,12 +68,17 @@ fun TerminalScreen(
status: TermStatus, status: TermStatus,
message: String?, message: String?,
frame: TerminalFrame?, frame: TerminalFrame?,
debug: List<String>,
onClearDebug: () -> Unit,
onSized: (rows: Int, cols: Int) -> Unit, onSized: (rows: Int, cols: Int) -> Unit,
onText: (String) -> Unit, onText: (String) -> Unit,
onBytes: (ByteArray) -> Unit, onBytes: (ByteArray) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
var showDebug by remember { mutableStateOf(false) }
// Abre la bitácora sola cuando algo falla: ahí es cuando hace falta verla.
LaunchedEffect(status) { if (status == TermStatus.Error) showDebug = true }
Column(Modifier.fillMaxSize().background(Vacio)) { Column(Modifier.fillMaxSize().background(Vacio)) {
// Barra superior. // Barra superior.
@@ -79,10 +87,19 @@ fun TerminalScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("", color = NeonVerde, modifier = Modifier.clickable { onBack() }.padding(end = 12.dp)) Text("", color = NeonVerde, modifier = Modifier.clickable { onBack() }.padding(end = 12.dp))
Column(Modifier.fillMaxWidth()) { Column(Modifier.weight(1f)) {
Text(title.ifBlank { "terminal" }, color = Tenue, maxLines = 1) Text(title.ifBlank { "terminal" }, color = Tenue, maxLines = 1)
Text(statusLine(status, message), color = statusColor(status), maxLines = 1) Text(statusLine(status, message), color = statusColor(status), maxLines = 1)
} }
Text(
if (showDebug) "log ▴" else "log ▾",
color = if (status == TermStatus.Error) NeonRosa else NeonCian,
modifier = Modifier.clickable { showDebug = !showDebug }.padding(start = 12.dp),
)
}
if (showDebug) {
DebugPanel(debug = debug, onClear = onClearDebug)
} }
// Pantalla del terminal — tap para enfocar el teclado. // Pantalla del terminal — tap para enfocar el teclado.
@@ -104,6 +121,52 @@ fun TerminalScreen(
} }
} }
/**
* Panel de diagnóstico en pantalla: las líneas de [DebugLog], más recientes
* arriba. Evita tener que abrir `adb logcat` para ver por qué no conecta.
*/
@Composable
private fun DebugPanel(debug: List<String>, onClear: () -> Unit) {
Column(
Modifier
.fillMaxWidth()
.heightIn(max = 220.dp)
.background(SuperficieAlta)
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Row(
Modifier.fillMaxWidth().padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("diagnóstico (${debug.size})", color = NeonCian, fontSize = 11.sp, modifier = Modifier.weight(1f))
Text("limpiar", color = Tenue, fontSize = 11.sp, modifier = Modifier.clickable { onClear() })
}
val scroll = rememberScrollState()
Column(Modifier.fillMaxWidth().verticalScroll(scroll)) {
if (debug.isEmpty()) {
Text("(sin eventos todavía)", color = MuyTenue, fontSize = 11.sp)
} else {
// Más reciente primero.
for (line in debug.asReversed()) {
Text(
line,
color = lineColor(line),
fontSize = 11.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
modifier = Modifier.padding(vertical = 1.dp),
)
}
}
}
}
}
private fun lineColor(line: String): Color = when {
"FALLO" in line || "pista:" in line -> NeonRosa
"handshake OK" in line || "← texto" in line || "" in line -> NeonVerde
else -> Tenue
}
@Composable @Composable
private fun TerminalCanvas(frame: TerminalFrame?, onSized: (Int, Int) -> Unit) { private fun TerminalCanvas(frame: TerminalFrame?, onSized: (Int, Int) -> Unit) {
val density = LocalDensity.current val density = LocalDensity.current