Skip to content

perf: cut main-process and renderer costs that grow with cards, hidden output and orchestration - #89

Merged
howdeploy merged 15 commits into
howdeploy:mainfrom
BIackFIame:perf/runtime-costs
Sep 28, 2026
Merged

howdeploy merged 15 commits into
howdeploy:mainfrom
BIackFIame:perf/runtime-costs

Conversation

@BIackFIame

@BIackFIame BIackFIame commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Goal

Remove runtime costs that grow with the number of cards and with scrollback size, keep every visible behaviour the same, and add a benchmark so the next change can be measured the same way.

What was wrong, what changed

Line numbers are at 31f287d.

# Where Problem Fix Test
a TerminalManager.ts:1741-1755 (appendScrollback) A chunk the ring dropped stayed in bufferChunks until more than 256 slots were dead. With 16 KB reads a session kept 1 376 768 characters alive for a 240 000 ring. The slot is emptied when the chunk leaves the ring. tests/terminal-output-costs.test.mjs: referenced text equals bufferLength for 1/16/64 KB chunks; history reads back unchanged
b TerminalManager.ts:821 (setVisible) Hidden → visible sent the whole ring (up to 240 000 chars), although the card already had everything up to the hide point. Crossing zoom 0.5 did it for every card at once. Replays only the output produced since the card was hidden (scrollbackTail). A stretch longer than the ring still sends the whole ring, so the truncation marker is unchanged. same file: replay is exactly the missed stretch and the real renderer dedup writes each byte once; 8 cards send 1 024 bytes each. Existing visibility tests updated to expect the missed suffix.
c AgentControlService.ts:96,116,124,191, registerIpc.ts:480 terminals.list() used as a lookup by id: every status check, ownership check, observe_agent and list_agents joined the scrollback of every card. TerminalManager.getMetadata(id) and listMetadata(); only the observed card's own scrollback is read. The plugin sessions.list answer uses listMetadata(). tests/agent-control.test.mjs: a tool call reads no list() and only the observed card's buffer
d AgentControlService.ts:145,165, PluginSessions.ts:207 All redaction rules ran over the whole 240 000-char scrollback (about 12-14 ms on an M1 Max) to keep the last 8 192 / 4 000 chars. SecretRedactionRegistry.redactTail masks a window starting at least 16 384 chars before the tail (more when a held value could wrap over that: each held value can match len × 65 chars), moved to a line start and to the header of a private-key block still open there. A secret split by the tail cut lies inside the window and is masked whole, as the item-6 fix requires. tests/secret-redaction.test.mjs: redactTail(text, n) === redact(text).slice(-n) for 14 secret kinds (custom value, wrapped value, tokens, JSON, assignment, URL, bearer, closed and open PEM) at 8 offsets around both the tail cut and the window start, 3 tail sizes (672 cases); the regexes see at most ~28 K chars; observe/result/plugin screen stay under 40 K per call
e ProviderLaunch.ts:379 The first Kimi launch ran spawnSync("kimi --help") on the main process, up to 3 s. warmKimiProbe() runs the same probe with execFile (same command, env, limits, answer) 5 s after startup and after a CLI recheck; the launch uses that answer. A launch before it finishes still probes as before. tests/agent-browser-provider-launch.test.mjs: warmed answer used with no blocking probe, recheck discards it; async and sync probes agree on three stand-in CLIs
f App.tsx:204, WorkspaceCanvas.tsx:827-859, TerminalCard.tsx:107 The camera is App state, so each pan move renders App and the workspace; every TerminalCard rendered again (new arrow functions and snap-target arrays each render, no memo). TerminalCard is React.memo (snap targets compared by value); the workspace hands it callbacks that stay the same functions and call its latest handlers through a ref. tests/canvas-card-render-cost.test.mjs (equality rules; memo and stable callbacks in source); benchmark: TerminalCard renders per pan move 7.9 → 0
h electron.vite.config.ts electron-vite leaves bundles unminified; the renderer parsed 1.83 MB of JS on every window load. Renderer build minified with esbuild. Source maps stay off; main and preload unchanged; audit:secrets passes on out/. tests/renderer-build-config.test.mjs
i ProviderIcon.tsx:7 hermes.png was the 1024 px original (574 KB), drawn at 24-125 CSS px. codex.png (544 px, 146 KB) likewise. 128 px + 256 px variant via srcset, scaled with sips -Z only; assets README says so. tests/provider-icon-assets.test.mjs
j companion/TerminalPresentation.ts:41-65,86-108 With the Even G2 companion on, every session got an @xterm/headless screen on its first event and parsed all its output. The headless screen is made on the glasses' first read of a session, from its scrollback, and fed from then on; answer state is still kept for every session. tests/companion-presentation.test.mjs: only read sessions are parsed; a late first read equals a live-parsed screen

