tawasuyu

sergio/humanoid

HTML 38Markdown 1TOML 10 Sutichaykuna

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>

e9d35ce8dd82f89a1fa32a0f72d69942766f80ca · ruwaq Sergio · (4 killa ñawpaq) · tayta 3ae5fa52d · kay commitpi sach'ata qhaway

Semántico sayk'uy

Mana ima sutichaypas t'ikrakurqanchu: qillqalla, yachachiylla utaq willaylla t'ikrakurqan.

6 qillqakuna · +152 −2

diff --git a/.gitignore b/.gitignore
index 1d22aea..c3f2d6a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,6 @@ captures/
 *.aab
 *.keystore
 !debug.keystore
+
+# screenshots personales sueltos en la raíz
+/Screenshot_*.jpg
diff --git a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
index 870be52..3da3969 100644
--- a/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
+++ b/consola/src/main/java/com/rizoma/consola/ConsolaViewModel.kt
@@ -57,6 +57,10 @@ class ConsolaViewModel(app: Application) : AndroidViewModel(app) {
     val termTitle: String get() = service?.terminal?.title ?: ""
     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 {
         override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
             val svc = (binder as? ConsolaService.LocalBinder)?.service ?: return
diff --git a/consola/src/main/java/com/rizoma/consola/DebugLog.kt b/consola/src/main/java/com/rizoma/consola/DebugLog.kt
new file mode 100644
index 0000000..442f3f2
--- /dev/null
+++ b/consola/src/main/java/com/rizoma/consola/DebugLog.kt
@@ -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")
+}
diff --git a/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt b/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
index 8d67968..bfbf7f5 100644
--- a/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
+++ b/consola/src/main/java/com/rizoma/consola/data/GatewayClient.kt
@@ -1,6 +1,7 @@
 package com.rizoma.consola.data
 
 import android.util.Log
+import com.rizoma.consola.DebugLog
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 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]. */
     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()
-            .url(config.baseUrl.trimEnd('/') + "/rpc")
+            .url(rpcUrl)
             .post(body.toRequestBody(json))
             .apply { if (config.token.isNotBlank()) header("Authorization", "Bearer ${config.token}") }
             .build()
@@ -44,12 +47,15 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
             client.newCall(req).execute().use { resp: Response ->
                 val text = resp.body?.string().orEmpty()
                 if (text.isEmpty()) {
+                    DebugLog.log("RPC", "HTTP ${resp.code} con cuerpo vacío")
                     RpcResponse.Failure("respuesta vacía (HTTP ${resp.code})")
                 } else {
+                    DebugLog.log("RPC", "HTTP ${resp.code} ← ${text.take(120)}")
                     RpcParser.parse(text)
                 }
             }
         }.getOrElse { e ->
+            DebugLog.log("RPC", "FALLO ${e.javaClass.simpleName}: ${e.message ?: "(sin mensaje)"}")
             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.
         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()
             .url(url)
             .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() {
             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())
                 listener.onOpen()
             }
 
             override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
+                DebugLog.log("WS", "← ${bytes.size} bytes")
                 listener.onBytes(bytes.toByteArray())
             }
 
             override fun onMessage(webSocket: WebSocket, text: String) {
+                DebugLog.log("WS", "← texto: ${text.take(160)}")
                 val o = runCatching { JSONObject(text) }.getOrNull() ?: return
                 when (o.optString("t")) {
                     "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) {
+                DebugLog.log("WS", "cerrando: code=$code reason=${reason.ifBlank { "—" }}")
                 webSocket.close(1000, null)
             }
 
             override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+                DebugLog.log("WS", "cerrado: code=$code reason=${reason.ifBlank { "—" }}")
                 listener.onClosed(reason.ifBlank { null })
             }
 
@@ -153,6 +169,29 @@ class GatewayClient(@Volatile var config: GatewayConfig) {
                         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.onClosed(detail)
             }
diff --git a/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt b/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt
index 4d0d761..d4d42e6 100644
--- a/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt
+++ b/consola/src/main/java/com/rizoma/consola/ui/ConsolaApp.kt
@@ -28,6 +28,8 @@ fun ConsolaApp(vm: ConsolaViewModel = viewModel()) {
                 status = vm.termStatus,
                 message = vm.termMessage,
                 frame = vm.frame,
+                debug = vm.debug,
+                onClearDebug = vm::clearDebug,
                 onSized = vm::onTerminalSized,
                 onText = vm::sendText,
                 onBytes = vm::sendBytes,
diff --git a/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt b/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
index d5afa24..a25ff0b 100644
--- a/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
+++ b/consola/src/main/java/com/rizoma/consola/ui/TerminalScreen.kt
@@ -11,14 +11,17 @@ import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.verticalScroll
 import androidx.compose.foundation.text.BasicTextField
 import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -65,12 +68,17 @@ fun TerminalScreen(
     status: TermStatus,
     message: String?,
     frame: TerminalFrame?,
+    debug: List<String>,
+    onClearDebug: () -> Unit,
     onSized: (rows: Int, cols: Int) -> Unit,
     onText: (String) -> Unit,
     onBytes: (ByteArray) -> Unit,
     onBack: () -> Unit,
 ) {
     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)) {
         // Barra superior.
@@ -79,10 +87,19 @@ fun TerminalScreen(
             verticalAlignment = Alignment.CenterVertically,
         ) {
             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(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.
@@ -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
 private fun TerminalCanvas(frame: TerminalFrame?, onSized: (Int, Int) -> Unit) {
     val density = LocalDensity.current