From cf08c2480d93efbd1caf26170997981bc107b3d1 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 30 Aug 2026 23:41:55 -0400 Subject: [PATCH] feat: record and replay a PTY stream, so a redraw glitch becomes a test Chasing an intermittent redraw glitch from a screenshot does not work. I tried it this week: I formed three theories about the terminal's wrap and batch logic from one image, and disproved all three by reading the code afterwards. A picture shows that something is wrong, not what. The byte stream is a different kind of evidence. It is deterministic: the same bytes must always produce the same grid. So a recording turns "sometimes the wrapped line garbles" into a failing test. This matters here specifically because of HOW the affected apps redraw. A capture of Claude Code's spinner shows every frame bracketed in DEC 2026, rewriting only the characters that changed, with absolute column addressing and no line clear: CSI 35 C "th" CSI 39 G "nking more with xhigh ef" CSI 64 G "ort" It is diffing against its own model of our grid. One cell of disagreement anywhere becomes permanent visible corruption, because nothing repaints the line to resync, and it surfaces later somewhere unrelated to where it started. Replaying a recording and comparing grids is how that gets localised to the chunk that caused it. Three parts: 1. BOSSTERM_PTY_LOG=1 starts per-tab recording (or set it to a directory). startFileLogging already existed and had NO callers - the facility was there and unreachable, the same shape of dead flag as setEnabled was in this class. 2. The escaper is now lossless. A literal backslash was written through unescaped, so "\e" in output was ambiguous between an escape character and the two characters \ and e - readable, but not replayable. Any Windows path or regex in the output would have replayed as a control sequence. DEL is escaped too. 3. PtyReplay (test sources) parses a recording and feeds it through a real BossEmulator headlessly, returning the grid. Chunk boundaries are preserved rather than concatenated, because a CSI split across two reads is exactly the kind of thing a recording should be able to re-expose. The round-trip test goes through the production writeChunkToFile rather than a reimplementation of it, so the two halves cannot drift apart silently; removing the backslash escape fails it. Writing the differential-redraw test also produced a small correction worth keeping: I expected "thinking more" and got "thinking moreg". The emulator is right - without a trailing clear the previous frame's tail survives, which is precisely the bug class here. Both behaviours are now pinned, with and without the CSI K the real capture ends on. --- .../compose/debug/DebugDataCollector.kt | 41 ++++- .../bossterm/compose/tabs/TabController.kt | 4 +- .../rever/bossterm/compose/debug/PtyReplay.kt | 132 ++++++++++++++++ .../bossterm/compose/debug/PtyReplayTest.kt | 143 ++++++++++++++++++ 4 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplay.kt create mode 100644 compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplayTest.kt diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/debug/DebugDataCollector.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/debug/DebugDataCollector.kt index 14aa6211..48f6eb28 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/debug/DebugDataCollector.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/debug/DebugDataCollector.kt @@ -39,6 +39,10 @@ class DebugDataCollector( private val snapshots = ConcurrentLinkedQueue() private val chunkIndex = AtomicInteger(0) + private companion object { + const val PTY_LOG_ENV = "BOSSTERM_PTY_LOG" + } + /** * Sizes of [chunks] and [snapshots], tracked rather than asked for. * @@ -395,6 +399,34 @@ class DebugDataCollector( * @param filePath Path to the log file * @throws java.io.IOException If the file cannot be created or written to */ + /** + * Start recording to disk automatically when `BOSSTERM_PTY_LOG` is set. + * + * The escaped format [writeChunkToFile] emits is reversible, so a recording made this + * way replays byte-for-byte through `PtyReplay` in the test sources. That is the point: + * a redraw glitch in a TUI is intermittent and unscreenshotable, but the byte stream + * that produced it is a deterministic input. Capture it once, and the bug becomes a + * failing test instead of a theory. + * + * Off unless the variable is set, and `startFileLogging` had NO callers before this - + * the facility existed and was unreachable. + * + * Set to `1` for the default location, or to a directory to choose one: + * BOSSTERM_PTY_LOG=1 ./gradlew :bossterm-app:run + * BOSSTERM_PTY_LOG=/tmp/ptylogs ./gradlew :bossterm-app:run + */ + fun startFileLoggingIfRequested(tabLabel: String) { + val requested = System.getenv(PTY_LOG_ENV)?.takeIf { it.isNotBlank() } ?: return + val dir = if (requested == "1" || requested.equals("true", ignoreCase = true)) { + "${System.getProperty("user.home")}/.bossterm/pty-log" + } else { + requested + } + val safe = tabLabel.replace(Regex("[^A-Za-z0-9._-]"), "_").take(40) + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss").format(Date()) + runCatching { startFileLogging("$dir/$stamp-$safe.log") } + } + fun startFileLogging(filePath: String) { synchronized(this) { stopFileLogging() @@ -448,14 +480,19 @@ class DebugDataCollector( ChunkSource.CONSOLE_LOG -> "LOG#" } writer.print("[$timestamp] $sourceTag ") - // Escape non-printable characters for readability + // Escape non-printables for readability - and LOSSLESSLY, because + // `PtyReplay` in the test sources reverses this to replay a recording + // byte for byte. A literal backslash was previously written through + // unescaped, which made "\\e" ambiguous between an escape character and + // the two characters `\` and `e`: readable, but not replayable. val escaped = chunk.data.joinToString("") { c -> when { + c == '\\' -> "\\\\" c == '\u001b' -> "\\e" c == '\n' -> "\\n" c == '\r' -> "\\r" c == '\t' -> "\\t" - c.code < 32 -> "\\x${c.code.toString(16).padStart(2, '0')}" + c.code < 32 || c.code == 127 -> "\\x${c.code.toString(16).padStart(2, '0')}" else -> c.toString() } } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt index 8710d694..b8163b2f 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/tabs/TabController.kt @@ -559,7 +559,7 @@ class TabController( tab = null, // Will be set after tab creation maxChunks = settings.debugMaxChunks, maxSnapshots = settings.debugMaxSnapshots - ) + ).also { it.startFileLoggingIfRequested(tabId ?: "tab") } // Create type-ahead model and manager if enabled val typeAheadModel = if (settings.typeAheadEnabled) { @@ -946,7 +946,7 @@ class TabController( tab = null, // Will be set after tab creation maxChunks = settings.debugMaxChunks, maxSnapshots = settings.debugMaxSnapshots - ) + ).also { it.startFileLoggingIfRequested(tabId ?: "tab") } // Create type-ahead model and manager if enabled val tabCoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplay.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplay.kt new file mode 100644 index 00000000..5df0d249 --- /dev/null +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplay.kt @@ -0,0 +1,132 @@ +package ai.rever.bossterm.compose.debug + +import ai.rever.bossterm.compose.ComposeTerminalDisplay +import ai.rever.bossterm.compose.terminal.BlockingTerminalDataStream +import ai.rever.bossterm.compose.terminal.drainTerminalEmulator +import ai.rever.bossterm.terminal.emulator.BossEmulator +import ai.rever.bossterm.terminal.model.BossTerminal +import ai.rever.bossterm.terminal.model.StyleState +import ai.rever.bossterm.terminal.model.TerminalTextBuffer +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Replay a recorded PTY byte stream into a headless terminal and read the grid back. + * + * Why this exists: a redraw glitch in a TUI is intermittent, and a screenshot of one is + * evidence that something is wrong but not evidence of what. The byte stream that produced + * it, on the other hand, is a deterministic input - the same bytes must always produce the + * same grid. So a recording turns "sometimes the wrapped line garbles" into a test. + * + * This matters most for applications that redraw DIFFERENTIALLY. Claude Code, for one, + * brackets each frame in DEC 2026 and then rewrites only the characters that changed, using + * absolute column addressing and no line clears: + * + * CSI 35 C "th" CSI 39 G "nking more with xhigh ef" CSI 64 G "ort" + * + * It is diffing against its own model of our grid. One cell of disagreement anywhere becomes + * permanent visible corruption, because nothing ever repaints the line to resync - and it + * surfaces later, somewhere unrelated to where it started. Comparing a replayed grid against + * the expected one is how that divergence gets localised to the chunk that caused it. + * + * Recordings come from `BOSSTERM_PTY_LOG=1`, which writes the same escaped format this + * parser reverses. + */ +object PtyReplay { + + data class Chunk(val source: String, val data: String) + + private val LINE = Regex("""^\[[^]]*] (PTY>|USR<|EMU!|LOG#) (.*)$""") + + /** + * Reverse [DebugDataCollector]'s file format. + * + * Only `PTY>` lines are terminal output; the others are this side's own writes and log + * noise, and feeding them back would replay input the shell never saw. + */ + fun parseLog(text: String): List = + text.lineSequence() + .mapNotNull { LINE.find(it) } + .map { Chunk(it.groupValues[1], unescape(it.groupValues[2])) } + .toList() + + /** Inverse of the collector's escaper. See its comment for why `\\` must come first. */ + fun unescape(s: String): String { + val out = StringBuilder(s.length) + var i = 0 + while (i < s.length) { + val c = s[i] + if (c != '\\' || i == s.lastIndex) { + out.append(c); i++; continue + } + when (val next = s[i + 1]) { + '\\' -> { out.append('\\'); i += 2 } + 'e' -> { out.append('\u001B'); i += 2 } + 'n' -> { out.append('\n'); i += 2 } + 'r' -> { out.append('\r'); i += 2 } + 't' -> { out.append('\t'); i += 2 } + 'x' -> { + val hex = s.substring(i + 2, minOf(i + 4, s.length)) + val code = hex.toIntOrNull(16) + if (code != null && hex.length == 2) { + out.append(code.toChar()); i += 4 + } else { + out.append(c); i++ + } + } + else -> { out.append(c).append(next); i += 2 } + } + } + return out.toString() + } + + /** + * Feed [chunks] through a real emulator at [width] x [height] and return the visible + * grid, one string per row, trailing blanks trimmed. + * + * Chunk boundaries are preserved rather than concatenated: the batch begin/end they + * drive is part of what is being reproduced, and a CSI split across two reads is + * exactly the kind of thing a recording should be able to re-expose. + */ + fun replay(chunks: List, width: Int, height: Int): List { + val display = ComposeTerminalDisplay() + val styleState = StyleState() + val textBuffer = TerminalTextBuffer(width, height, styleState) + val terminal = BossTerminal(display, textBuffer, styleState) + val dataStream = BlockingTerminalDataStream() + val emulator = BossEmulator(dataStream, terminal) + + dataStream.onChunkStart = textBuffer::beginBatch + dataStream.onChunkEnd = textBuffer::endBatch + + val executor = Executors.newSingleThreadExecutor() + val drain = executor.submit { + drainTerminalEmulator( + emulator = emulator, + dataStream = dataStream, + terminal = terminal, + shouldContinue = { true }, + ) + } + try { + chunks.filter { it.source == "PTY>" }.forEach { dataStream.append(it.data) } + dataStream.close() + drain.get(30, TimeUnit.SECONDS) + } finally { + executor.shutdownNow() + } + + textBuffer.lock() + try { + return (0 until height).map { row -> + textBuffer.getLine(row).text.trimEnd() + } + } finally { + textBuffer.unlock() + } + } + + /** Convenience: parse and replay in one step. */ + fun replayLog(text: String, width: Int, height: Int): List = + replay(parseLog(text), width, height) +} diff --git a/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplayTest.kt b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplayTest.kt new file mode 100644 index 00000000..ea33a3c9 --- /dev/null +++ b/compose-ui/src/desktopTest/kotlin/ai/rever/bossterm/compose/debug/PtyReplayTest.kt @@ -0,0 +1,143 @@ +package ai.rever.bossterm.compose.debug + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The replay harness is only worth anything if a recording round-trips exactly. If the + * escaper and the parser disagree by one character, every conclusion drawn from a replayed + * grid is about the wrong bytes - and it would look like a terminal bug rather than a + * tooling bug, which is the worst possible failure mode for a debugging aid. + */ +class PtyReplayTest { + + private fun roundTrip(payload: String): String { + val file = File.createTempFile("pty-replay", ".log") + file.deleteOnExit() + val collector = DebugDataCollector(tab = null, maxChunks = 16, maxSnapshots = 2) + collector.startFileLogging(file.absolutePath) + collector.recordChunk(payload, ChunkSource.PTY_OUTPUT) + collector.stopFileLogging() + val chunks = PtyReplay.parseLog(file.readText()) + assertEquals(1, chunks.size, "expected exactly one PTY chunk in the recording") + return chunks.single().data + } + + @Test + fun aRecordingRoundTripsThroughTheProductionEscaper() { + // Through the real writeChunkToFile, not a reimplementation of it, so the two halves + // cannot drift apart silently. + listOf( + "plain ascii", + "\u001B[?2026h\u001B[?25l\u001B[H\r\u001B[27B", + "\u001B[38;2;255;193;7m\u001B[1m\u001B[22m\u001B[39m", + "tab\there\nnewline\rcarriage", + "control\u0000nul\u0007bell\u007Fdel", + "unicode ✻ ✽ ✶ 你好 👨\u200D💻 └─", + ).forEach { payload -> + assertEquals(payload, roundTrip(payload), "round trip lost data") + } + } + + @Test + fun aLiteralBackslashIsNotConfusedWithAnEscape() { + // The reason the escaper needed fixing. Unescaped, the two characters `\` and `e` + // are indistinguishable from an ESC, so a Windows path or a regex in the output + // would replay as a control sequence. + listOf( + """C:\Users\kshivang\dev""", + """\e is not an escape here""", + """\\e""", + """grep -E '\d+\s*' file""", + """trailing backslash \""", + ).forEach { payload -> + assertEquals(payload, roundTrip(payload), "backslash handling lost data") + } + } + + @Test + fun onlyTerminalOutputIsReplayed() { + // Replaying USR< would feed our own keystrokes back into the emulator as if the + // shell had echoed them, inventing content the recording never contained. + val file = File.createTempFile("pty-replay-mixed", ".log") + file.deleteOnExit() + val collector = DebugDataCollector(tab = null, maxChunks = 16, maxSnapshots = 2) + collector.startFileLogging(file.absolutePath) + collector.recordChunk("from-pty", ChunkSource.PTY_OUTPUT) + collector.recordChunk("from-user", ChunkSource.USER_INPUT) + collector.stopFileLogging() + + val parsed = PtyReplay.parseLog(file.readText()) + assertEquals(2, parsed.size, "the parser should surface both, and let replay filter") + assertEquals(listOf("PTY>", "USR<"), parsed.map { it.source }) + + val grid = PtyReplay.replay(parsed, width = 20, height = 2) + assertEquals("from-pty", grid[0]) + assertTrue(grid.none { it.contains("from-user") }, "user input must not be replayed") + } + + @Test + fun aDifferentialRedrawReplaysToTheGridTheAppIntended() { + // The shape that produced the reported glitch: absolute column addressing, no line + // clear, only the changed characters rewritten. Frame two turns + // "still thinking with xhigh effort" into "thinking more with xhigh effort" by + // rewriting cols 1-2, skipping col 3, and resuming at col 4 - which is only correct + // if our grid holds exactly what frame one left there. + val esc = "\u001B" + val frame1 = "$esc[H${esc}[Kstill thinking" + + // With the trailing clear the real capture ends on (chunk 16312 finishes with + // CSI[K]), the shorter replacement lands cleanly. + assertEquals( + "thinking more", + PtyReplay.replay( + listOf( + PtyReplay.Chunk("PTY>", frame1), + PtyReplay.Chunk("PTY>", "$esc[H" + "th" + "$esc[4G" + "nking more" + "$esc[K"), + ), + width = 40, height = 2, + )[0], + ) + + // WITHOUT it, the tail of the previous frame survives - "thinking moreg", the + // trailing g of "thinking" left behind because nothing overwrote or cleared it. + // That is the whole bug class this harness exists to catch: a differential redraw + // leaves stale cells whenever the app's model of the grid and ours disagree about + // what is already there, and no full repaint ever comes along to resync. Pinned + // here so the emulator's behaviour is stated rather than assumed - it is correct, + // and it is why one cell of divergence becomes permanent visible corruption. + assertEquals( + "thinking moreg", + PtyReplay.replay( + listOf( + PtyReplay.Chunk("PTY>", frame1), + PtyReplay.Chunk("PTY>", "$esc[H" + "th" + "$esc[4G" + "nking more"), + ), + width = 40, height = 2, + )[0], + ) + } + + @Test + fun chunkBoundariesDoNotSplitAControlSequence() { + // A CSI arriving across two PTY reads is normal and must not corrupt the grid. This + // is the property BlockingTerminalDataStream exists for; replaying preserves the + // boundaries so a recording can re-expose it rather than smoothing it over. + val esc = "\u001B" + val whole = PtyReplay.replay( + listOf(PtyReplay.Chunk("PTY>", "$esc[H${esc}[2Jabc$esc[3Gz")), + width = 10, height = 2, + ) + val split = PtyReplay.replay( + listOf( + PtyReplay.Chunk("PTY>", "$esc[H${esc}[2Jabc$esc["), + PtyReplay.Chunk("PTY>", "3Gz"), + ), + width = 10, height = 2, + ) + assertEquals(whole, split, "a CSI split across reads changed the result") + assertEquals("abz", whole[0]) + } +}