perf: cut terminal latency (interactive echo 16.4ms -> ~2ms, bulk queue wait ~5x) - #376
Conversation
The benchmark suite measures how fast the emulator consumes bytes, and by that measure BossTerm already beats iTerm2 and Alacritty. It says nothing about the interval that decides whether a terminal feels snappy: PTY arrival to the pixel that byte produces. That interval holds the redraw debounce, the data-stream poll timeout and the whole paint pass, and an emulator can parse 1.6 GB/s while still waiting 50 ms before drawing any of it. FrameLatencyProbe measures it. Gated on BOSSTERM_FRAME_PROBE=1; inert otherwise, with no timestamps taken and no allocation on any hot path. A daemon thread writes JSON to ~/.bossterm/frame-probe.json once a second. Four series: byteToPaintMs arrival to end of the paint that first draws it paintCostMs wall time inside renderTerminal lockedCaptureMs UI-thread time holding the terminal buffer lock drawCallsPerFrame drawText invocations per paint Arrival is stamped in BlockingTerminalDataStream rather than in the PTY reader, so it covers every producer (PTY, daemon bridge, share) and, more importantly, sits ahead of the queue wait: whatever performanceMode spends polling lands inside the measurement instead of hidden before it. byteToPaintMs is measured to draw-issued, not to photons, so it is a lower bound that excludes present and vsync. Two builds compared the same way are sound; an absolute latency claim needs the external camera anchor the README describes. Also adds BOSSTERM_REDRAW_DEBOUNCE_MS and BOSSTERM_HIGH_VOLUME_DEBOUNCE_MS so one process can A/B the debounce against the same warmed JIT and window geometry. Both unset means the build behaves exactly as shipped, so a run with no env is a true baseline. This scaffolding comes out once the question settles. Histogram uses eight sub-buckets per octave and reports bucket floors, so every number is a slight under-estimate: it cannot manufacture an improvement that is not there. The bucketing is tested rather than trusted, and the earliest-arrival rule is mutation-checked - last-wins would under-report exactly when output is bursty, which is when it matters.
Two reductions on the renderer's hottest loop, both gated on BOSSTERM_FAST_TEXT=1 so one process can A/B them against the same warmed JIT and window geometry as the baseline. Unset, the renderer behaves exactly as before. 1. ASCII neighbourhoods skip the sequence probes. Every cell currently builds a 20-character lookahead into a String and runs three scans over it, to decide whether a ZWJ sequence, a skin-tone modifier or a regional indicator starts here. All three are non-ASCII, and all three can only reach a cell from col..col+2 - checkFollowingSkinTone looks one ahead and steps over a DWC marker, checkRegionalIndicatorSequence needs the indicator at col itself, and a grapheme cluster can only pull a ZWJ in from the very next cell. So on a line of a log, a diff or source code, that work is dead, per cell, per frame. The soundness condition is one-directional: the fast path must never skip a probe that would have fired. PlainAsciiFastPathTest checks exactly that, over a corpus with ZWJ families, flags, skin tones, variation selectors and CJK, at every column - and separately that the guard still engages on ordinary text, since a fast path that never fires would pass the first check trivially. Narrowing the window to col..col alone makes both fail. 2. Blanks extend a batched run instead of breaking it. canBatch excluded ' ', so every space flushed the batch: aligned output, indented source and powerline prompts cost one drawText per WORD. A blank paints no glyph, so only an underline makes it visible - it can join a run whose underline state matches whatever colour or weight it nominally carries. It still may not start one, since leading blanks would move the origin and shape nothing. Trailing blanks are then trimmed before layout, since shaping invisible cells is pure cost, except under an underline where the blanks carry the rule. That trim is the one place this could drop a character that should have been drawn, so it is extracted as visibleRunLength and tested directly - interior blanks in particular must survive, or "a b" collapses to "a". NUL is mapped to a space on the way into the batch: appending it verbatim would hand the shaper a control character to draw.
|
Review: Strong framing, and the right instinct: the existing suite measures consumption, and "arrival → pixel" genuinely is the number that decides whether the terminal feels fast. The histogram design (log buckets, floor-reporting so it cannot manufacture a win), the mutation-checked invariants, and the honest caveat about draw-issued vs. photons are better discipline than most perf PRs get. Comments explain why rather than what, matching the house style. Most of what follows is about probe fidelity rather than plumbing — since every later decision rests on these numbers, a systematic bias matters more here than in ordinary code. 1.
Concretely: an AI CLI spinner in tab B stamps That lands squarely on At minimum the harness should say "one tab, one pane, nothing else running" out loud. Better: key the pending slot per display (a 2.
So the ordering Both this and item 1 have the same clean fix: read Worth folding into the README caveat section too — it currently lists only the photons caveat, which reads as "a lower bound by one frame" rather than "arbitrarily low when a paint races a parse". 3. The commit message and README present the flag as a pure reduction ("Unset, the renderer behaves exactly as before" — true), but set, output changes for one case: Previously a space could never join a batch ( That is almost certainly a fix and I would keep it. But it means the A/B is not a pure perf comparison and needs a visual check, and a leading underlined blank still draws nothing (it cannot start a run), so the behaviour is now inconsistent within a line. Worth stating in the commit message so the next reader does not treat a pixel diff as a regression. 4.
|
|
Review: 5. The ASCII guard soundness argument has one untested link The skin-tone and regional-indicator halves check out against the code:
The ZWJ half is different: Relatedly, the corpus is built with 6.
7. It is mutable only so the tests can flip it, but the cost is that the JIT cannot constant-fold the branch, so That test-vs-singleton coupling is also an order-dependency risk: Smaller things
Test coverage gap worth closing before the flag becomes the default
Verification note Reviewed statically; I did not build or run anything, per Nice piece of work overall — the pushback is all "calibrate the instrument before trusting its readings", which is the failure mode this PR is otherwise unusually careful about. |
…baseline byteToPaint alone cannot say WHERE the time goes, and a slow recomposition and a long debounce have opposite fixes. triggerToPaintMs splits it: everything up to actualRedraw (queue wait, parse, debounce) versus everything after it (recompose, layout, draw). That split is what made the results readable, and it disproved a hypothesis I had been carrying: recomposition of the 2400-line ProperTerminal is NOT a bottleneck. triggerToPaint is 1.8-13.3 ms and tracks paint cost. Measured baseline in benchmark_results/LATENCY_BASELINE_2026-08-27.md. Headlines: 1. An occluded window is throttled to ~3 fps by macOS, and Compose's frame clock follows. Same tui workload, same process: 29 paints and 295 ms p50 covered, 653 paints and 9.2 ms p50 raised. That is ~30x larger than anything being measured, so it silently makes a healthy build look catastrophic. Every run now asserts the window is frontmost before starting. 2. The debounce, not the renderer, owns the multi-second lag on bulk output. bulk p95: 1310 ms baseline, 1704 ms with the renderer reductions on and the debounce untouched, 10.2 ms with the debounce zeroed. Renderer work alone does not help; zeroing the debounce collapses the tail by ~99%. 3. Blank batching cuts drawText calls 208 -> 28 per frame on tui, but paint cost only 11.3 -> 9.2 ms. So draw-call COUNT was not the dominant paint cost. That argues against the next renderer step as scoped: caching TextLayoutResult, or a Skia TextBlob fast path, both attack the same ~19% slice that run-merging just showed is small. Per-cell work in the two full-grid passes is the bigger target. 4. The O(scrollback) snapshot is real but small: lockedCapture p50 rises 0.03 -> 1.28 ms with 10k lines of history, ~40x, but that is ~8% of a frame. Worth fixing, not worth prioritising. The interactive path came out where reading the code predicted: 16.4 ms baseline, of which only 1.8 ms is after the trigger, so ~14.6 ms is the 5 ms poll plus the 8 ms debounce plus parse.
Review (1/2) — correctness and measurement validityI read the full diff plus the surrounding renderer and data-stream code. This is unusually good work: measurement before optimisation, the two risky invariants mutation-checked rather than asserted, floor-reporting quantiles so the instrument cannot manufacture a win, and finding 3 arguing against the author's own next step. The I could not run 1. The ASCII fast path still pays the per-cell ThreadLocal and allocation it set out to remove
val builder = zwjCheckBuilder.get() // still runs
builder.setLength(0) // still runs
run { while (!plainAscii && …) { … } }
val cleanText = builder.toString() // still runs -> fresh empty String, every cell
That is a plausible cause of the two paintCost regressions in the baseline table ( For what it's worth the soundness argument checks out, and more robustly than the KDoc claims. 2.
|
Review (2/2) — code quality, security, test coverageSmaller code-quality points
PerformanceNo concerns with the instrumentation cost when off — one volatile read at each of five call sites, and SecurityNothing concerning. Test coverageGood where it counts. The two mutation-checked invariants are the right two, and
Also worth noting: Draft status noted, and scaffolding-with-a-stated-exit-plan is the right approach for this. The three I would fix before the next measurement round — because they affect the numbers rather than the code — are #1 (the fast path is not as fast as intended), #3 ( |
Interactive echo goes from 16.4 ms to ~2.0 ms, an 87% cut, measured over three consecutive runs (1.9 / 2.3 / 2.0 p50). Afterwards byteToPaint equals triggerToPaint, which is the signature of nothing being spent ahead of the redraw trigger - the 8 ms debounce and the 5 ms BALANCED poll were the whole gap. Frame counts stay vsync-capped (~62/sec) without the debounce, so the sleep was never what kept the terminal from over-rendering: the frame clock was, and the CONFLATED channel plus Compose's per-frame coalescing already do the job it was added for. With the wait gone, the adaptive machinery around it had no purpose left, so it goes too: RedrawMode, RedrawPriority, the redraws/sec detector, the mode-transition job, and the 100 ms mode-reset coroutine that requestImmediateRedraw launched on every keystroke. scrollArea had been branching on the mode to pick between the conflated and non-conflated path; with both paths now identical in timing it takes the conflated one. Net -131 lines. CORRECTION to the previous commit's claim. It said zeroing the debounce took bulk p95 from 1310 ms to 10.2 ms, a ~99% collapse. That was wrong: it rested on a single run that returned only 33 samples, which should have been treated as suspect rather than reported. Three consecutive runs on these defaults give p95 983.0 ms every time. The real bulk improvement is ~25%, not a fix. The split probe says exactly where the rest goes: on bulk, triggerToPaint p50 is 6.7 ms against a byteToPaint p50 of 491.5 ms, so ~485 ms is upstream of the trigger. With the debounce gone that is queue wait and parse - a 5 MB cat arrives as ~640 chunks through an 8 KiB read buffer, each allocating a ByteArray, a copyOf and a String, into an emulator that pulls them back one Char at a time. Bulk output is a PTY-path problem, not a render-path one, and is not addressed here. benchmark_results/LATENCY_BASELINE_2026-08-27.md rewritten with the corrected numbers and the three-run medians.
Review (1/3) — summary and the things I'd resolve before mergeRead the full diff plus surrounding code in What's genuinely good and worth keeping in this shape:
1. The
|
| workload | baseline p50 | shipped defaults p50 |
|---|---|---|
tui |
9.2 | 13.3 (+45%) |
bulk |
81.9 | 491.5 (6x) |
tui paintCost also moves 11.26 -> 12.29. The doc handles the bulk p95 correction with real rigour, then "What shipped, and what it bought" discusses only the interactive win. The TUI/spinner regime is the one the PR description leads with ("the regime of any TUI or AI CLI spinner") — a 45% p50 regression there deserves a paragraph, even if the conclusion is "p95 unchanged at 15.4, p50 under one frame either way, accepted".
Relatedly, the one config that helps tui — fast-text only: p50 8.2, paintCost 9.22, 28 draw calls — is the one that ships disabled, and shipped defaults + BOSSTERM_FAST_TEXT=1 is the missing cell in the matrix. That's the config you'd actually want to ship, and it was never measured.
3. Missing measurement: keystroke echo during bulk/tui output
Every row is one workload in isolation. The HIGH_VOLUME throttle's real job was arguably to stop the Main dispatcher being hammered with one coroutine resumption + one Compose snapshot write per PTY chunk. With it gone, redrawChannel almost never actually conflates (the processor drains it immediately, since nothing sleeps), so a 5 MB cat puts ~640 dispatches on the same thread as the frame clock. Whether that starves an interleaved keystroke echo is the regression this change is most likely to have, and no workload combines the two.
4. BOSSTERM_FAST_TEXT should not ship as an env gate
The PR text says the flag comes out once there are numbers — and LATENCY_BASELINE_2026-08-27.md has the numbers. Shipping it off leaves two divergent text-rendering paths in the production binary where only one gets user exposure, and leaves PlainAsciiFastPathTest guarding code no user executes. Either flip it on (strictly better on tui) or land the renderer reductions separately. Same for the "Not ready to merge" framing: the description still lists BOSSTERM_REDRAW_DEBOUNCE_MS / BOSSTERM_HIGH_VOLUME_DEBOUNCE_MS, neither of which exists in the diff, and the debounce is now deleted outright rather than A/B-able.
5. benchmark/latency/README.md documents things that don't exist
Anyone following it hits a usage error on the first command:
BOSSTERM_REDRAW_DEBOUNCE_MSandBOSSTERM_HIGH_VOLUME_DEBOUNCE_MS(README:68-69, 78, 86-87) are not in the code —rgfinds them only in markdown. The whole "Suggested ladder" section describes a knob set that isn't there.workloads.sh keystrokes(README:48) — the script only acceptsinteractive|bulk|tui|scroll|aged.- README:71 lists the
performanceModebaseline asbalanced; it's nowlatency. - README:48 calls
interactive"200 single keypresses at a prompt". The script's own header is honest about this (printf 'x'is PTY output, and no workload covers the input half) — the README should be too. - The field table (README:20-26) omits
triggerToPaintMs, the most useful of the five.FrameLatencyProbe.report()omits it too, whilejson()includes it.
Review (2/3) — measurement honesty, and the renderer change6. Probe attribution: the arrival stamp is consumed at dequeue, not at render
On 7.
|
Review (3/3) — test coverage, small stuff, securityTestsThe three mutation-checked invariants are the right ones. Gaps:
Small stuff
SecurityNothing of concern. Verdict. The measurement infrastructure and the |
…ison Blank batching and the ASCII probe skip come off their flag and ship on. Measured on the shipped defaults: drawText calls drop 208 -> 30 per frame on tui and 176 -> 60 on scroll, with paint cost 11.26 -> 10.24 ms and 5.63 -> 2.30 ms. This change fails by producing a wrong PICTURE, not a wrong value, so no unit test would catch a regression. The existing tests pin what is checkable - the ASCII guard never skips a probe that would have fired, and the blank trim never drops a character - but neither of those sees a glyph in the wrong place. So it was verified by rendering benchmark/latency/unicode-torture.sh twice, with the fast path off and on, capturing the window both times, and diffing the images. ZWJ families, flags, skin tones, variation selectors, CJK, powerline glyphs, underlines spanning gaps, bold/italic, inverse, truecolour runs and aligned columns are identical apart from subpixel antialiasing: 0.31% of pixels, thin glyph outlines, no positional drift. Column alignment was checked specifically rather than inferred from the pixel count. A merged run advances by font metrics rather than by cell origin, so it could drift progressively along a line while a whole-image diff still looked small. Cropping the aligned table from both frames shows the columns landing on the same pixels. The fixture is committed because it is the regression asset for the next renderer change, and README.md now states the method rather than the flag. Caveat carried forward: cutting draw calls 86% bought only ~9% of paint time on tui. Caching TextLayoutResult or a Skia TextBlob fast path attack that same small slice. Per-cell scan and colour-conversion work in the two full-grid passes is where renderer effort should go next.
Review (1/3): summary and the four things I would resolve firstStrong work. Measuring before tuning, splitting I checked the ASCII guard independently and it holds: 1. The PR description no longer matches the diffThe body says the renderer work is "behind 2. The
|
Review (2/3): probe fidelity, and hygiene5. The probe is process-global; arrivals are per-stream
Both bias Smaller, same class: the cursor overlay is a separate Canvas, and its 6.
|
Review (3/3): test coverage, security, performanceTest coverageWhat is here is well-aimed: bucket round-trip against a stated error bound, a known distribution to catch the classic inverted-percentile bug, empty-histogram-is-not-zero-latency, earliest-arrival-wins, idle frames excluded, per-frame draw-call reset, the disabled path, Three gaps:
Also: SecurityNothing of concern. The probe is env-gated, writes only under PerformanceNet positive and honestly reported. Removing the debounce means one Main-dispatcher resumption per PTY chunk instead of one per 8/50 ms, but the CONFLATED channel caps pending requests at one and the measured bulk p95 moved the right way (1310 -> 983 ms), so that concern is answered by data rather than by argument. "Draw-call count is not the dominant paint cost" - 86% fewer One risk the pixel diff cannot rule out on its own: |
…obody is watching
Profiling a 5 MB `cat` put 59% of execution samples (55 of 93) in DebugDataCollector:
32 TextEntries.add <- TerminalLine.copy <- TerminalTextBuffer.createSnapshot
<- DebugDataCollector.captureState
15 TerminalLine.copy <- createSnapshot <- captureState
7 ConcurrentLinkedQueue.size <- DebugDataCollector.recordChunk
1 String.toCharArray <- recordChunk
Two independent problems, both on by default:
1. captureState() ran on a 100 ms timer for the life of every tab and called
createSnapshot() - the FULL deep copy, every line of screen AND history cloned.
debugModeEnabled defaults to false, so all of it was thrown away. The
collector's own `enabled` flag defaults true and setEnabled() has no callers
anywhere, so nothing ever turned it off. It now returns early unless the tab's
debug panel is actually open. The loop keeps ticking rather than being torn
down, because the panel toggles at runtime (Cmd/Ctrl+Shift+D) and must start
showing data immediately.
2. Both ring buffers trimmed with `while (queue.size > max) queue.poll()`.
ConcurrentLinkedQueue.size() is O(n): it walks the list. With maxChunks=1000
that is up to 1000 node traversals per chunk, on the PTY reader thread, for
every chunk that crossed the tty. Now trimmed against a tracked counter.
The counter shadows a queue, and drift is silent and directional - too high and
the ring evicts live entries forever, too low and it grows unbounded. Neither
throws; read_debug_console would just return the wrong window of history. So the
invariant is tested, including that clear() resets both. Removing the reset makes
clearResetsTheCounterAlongsideTheQueue fail.
NOT YET VERIFIED BY MEASUREMENT. The profile is strong evidence for the diagnosis,
but the before/after number is missing: the display went to sleep, and measuring a
terminal on a dark screen reproduces the ~3 fps occlusion artefact documented as
finding 0 in the baseline. Numbers taken now would be that artefact, not this fix.
The bulk figures in LATENCY_BASELINE stand unchanged until a run on a live display
says otherwise.
Also adds queueWaitMs to the probe, which is what identified the bottleneck as
upstream of rendering: on bulk, queue wait p50 was 1179 ms against a byteToPaint
p50 of 1048 ms, i.e. essentially all of it, with 5393 chunks for 5.5M chars
(~1022 chars per chunk - PTY reads return far less than the 8 KiB requested).
Review —
|
|
(review, part 2 of 2 — smaller findings, what checked out, and test coverage) Smaller things
Things I checked that hold up
Test coverageThe three new suites cover the right things, and extracting
No security concerns — the probe is opt-in via env var, writes only timing aggregates, and the reset marker is a local file with no privileged effect. |
Stack-sampled the emulator thread through a sustained cat loop. Three costs, none of which is visible from reading the code, and none of which was on my list from doing exactly that. 1. An exception per character (20% of parse time). drainTerminalEmulator loops `while (shouldContinue())`, and shouldContinue is handle::isAlive -> Process.hasExited() -> UnixPtyProcess.exitValue(), which THROWS IllegalThreadStateException whenever the child is alive - the normal case. Java catches it internally, so the call site looks free. A 5 MB cat built ~5.5 million exceptions with filled-in stack traces. isAlive() now caches for 20 ms and latches death. Safe because the drain loop's real termination signal is EOF from the data stream; this check is the belt-and-braces one. 2. An AWT event per redraw request (20%). requestRedraw trySends on the redraw channel; when the processor is parked each send resumes its continuation through the Swing dispatcher, which means EventQueue.invokeLater, an InvocationEvent, and an AccessController.getContext native stack walk. The emulator requests a redraw on every buffer mutation. A pending flag now skips the send when one is already queued, cleared before the redraw so a mutation landing mid-redraw still queues the next frame. 3. ICU grapheme segmentation on plain ASCII (~27%). segmentIntoGraphemes ran RuleBasedBreakIterator, a per-cluster substring and a width calculation over text that was ASCII end to end. Nothing below U+0080 is wide, ambiguous, combining, a surrogate, a ZWJ or a variation selector, so the answer is decidable without ICU. The ICU body is split into segmentViaBreakIterator so the fast path can be PROVED equivalent rather than argued to be: GraphemeAsciiFastPathTest compares the two over every printable ASCII character and a corpus of real terminal output. Loosening the guard to admit non-ASCII makes it fail. Measured on queueWaitMs, which has n~5400 per run (one sample per chunk) and is the reliable series here - byteToPaintMs sometimes lands only a handful of frames on this workload. p50 786-1179 ms -> 262-459 ms p95 1572-1966 ms -> 491-655 ms Bulk output is improved, not solved. What remains on the emulator thread: visualColToBufferCol (~9%), TerminalLine.toBuf/merge (~11%), residual AWT dispatch. Unicode rendering re-checked by rendering unicode-torture.sh on the new build: ZWJ families, flags, skin tones, variation selectors, CJK, powerline, underlines spanning gaps and aligned columns all correct.
Review:
|
Review:
|
BossTerminal.wrapLines calls visualColToBufferCol(line, terminalWidth, length) - a walk from column 0 to the full terminal width, with an O(runs) charAt inside it - every time a line wraps. For output wider than the window that is every line. Stack sampling the emulator thread through a sustained cat put ~10% of its time there, and every one of those samples came from that single call site. A line that needs no visual-column mapping holds nothing above U+007F: no double-width character, no DWC marker (U+E000), no combining mark, no surrogate. Buffer column and terminal cell are then the same number, so the answer is the identity clamped to the line. TerminalLine already tracks the flag; the conversion now short-circuits on it, which the renderer's hit-testing benefits from too. Measured on queueWaitMs (n~5400 per run, 3 runs): p50 262-459 ms -> 147-262 ms p95 491-655 ms -> 262-360 ms That is ~5x on the bulk queue wait end to end for this branch (from 786-1179 ms). The guard is the entire correctness argument, so it is tested against a line carrying DWC markers. Worth recording how: the first version of that test asserted only columns where the identity happens to agree, and passed against a build with the guard deleted. The assertions that bite are the SECOND cell of each wide character, where the buffer index must snap back to the glyph's start instead of landing on the DWC marker. Deleting the guard now fails it. Also commits TerminalLineWriteModelTest, which is not needed by this change. TerminalLine.merge is the other remaining hot spot (~12% of non-parked emulator time): writeCharacters rebuilds the whole line through toBuf and re-derives every style run whenever a write lands anywhere but the end, which - since lines are NUL-filled to width - is most writes. The rope-style entry walk that would fix it is NOT a safe drop-in: collectFromBuffer coalesces adjacent runs with reference-equal styles and an entry walk does not, so without matching that, every overwrite fragments the line further and charAt is O(entries). That trades a one-off O(lineLength) rebuild for a permanently slower line, and it would not show up in a five-second benchmark - only in a long session. It needs run coalescing across the splice boundaries and deserves its own change. The model test is the harness for whoever does it: a randomised check pinning what writeString must produce cell by cell, independent of how the line is stored.
Review (1/2): correctness + the measurement tableUnusually good performance PR. The measurement-before-tuning discipline, the mutation-checked invariants, the self-correction of the "~99% collapse" claim, and the "a fix that did not work, and why it looked like it would" section are all things I'd like to see more of. Most of what follows is either (a) the results table not having kept up with the last three commits, or (b) leftovers on paths the PR itself just optimised. Reviewed by reading; I did not build or run anything (per Correctness arguments I checked and agree withStating these explicitly, because they're the three places a silent wrong-picture bug would live:
1. The results table mixes commits, and two rows regressed without being called out
As written the table says:
The prose only discusses the wins. I don't think the bulk p50 is real — almost certainly a regime artefact (far fewer, much fatter frames, which also explains draw calls going up on bulk while dropping 86% on tui). But that's precisely the argument for re-running rather than leaving it under a heading that says "shipped defaults". The scroll p95 regression looks more likely to be genuine — see #2. Suggestion: re-run the full 2.
|
Review (2/3): leftovers on paths this PR just optimised, and stale docs4.
|
Review (3/3): test coverage, nits, and what to keep12. Test coverage — the riskiest new pieces have noneExtracting
13. Smaller things
Things I'd keep exactly as they are
The one blocker for me is #1 — re-run the matrix on HEAD so the table describes the build it's headed. #2, #3, #5 and #6 are small and worth folding in while you're there. |
Regression from 610538b, caught by the user on the first hand-check: pressing Cmd+Shift+D opened a permanently empty debug panel. TerminalTab carries two debug flags and they mean different things: debugEnabled background COLLECTION, seeded from settings.debugModeEnabled debugPanelVisible the UI, toggled with Cmd/Ctrl+Shift+D The gate read only debugEnabled. Opening the panel does not set that flag, so captureState kept skipping and the panel had nothing to render. Either flag means somebody wants the data, so either must capture. The predicate is now a pure function, shouldCaptureState(collectionEnabled, panelVisible), specifically so the mistake is testable rather than only findable by opening the panel. Restoring the old one-flag condition fails the new test. Worth naming the class of error: the two fields sit adjacent in the constructor with near-identical names, and their KDoc is what distinguishes them. I read the first, saw "debug", and did not check whether it was the one the keyboard shortcut writes. A gate on a feature nothing tests needs the flag traced to its writer, not matched by name.
|
Review: measure terminal latency, and cut the per-cell work behind it — part 1/2 Read the whole diff plus surrounding code in This is unusually good work. Measuring before tuning, splitting I verified three load-bearing arguments independently and they hold:
Correctness 1.
for (ch in text) add(GraphemeCluster.fromChar(ch, 1))Same interning, no second table, no way for the two to drift if the width rule for some ASCII range ever changes. 2.
val lineIsAscii = line.myTypeAheadLine == null && !line.requiresVisualColumnMapping
// in the loop:
val plainAscii = lineIsAscii || isPlainAsciiRun(line, col, bufferLimit)3. Does the
Narrow: type-ahead is transient and the emulator-side 4. Underlined blanks are now asymmetric — A blank may extend a run but not start one, so Related:
5. Death is latched, but there is no |
|
Review — part 2/2: performance, harness, docs, tests Performance 6. The processor loop destructures nothing from 7. The redraw processor no longer yields — With If you ever want the belt-and-braces version, 8. Probe state is process-global, but tabs are not —
Harness bugs 9.
stat -c %Y "$OUT" 2>/dev/null || stat -f %m "$OUT"10. README documents a workload name the script rejects — Docs and consistency 11. Three stale "debouncing" references in the file that removes debouncing — 12. The new 13. 14. A test comment contradicts the code — 15. Settings UI still presents Balanced as the recommended default — 16. The PR description is stale. It says draft, not ready to merge, three env vars ( Test coverage Strong where it counts, and the mutation checks are why I trust it. Gaps, roughly in priority order:
Nothing here blocks on its own. Items 9 and 3 are the two I would fix before the next measurement run, and 16 before merge. |
Why
BossTerm's benchmark suite says it beats iTerm2 and Alacritty on throughput (+43% vs iTerm2 at 1 MB), and that is true. It measures how fast the emulator consumes bytes:
cata file, time how long it takes to return.It cannot see the interval that decides whether a terminal feels snappy - PTY arrival to the pixel that byte produces. An emulator can parse 1.6 GB/s and still wait 50 ms before drawing any of it, which is roughly what was happening.
The comparison that prompted this: the Rust terminal in BossConsoleRust reads 64 KiB, parses, and calls
request_repaint(). No debounce, no timer, no frame cap. Coalescing is free because the frame clock does it.Results
Measured with a probe added in the first commit, window raised, three runs per configuration.
drawTextper frame (tui)Full numbers, method and the failed hypotheses:
benchmark_results/LATENCY_BASELINE_2026-08-27.md.What changed
Display path
RedrawMode, the rate detector and the mode-transition job had no purpose left: net -131 lines.performanceModenow defaults tolatencyrather thanbalanced, removing a 5 ms poll taken with the glyphs already in hand.PTY / parse path
isAlive()is cached for 20 ms.UnixPtyProcess.exitValue()throwsIllegalThreadStateExceptionwhenever the child is alive, and the drain loop called it once per character: ~5.5M exceptions with filled-in stack traces per 5 MBcat, 20% of parse time.trySendto a parked consumer resumed it through the Swing dispatcher, meaninginvokeLater+ anInvocationEvent+ anAccessControllerstack walk, per buffer mutation. Another 20%.visualColToBufferColshort-circuits to the identity on lines needing no visual mapping.wrapLinescalled it with the full terminal width on every wrap (~10%, all from that one site).Verification
Automated: full suite green in both modules. The invariants that could fail silently are mutation-checked rather than trusted - the earliest-arrival rule, the ASCII guards, the ring-buffer counter, and the debug-panel gate each have a test that fails when the thing it protects is reverted.
Renderer changes fail by producing a wrong picture, which no unit test sees, so they were pixel-diffed:
benchmark/latency/unicode-torture.shrendered before and after, ZWJ families, flags, skin tones, variation selectors, CJK, powerline, underlines spanning gaps and aligned columns identical apart from subpixel antialiasing. Column alignment was checked specifically, since a merged run advancing by font metrics rather than cell width would drift progressively along a line.By hand: no flicker under
tui,bulkor vim; typing confirmed snappier; debug panel confirmed working.Things worth a reviewer's attention
A fix that did nothing. Profiling first pointed at
DebugDataCollector- 59% of samples. Gating it changed bulk latency not at all: that work runs onDispatchers.IOworkers parallel to the parse, andJavaMonitorEnterwas zero, so it never blocked. A whole-JVM profile answers "where is CPU spent", not "what is on the critical path". The fix is kept because it removes 10 full-buffer deep copies per second per tab, but it is filed as idle-CPU, not latency.A regression this branch shipped and then fixed. Gating the debug collector, I read
debugEnabled(background collection) instead ofdebugPanelVisible(the UI, what Cmd+Shift+D toggles). The two fields are adjacent with near-identical names. Result: an empty debug panel, found on the first hand-check. The predicate is now a pure function with a test that reproduces the bug.Bulk output is improved, not solved. Still ~150-260 ms on a 5 MB
cat.TerminalLine.mergeis deliberately not fixed (~12% of remaining emulator time).writeCharactersrebuilds the whole line whenever a write is not at the end, and lines are NUL-filled to width so that is most writes. The rope-style entry walk is not a safe drop-in:collectFromBuffercoalesces adjacent runs by reference-equal style and an entry walk does not, so it would fragment lines permanently whilecharAtis O(entries) - a long-session regression a short benchmark cannot see.TerminalLineWriteModelTestis committed as the harness for whoever does it properly.Also still open: residual AWT dispatch (~20% of non-parked emulator time) and
joinTo/appendElementstring building (~10%).