Not changed, on purpose:

  • (g) hidden cards still parse output — only cards at zoom ≥ 0.5 that are off-screen do; summary-mode cards are already gated. Gating off-screen cards would leave terminal queries (DA, cursor position, OSC colour) unanswered until the card is scrolled into view, so an agent TUI that waits for them at startup (a subagent spawned off-screen, for example) could stall or fail. xterm already skips painting off-screen cards.
  • Failure details on a non-zero exit still mask the whole buffer: it happens once per failed exit, and the traceback search reaches back arbitrarily.

Benchmark

scripts/bench-runtime.mjs (docs: docs/performance-benchmark.md) runs the built app in hidden, off-screen, unfocusable windows with a throw-away HOME, keychain and safeStorage off, and micro-benchmarks the hot paths in plain Node on fake PTYs.

Machine: macOS 26.6.2, Apple M1 Max (10 cores), 64 GB RAM, Node 23.3.0, Electron 43.2.0. 3 runs each, medians. BEFORE = origin/main + the benchmark commit, AFTER = this branch. App scenarios: 8 plain terminal cards, flood = 1 MB/s of coloured log lines per card for 20 s (8 MB/s total).

Scenario Metric BEFORE AFTER
idle RSS, app processes 440 MB 438 MB
8 terminals RSS 456 MB 456 MB
flood RSS (peak incl. generators) 724 MB (1 210) 723 MB (1 204)
flood CPU main / renderer 16.2 % / 28.9 % 16.6 % / 28.1 %
10 s after RSS, CPU main / renderer 553 MB, 0.8 % / 0.2 % 550 MB, 0.2 % / 0.4 %
pan, 8 cards TerminalCard renders per move 7.9 0
pan components rendered per move 94.8 48.2
pan renderer ms per move (script / all tasks) 1.04 / 2.07 1.14 / 2.54
bundle renderer JS / CSS / images 1 830 / 149 / 862 KB 1 036 / 126 / 260 KB
micro scrollback chars kept, 16 KB reads (4 KB, 1 KB) 1 376 768 (658 304, 307 584) 240 000 (240 000, 240 000)
micro bytes sent when 8 full cards become visible after 1 KB each 1 920 000 8 192
micro one observe_agent call, 20 cards with full scrollback 17.3 ms, 29.4 MB allocated 1.65 ms, 0.52 MB
micro observe_agent alone / list_agents 14.0 / 2.72 ms 1.56 / 0.05 ms
micro Even G2 on, 8 cards × 512 KB, glasses show 1 77 ms, 8 headless screens 24.9 ms, 1
micro first Kimi launch, CLI answers --help in 1 s 1 496 ms main-thread block 16 ms

Where to expect it:

  • Many cards / zoom-out and back: no 240 KB resend per card when crossing zoom 0.5; up to ~1.1 MB less retained text per busy session.
  • Orchestration: each canvastty_agents tool call is ~10× faster and no longer allocates every card's scrollback; the saving grows with the number and history of cards.
  • Heavy output: the plain flood is unchanged within noise (it exercises none of the fixed paths while cards stay visible); the gains show when cards are hidden or read by agents, plugins or the glasses.
  • Pan/zoom: terminal cards no longer re-render per move. At 8 cards the renderer's time per move did not drop measurably (layout and the remaining App/Settings/Home renders dominate); the saving scales with the number of cards.
  • Even G2: output of sessions not on the glasses is no longer parsed.
  • Startup/window load: 0.8 MB less JS to parse, 0.6 MB less image data.

Reproduce:

npx electron-vite build
node scripts/bench-runtime.mjs --runs 3 --json bench.json   # add --pan-only or --micro-only for parts

Not in this PR: native helpers

Every hook, permission gate and MCP helper call starts Electron as Node. A Go prototype of hook-helper.mjs (same argv, env, stdin, socket message and exit code; message checked identical against a stand-in gateway), 40 runs each on the machine above:

wall time per call peak RSS
Electron-as-Node hook-helper.mjs 99.6 ms 60.1 MB
Go prototype (2.7 MB binary) 7.8 ms 9.3 MB

A helper runs on every lifecycle event and every gated tool call, so this is the largest remaining per-event cost. It needs a build and signing story per platform; to be discussed in an issue first.

Observed, not addressed

  • In both BEFORE and AFTER runs the harness saw libc++abi: terminating due to uncaught exception of type Napi::Error at quit after the flood (4 of 6 runs), after the report was complete. Looks like node-pty during shutdown; worth its own issue.
  • A held secret of about 3 800+ characters (the registry accepts up to 4 096) makes the combined known-value regex fail to compile ("Stack overflow"), and every redact() then throws. Pre-existing; not changed here.
  • Remaining per-pan renders: the closed SettingsPanel, HomeZone and their icons (about 48 per move). Memoizing them needs the same stable-callback treatment in App.tsx.

Checks

Typecheck, full suite 1028/1028, electron-vite build, audit:secrets, test:even 47/47, all with a fake HOME. Every commit typechecks and passes the tests it touches. Benchmarks ran in hidden, unfocused windows with keychain access refused.

Fixes for two bugs the benchmark found

Two commits on top of the performance work, each with its own regression tests.

Crash on quit after an output flood (fix(terminal): wait for every PTY to exit before the app finishes quitting)

Symptom. After the flood scenario, quitting sometimes aborted with libc++abi: terminating due to uncaught exception of type Napi::Error (SIGABRT). This also happens on main. The crash report shows node-pty's exit ThreadSafeFunction::CallJS running inside node::FreeEnvironment, while other pty exit watchers were still waiting in kevent.

Root cause. TerminalManager.shutdown() (src/main/services/TerminalManager.ts, disposeAll → session.process.kill()) sends SIGHUP to every shell and returns straight away. shutdownServices() in src/main/index.ts then lets the app quit. A shell that exits while Electron is freeing the Node environment makes node-pty's native exit callback call into JavaScript that can no longer run. node-pty rethrows that failure as a C++ exception, which aborts the app.

Fix. TerminalManager now keeps track of every PTY until its exit is reported. The new waitForProcessExits() waits up to 2 s for those exits, sends SIGKILL to any process still running, and then waits up to 1 s more. shutdownServices() starts this wait right after the terminal shutdown and awaits it before quitting finishes. The exit handler is also wrapped so that no exception can reach node-pty's native callback, where a throw would abort the app too.

Tests. tests/terminal-quit-exits.test.mjs uses fake PTYs. It checks that the wait lasts until the last exit, that a process ignoring SIGHUP gets SIGKILL, that the wait is bounded, that a card closed just before quitting is still waited for, that a throwing exit handler is contained, and that the quit path awaits the wait. All six tests fail without the fix.

Live evidence. Each run used a hidden app with 8 cards, a 1 MB/s flood, and app.quit() with no forced exit.

  • Before the fix: 2 of 3 quits after the flood aborted with Napi::Error, and all 3 of 3 benchmark runs (--app-only --runs 3) did.
  • After the fix: 0 of 10 quits after the flood crashed, 0 of 10 quits during the flood crashed, and 0 of 3 benchmark runs crashed. No new Electron crash reports appeared in ~/Library/Logs/DiagnosticReports during these 23 runs. Quitting takes about 100 ms longer.

Long stored secrets broke redaction (fix(redaction): find held values with a linear search, not a pattern built from them)

Symptom. Once a stored secret longer than about 3,700 characters was held, every redaction call threw SyntaxError: Invalid regular expression. That broke agent observe/result, plugin screens and failure details. A secret longer than 4,096 characters was silently not masked at all.

Root cause. SecretRedactionRegistry.knownPattern() (src/main/services/safety/SecretRedaction.ts) built one regular expression from each value, with a [\s─-╿]{0,64} wrap-gap class between every two characters. For long values the pattern grew beyond V8's limit.

Fix. Held values are no longer turned into a pattern. The text is searched with its wrap characters taken out, using Knuth-Morris-Pratt for each value, and a position map leads back to the original text. The 64-character gap limit and the "leftmost match, longest first" behaviour stay the same. The cost is linear in the text plus the value, so the maximum length of a held value rises to 65,536 characters. redactTail still masks a window at least as wide as the longest possible match, so the guarantee to mask the full text before cutting it still holds.

Tests. New cases in tests/secret-redaction.test.mjs all fail on the previous code:

  • values of 3.8k, 4k, 16k and 64k characters, masked whole in plain text, wrapped in a box, and in JSON-escaped form
  • two halves of a value further apart than one wrap gap are left alone
  • a long value never breaks the masking of other values
  • a linear-time check with values that overlap themselves over 240k characters
  • redactTail equals masking the whole text and cutting it, for 4k and 16k values placed across the tail cut and the window cut, with no fragment surviving

A plain redact() of a 240k scrollback holding 10 values takes about 14 ms, compared with 12 ms before.

Gates on the branch: typecheck, full suite (1038/1038), electron-vite build, audit:secrets, test:even (47/47).

scripts/bench-runtime.mjs runs the built app in hidden windows with a throw-away HOME (idle, N terminal
cards, a fixed-rate output flood in every card, 10 s after it, a canvas pan) and micro-benchmarks of the
main-process hot paths, and prints medians. docs/performance-benchmark.md explains the scenarios.
appendScrollback advanced bufferStart past a dropped chunk but left the chunk in the array until more
than 256 slots were dead, so a busy session kept up to 256 extra chunks alive (823 552 characters instead
of 240 000 with 16 KB reads). The slot is now emptied when the chunk leaves the ring.
…ible

setVisible(true) sent the renderer the whole retained scrollback (up to 240 000 characters) although the
card already had everything up to the hide point and drops what it wrote before. Crossing the 0.5 zoom
threshold did that for every card at once: 8 cards with full history sent 1.9 MB for 8 KB of new output.
The replay now carries the output produced since the card was hidden; a stretch longer than the ring still
arrives as the whole ring, so the truncation marker is unchanged.
…ting every card

AgentControlService used terminals.list() as a lookup: every status, ownership check, observe_agent and
list_agents joined the scrollback of every card on the canvas and threw it away (29 MB allocated per
observe_agent call on 20 cards with full history). It now reads metadata through getMetadata(id) and
listMetadata(); only the observed card's own scrollback is read. The plugin sessions.list answer is built
from listMetadata() as well.
…gins read

observe_agent, get_agent_result and the plugin screen ran every redaction rule over the whole 240 000-
character scrollback (about 12 ms on an M1 Max) to keep its last 8 192 or 4 000 characters.
SecretRedactionRegistry.redactTail masks a window that starts at least 16 384 characters before the tail
(more when a held value could wrap over that), at a line start, and at the header of a private-key block
still open there. A secret split by the tail's cut lies inside the window and is masked whole, as before;
tests compare redactTail with masking the whole text for secrets placed around the tail cut and the window
start.
…rst launch

The first Kimi launch ran spawnSync("kimi --help") on the main process (up to 3 s) to learn whether the CLI
takes a per-run MCP config; nothing else moved meanwhile. ProviderLaunchAdapters.warmKimiProbe runs the
same probe with execFile (same command, environment, limits and answer) 5 s after startup and after a
CLI recheck, and the launch uses that answer. A launch that comes before it still probes as before.
With the companion on, TerminalPresentation made an @xterm/headless screen for every session on its first
event and parsed all of its output, although the glasses read only the sessions shared with them. The
headless screen is now made on the first read, from the scrollback (the same text the stream carried),
and fed from then on; the answer state is still kept for every session.
The camera lives in App state, so each pointer move of a pan renders App and the workspace, and every
TerminalCard rendered again with it: the workspace passed new arrow functions and a rebuilt snap target
list on every render, and nothing was memoized. TerminalCard is now React.memo'd (snap targets compared by
value), and the workspace hands it callbacks that stay the same functions and call its latest handlers
through a ref. A card still renders when its session, zoom, focus, selection or a neighbour's bounds change.
With 8 cards a pan move rendered 8 TerminalCards (97 components); now none (48 components).
…x variant

hermes.png was the 1024 px original (574 KB) and codex.png 544 px (146 KB), drawn at 24 to about 125 CSS
px. Both are scaled down to 128 px plus a 256 px variant chosen through srcset on high-density displays:
574 KB + 146 KB become 12 + 42 KB and 12 + 37 KB. The marks are not otherwise changed; the assets README
says how they were scaled.
electron-vite leaves every bundle unminified, so each window load parsed 1.83 MB of renderer JavaScript.
The renderer build now minifies with esbuild (1.83 MB to 1.04 MB, CSS 149 KB to 126 KB); source maps stay
off and audit:secrets passes on the built output. Main and preload bundles are unchanged.
…tting

Quitting hung every card up and let Electron free the Node environment at
once. A shell that exited during that teardown made node-pty's native exit
watcher call into JavaScript that could no longer run; node-pty rethrew the
failure as a C++ Napi::Error and the app aborted (SIGABRT). The benchmark's
quit after the output flood hit it in most runs, on main as well.

TerminalManager now tracks every PTY until its exit is reported, and
waitForProcessExits() resolves once all have exited: after 2 s it sends
SIGKILL to the rest and waits 1 s more. shutdownServices() starts that wait
right after the terminal shutdown and awaits it before quitting finishes.
The exit handler also never lets an exception reach node-pty's native
callback, where a throw aborts the app too.
…built from them

Each held value became a regular expression with a wrap-gap class between
every two characters. From about 3,700 characters the pattern exceeded what
V8 accepts, and the SyntaxError broke every redaction call (agent observe
and result, plugin screens, failure details) as long as the value was held.

Held values are now found in the text with its wrap characters (whitespace,
box sides) taken out, by Knuth-Morris-Pratt per value, with a position map
back to the original text and the same 64-character gap limit. The cost is
linear in the text and the value, so the limit on a held value rises from
4,096 to 65,536 characters (PEM keys, service-account JSON). redactTail keeps
masking a window at least as wide as the longest possible match, so the tail
is still exactly what masking the whole text and cutting it gives.

Copy link
Copy Markdown
Owner

I reviewed 44a5fa7 and found two potential redaction regressions that should be addressed before merging the redaction changes. These are static-review findings; I have not run reproductions or the test suite.

  1. Registered secrets can disappear from the matcher after whitespace normalization. In SecretRedaction.ts:112, knownForms() checks the minimum length again after removing wrap characters. For example, add("test", ["abc defg"]) accepts the eight-character value, but its normalized form has only seven characters and is discarded. Plain output containing abc defg then has neither a known-value match nor a generic credential shape. The previous matcher retained the literal space and masked this value. Please preserve exact matching for accepted values even when their normalized form is too short for the wrap-tolerant matcher.

  2. The tail window can discard context required by an unbounded redaction rule. In SecretRedaction.ts:90–98, only PEM headers receive special handling before slicing. The quoted assignment rule also accepts arbitrarily long values. With an otherwise empty registry, consider a password=" assignment containing "a ".repeat(20000), followed by the closing quote, and maxChars = 8192. Full redaction recognizes the assignment, but the tail window starts inside its value, loses the password=" prefix, and appears to return its contents. Since the sliced text was not shortened by masking, the existing fallback does not trigger.

Could you add regression cases for both, including the long-assignment equality check against redact(text).slice(-maxChars), and adjust the implementation? The performance work is useful; the concern here is preserving the previous redaction guarantees on output exposed to agents and plugins.

… leave too few others

The wrap-tolerant search matches each held value with its wrap characters taken out, and dropped a form
that kept fewer than eight characters that way: `abc defg` was accepted by add() but then matched neither
as a held value nor as a credential shape. A value whose own gaps are wider than a wrap gap (64 characters)
was kept but could never match, since the search refuses to cross such a gap.

Each value and JSON-escaped form that holds wrap characters is now also searched exactly as written (Knuth-
Morris-Pratt over the text itself, so still linear). Both searches feed one leftmost-longest selection. A
value that is mostly spaces still matches only as written, never as the fragment left without its spaces.
redactTail cut its window at a line start near the margin and relied on every rule matching at most a
few thousand characters. Several do not: quoted and unquoted assignments, URL query values and userinfo,
Authorization/Bearer/assignment/JSON separators followed by any amount of whitespace, and wrapped tokens
that go on over many lines. When such a match began before the window, the window saw only its middle,
nothing masked it, and the fallback did not fire because masking had not shortened the window
(`password="` followed by 40 000 characters of `a ` came back unmasked).

The window now starts at a line start that no match can cross, found by walking back over line starts:
it lies in no private-key block (blocks found from the text's start, the way the PEM rule sees them), the
last non-whitespace character before it is none a wrapped run or a separator's whitespace continues after,
no JSON secret value is open over it, and no held-value match crosses it or touches what those checks
read. From there every pass finds the same matches as on the whole text, so the tail is exactly
redact(text).slice(-maxChars). With no such line start within 64k characters, with less than the tail
left after masking, or with a held value holding PEM armour and a private-key header in the text, the
whole text is masked.
@BIackFIame

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Both findings reproduced; they are fixed as two new commits on top of 44a5fa7 with no history rewrite. Each regression test was run against 44a5fa7 first and failed there for the reason you describe.

Final head: 6cd6d66acf4af214adf95c6bd7a518e820658d9d.

1. Registered values dropped after whitespace normalization

Commit: f577fdb3f7fbae0ceaa6c7f7e6ab8e560c544582

Root cause. knownForms() stored each value only in its wrap-free form, and dropped any form shorter than eight characters after stripping. As you described, add("test", ["abc defg"]) accepted the value, but it then matched nothing. A related case also failed: a value whose own gaps are wider than a wrap gap. For example, abcd + 70 spaces + efgh kept its form, but the search refuses to cross a gap of more than 64 characters, so that value could never match either.

Fix.

  • Every value and JSON-escaped form that contains wrap characters is now also searched exactly as written. This uses Knuth-Morris-Pratt over the original text, so the search stays linear.
  • The wrap-tolerant form is still used whenever at least eight characters remain after stripping.
  • Both searches feed one leftmost-longest, non-overlapping selection.
  • A value that is mostly spaces (for example a + 7 spaces + b) is matched only as written. a b or ab in ordinary text stays untouched.

Test (tests/secret-redaction.test.mjs): "registry: an accepted value is masked where it stands even when its wrap characters leave fewer than eight others".

  • Values containing a space, a tab and a newline, a mostly-space value, and a value with a 70-space gap are each masked as written and in their JSON-escaped form.
  • a b, ab, a b and abcdefg stay unchanged.
  • redactTail equals redact(text).slice(-8192) with the value placed at the tail cut and at the window cut.

2. The tail window could start inside an unbounded match

Commit: 6cd6d66acf4af214adf95c6bd7a518e820658d9d

Root cause. The window started at a line start near text.length - maxChars - margin. The design assumed every rule except PEM matches at most a few thousand characters, but several rules have no bound:

  • quoted and unquoted assignments;
  • URL query values and userinfo;
  • the \s* after Authorization:, Bearer, name = and "key":, which can cover any number of spaces or line breaks;
  • wrapped tokens that continue over many lines.

When such a match started before the window, the window saw only its middle. Your password=" + "a ".repeat(20000) + " case came back unmasked, and the fallback did not fire because masking had not shortened the window. With an empty registry and 40 000-character scrollbacks, 10 of 11 such shapes diverged from redact(text).slice(-maxChars) at 44a5fa7. A randomized comparison (300 texts × 3 tail sizes) diverged 42 times.

Fix. The window now starts at a line start that no match can cross. The search walks back over line starts from the desired cut. A line start qualifies only if all of the following hold:

  • it lies in no private-key block, using blocks found from the start of the text the way the PEM rule finds them;
  • the last non-whitespace character before it is not one that a wrapped run or a separator's whitespace continues after: a letter, a digit, one of + = _ - : " ', or the > of =>;
  • no JSON secret value (up to 2 048 characters, the one value that may contain a line break) is open across it;
  • no held-value match crosses it or covers the characters those checks read. This is checked with the same matcher over a region of knownSpan around it.

From such a line start, every pass (held values, JSON values, each rule) finds the same matches in the window as in the whole text. At the cut, a lookbehind sees a line break in the whole text and the start of input in the window, and every rule treats those alike. Masking before the cut only inserts markers ending in >, which none of these rules continue after. The tail is therefore exactly redact(text).slice(-maxChars).

The whole text is masked instead in three cases:

  • no such line start exists within 64k characters;
  • masking left less than maxChars after the cut;
  • a held value contains PEM armour and the text contains a private-key header. Masking that value can move where a PEM block ends.

Ordinary scrollback still masks a bounded window: the existing "masks a window around the tail" test keeps its bound of maxChars + 16384 + 4096.

Test: "redactTail equals masking the whole text when a match with no length bound starts before the window". It includes your case: an empty registry and the long quoted value with maxChars = 8192, compared for equality and checked for leftover a a a. The other shapes it covers:

  • single-quoted and unquoted assignments;
  • an assignment and a JSON key separated from their values by 30 000 line breaks;
  • Authorization: followed by 30 000 spaces;
  • Bearer over 15 000 CRLFs;
  • a 40 000-character URL query value and a URL userinfo;
  • a token wrapped over 500 lines;
  • a nested private-key header.

Each shape is placed so that its match ends inside the tail, just before it, or far before it, with tails of 300 and 8 192 characters. The test also covers a registry holding a private key (plain and inside JSON) with PEM blocks in the text. All existing redactTail equality tests pass unchanged.

Also checked.

  • Rules whose matches cannot contain a line break are safe at a line start (AWS, JWT, url-credentials, plain high-entropy runs, and the value part of Authorization). Quoted assignment values exclude \r\n.
  • The randomized comparison (2 700 comparisons, texts built from these fragments, blank-line runs, space runs, PEM headers and footers, and held values containing a newline) found no divergence after the fix.

Gates on 6cd6d66: npm run typecheck, the full suite (node --test --test-concurrency=2, 1040/1040), npm run build and npm run audit:secrets all pass.

The probe test compares the blocking and background answers. Both used the
3 s production limit, which a loaded machine can hit while starting the stub
CLI, so the test failed on load rather than on a wrong answer. The limit is
now a parameter (default unchanged) and the test passes 30 s.
BIackFIame added a commit to BIackFIame/CanvasTTY that referenced this pull request Sep 28, 2026
@howdeploy
howdeploy merged commit b57b9dd into howdeploy:main Sep 28, 2026
3 checks passed
howdeploy added a commit that referenced this pull request Sep 28, 2026
Integrate the reviewed performance and reliability changes from #89, #90, #92, #93, #94, and #95.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants