Repository navigation
Conversation
Every adapter now dispatches live `pty:data` through one shared `PtyDataDispatch`: the terminal that renders a chunk claims it and passes the claim as xterm's write callback, so the chunk is acknowledged once parsed, in batches; a chunk nothing claimed is acknowledged at once. A handler that throws no longer stops the others or escapes the transport, and a write xterm refuses (its 50M-char discard) is still acknowledged. The new required `PlatformAdapter.acknowledgePtyData` is a no-op on every adapter for now; the hosts' halves follow. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The sidecar counts what it sends each window as `pty:data`, after its parse, so the count is in the units the renderer acknowledges. Past the high watermark it pauses the PTY; at the low one it resumes it. A window re-asking, a transfer mark, a spawn over the id and the quit's recovery capture forgive the debt and resume; a kill or reap drops it. pty-core gains `pause` / `resume`. Every stop resumes first, since node-pty finishes its teardown by reading, and a paused PTY whose process is gone is resumed so its tail is read before node-pty reports the exit (it otherwise destroys the unread socket 200 ms after the exit on POSIX, and reports no exit at all on Windows). The real-PTY tests run the sidecar as main.js composes it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Both standalone adapters send each batch the shared dispatch collects as `pty_ack`, which Rust forwards as `pty:ack` (and the browser-dev harness the same way). Rust also acknowledges, on the window's behalf, every `pty:data` it drops — mid-transfer, or owned by no window — since nothing else would ever pay it back, counting UTF-16 code units as the sidecar does. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The webview posts each batch the shared dispatch collects as `pty:ack`. The extension host counts what it posts the owning webview as `pty:data`, takes acknowledgements from that webview only, and pauses or resumes the PTY in the pty-host child, which never forks for it. A webview re-initializing, a view leaving its PTYs running, a respawn and the recovery capture's interrupt forgive the debt; a kill drops it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
transport.md gains "Output flow control" beside "PTY buffering": what is acknowledged and when, the watermarks, what forgives a debt, a paused PTY's exit and stop, and that the remote path is not yet flow-controlled. The message table gains `pty:ack` / `pty_ack`. The rationale records why, and what node-pty does with a paused PTY at exit on each platform. transport.md is now over its word budget (5014 of 4700). Co-Authored-By: Claude Opus 5.5 <[email protected]>
256 Ki chars per acknowledgement, resume at 1 Mi, pause past 2 Mi. Measured on real node-pty, pty-core and an xterm parse over IPC: within 4% of the unbounded path at 2 ms each way (VS Code's 5,000 / 5,000 / 100,000 cost 55% on 80-column logs), a Ctrl+C on a `yes` flood taking effect in 48-77 ms instead of 4-25 s, and at most about 4.2 MB held per terminal. The rationale records the runs. Co-Authored-By: Claude Opus 5.5 <[email protected]>
`scripts/workspace-sources.test.mjs` requires every package whose TypeScript imports a `node:` builtin to pin the typings itself, at the workspace's range. Co-Authored-By: Claude Opus 5.5 <[email protected]>
In Chrome with the WebGL renderer, a `yes` flood parsed at 10.8 million chars a second with xterm's queue bounded under 1.84 million and a Ctrl+C reaching the prompt in 128 ms. With every acknowledgement forgiven, the queue hit xterm's 50-million-char discard within 4 s and a Ctrl+C had not taken effect two minutes later. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…audit-finding IDs from the text The flow-control section adds about 330 words to transport.md. The remote path's own bounds are stated where they live, so the sentence here says only that this mechanism never paces a Client's stream. Co-Authored-By: Claude Fable 5.2 <[email protected]>
Deploying mouseterm with
|
| Latest commit: |
9c8a59a
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://012329e9.mouseterm.pages.dev |
| Branch Preview URL: | https://flow-control.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
A hidden VS Code view now throttles its programs, not just its rendering. The extension keeps webviews alive when hidden (retainContextWhenHidden: true in vscode-ext/src/extension.ts), and transport.rationale.md -> "Why not the webview" notes that a hidden webview's timers fire about once a second. xterm parses in 12 ms slices that a timer re-arms, so a hidden view parses roughly one slice per second. At the 10.8 M chars/s this PR measured for a visible page, that comes to around 130 K chars/s. That figure is my extrapolation, not a measurement. Before this change, output from a PTY in a hidden panel queued in the webview and the program ran at full speed. Now, once a hidden view owes 2 M chars, sentOutput -> pause blocks the program's write until the view catches up. A verbose build, a test run, or an agent printing heavily in a collapsed Dormouse panel slows to that rate for as long as the panel stays hidden. If Chromium's intensive throttling applies to the hidden frame, it slows further. VS Code's own terminal doesn't do this: its renderer runs in the visible workbench window, so the scheme it borrows never sees a throttled renderer.
The PR names the macOS tail loss as the main trade-off but doesn't mention this one. It applies to every VS Code user, not only to a flood racing an exit. Minimized standalone windows on Windows and Linux are the same case, already listed under "Not verified". A measurement would settle how large the effect is: time seq 1 5000000 printed to the terminal in a hidden panel versus a visible one. If it is significant, the host already knows when a view is hidden (the shown port in webview-view-provider.ts / extension.ts). It could forgive the debt while the view is hidden and resume counting when it is shown again. Whether to do that is a maintainer's call between bounded memory and background throughput.
On Windows 11 five cases judged the PTY paused while its `node` program had not started: ConPTY's setup sequences (23 chars) sat quiet longer than the 400 ms the wait allowed. The wait now needs the program to have written and the PTY to have forwarded past a floor first. The fixture program takes an explicit mode and no empty argument, which does not survive every command line. Ctrl+C goes as Dormouse's renderer sends it: win32-input-mode key records once ConPTY asks for them, otherwise `\x03`. Every failure prints the PTY's first 200 chars and the program's own count. The one POSIX-only case is `yes` itself; its every-platform twin floods with `node`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Probed on Windows 11: ConPTY takes no input while its output is blocked, so a paused PTY never received the Ctrl+C written to it, whatever its encoding; resumed right after, the program ended in 16 ms. POSIX's line discipline delivers it to the blocked program, as before. On `win32` every write to a paused PTY now resumes it, in both hosts (the sidecar's writer, VS Code's `pty-manager.write`), and it pauses again once no input has come for 250 ms if its renderer still owes past the high watermark. Output read meanwhile is counted, and at twice the high watermark input resumes nothing and the PTY pauses: the input still goes in, and arrives once the renderer catches up. The recovery capture's interrupt already forgives and resumes, and every stop resumes first. Co-Authored-By: Claude Opus 5.5 <[email protected]>
When a PTY's leader exits with more than about 1 KB unread, macOS holds the exit 615-655 ms and then discards everything past the first 1,024 bytes, with nothing visible to the exit watch. The flow controller now resumes a paused PTY there for one read every 150 ms, counted like any read, and stops at the same ceiling of twice the high watermark; a reset, kill, or catching up ends it. The controller gained a dispose, which the sidecar host calls. The real-PTY tail test now writes a 1,500-char tail, which without the peeks arrives cut at 1,024; its waits tolerate the trickle. The rationale records the probe and corrects "unread" to "past about 1 KB". Co-Authored-By: Claude Opus 5.5 <[email protected]>
Deleting the acknowledgement in `dispatch_sidecar_event`'s `Delivery::Nowhere` arm left every test green. The dispatch needs a running app, so a source test reads that arm, as the other wiring tests in `lib.rs` do. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The grace was armed once per write, but `pty-core` writes paced text in 256-byte runs 10 ms apart, so a long `dor send`, or a `--key ctrl-c` behind it, landed after the PTY had paused again. `pty-core` now reports each slice it writes from its queue (`onInputWritten`); the sidecar hands it to its flow control, and VS Code's pty host sends it to the extension host as `inputWritten`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ewer Windows input and the macOS peeks read a paused PTY on up to twice the high watermark, so that, not the high watermark plus one read, bounds what sits between node-pty and xterm. A paused PTY also holds back a Client's stream, which the old sentence denied. The rationale's worst case follows: about 8.4 MB. Co-Authored-By: Claude Opus 5.5 <[email protected]>
`standalone/src/sidecar-flow-control.test.ts` and `vscode-ext/test/pty-host-flow.test.ts` exercise ConPTY's pause, input through a paused PTY, and a paused PTY's tail and exit, which only Windows can show. One step in `standalone-platform-check`, after the PTY shutdown tests, under bash so the first failure stops it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
dormouse-bot
left a comment
There was a problem hiding this comment.
This push turns the spec's "a Client's stream is never paced" into "a PTY it pauses slows every viewer, a Client's stream too". That makes the laptop's renderer the pace of a phone session, and it slows down exactly when nobody is at the laptop. The note is on the spec line.
… test xtermWriteSlice runs one of xterm's own 12 ms parse slices now, through the private _innerWrite, and never while an async parser handler's continuation is owed (_parseStack.paused). It feature-detects both members and falls back to timers, logging once, when either is missing. The test pins them on the pinned xterm with the real image addon's async inline-image handler. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A hidden page's timers fire once a second, then once a minute, so a paced PTY behind a timers-only parser crawled. parseOnArrival runs one of xterm's own slices per arriving chunk, in a microtask, while the page is hidden or once written output has waited a second with no write finishing while more arrives (a host that throttles without reporting hidden). It hands back to xterm's timers once the queue is empty and the page is visible. Write callbacks, and so acknowledgements, are unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
One rule in Output flow control, within the word budget, and the rationale's before and after from the browser-dev harness: a hidden yes flood parsed about 540 chars a second on timers alone once Chrome reached its once-a-minute tier, and 9.2 million a second parsing a slice per arrival. Co-Authored-By: Claude Opus 5.5 <[email protected]>
dormouse-bot
left a comment
There was a problem hiding this comment.
parseOnArrival only treats the page as hidden when document.hidden is true. The hidden VS Code view this push answers usually doesn't report that. VS Code's extension posts dormouse:shown for this reason, and lib/src/lib/host-shown.ts exists for it too. That leaves the starvation trigger to cover the case, which the PR's "Not verified" already flags. Under a 1 Hz timer throttle the trigger is borderline: every throttled wake finishes a write and resets progressAt, so an arrival rarely sees a full PARSE_STARVED_MS gap, and parsing stays at one slice per wake. That's my inference, not a measurement. Checking hostShown() beside document.hidden covers the view without depending on the threshold; suggestion inline.
On a fresh Windows checkout a cold node start under Defender left the first flood barely begun three seconds in, and a quiet spell before the flood reached ConPTY passed for a pause. Each real-PTY case now gives its program 60 s to start, waits for the host's own pause rather than a quiet spell, and fails a phase only when nothing more is forwarded or written for 15 s, naming the phase. The VS Code host's flood gets the same start allowance. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A hidden VS Code view kept alive by retainContextWhenHidden can still report document.hidden false, while its extension posts dormouse:shown. Every other host leaves hostShown() true. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A host that throttles a page to one timer a second finishes a write at every wake, so at a one-second threshold the starvation trigger fell silent and a hidden tab reporting visible parsed yes at about 50,000 chars a second. At 250 ms it parsed about 4 million, and a visible yes kept its parse rate and 60 frames a second; parsing on every arrival in a visible window cost it frames, so the threshold stays above a frame. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Draft, for the maintainer's read. Audit finding
session-03: nothing between the PTY and xterm.js applied backpressure.The problem
Every chunk of PTY output was pushed into xterm with no accounting. xterm parses in 12 ms slices and throws
write data discardedonce 50,000,000 chars wait unparsed. So a flood (yes, a runaway print loop), or ordinary output into a window whose timers are throttled (a minimized standalone window on Windows or Linux), queued without bound, then lost output and raised an uncaught error per chunk.Measured in real Chrome through the innerdogfood harness with acknowledgements disabled, which is the old behavior:
yesfilled xterm's queue to the 50 M discard within 4 seconds, and Ctrl+C had not reached a prompt after 2 minutes.The design
The scheme VS Code's own terminal uses: acknowledged characters, and the host decides.
The renderer acknowledges what it parsed. Every char of live
pty:datais acknowledged exactly once: from xterm's write callback in batches, or at once when no terminal took it or xterm refused it. Never from a timer, so a throttled window acknowledges as fast as it parses. Replays are not counted.The host counts and pauses. Each Node-resident host keeps, per PTY, chars sent minus chars acknowledged. Above the high watermark it pauses node-pty, the kernel buffer fills and the program's
writeblocks; at the low watermark it resumes.The host owns recovery. The debt is forgiven and the PTY resumed on a
requestInit, a transfer mark, a spawn over the id, a VS Code view going away, and the recovery capture's interrupt. A PTY never stays paused because an acknowledgement was lost.Input reaches a paused program. On macOS and Linux the kernel delivers an interrupt to a blocked program by itself. On Windows it cannot: the console host stops taking input while it is blocked on a full output pipe (see below). So on Windows any input written to a paused PTY resumes reading, pausing again after 250 ms without input, with a hard ceiling at twice the high watermark so memory stays bounded however much input arrives, from a keyboard or a phone.
Only the local renderer exerts backpressure. A phone can never pause a PTY on the laptop; Pocket's and the fake adapter's
acknowledgePtyDataare no-ops. The remote path's own bounds are Remote control: coalesce terminal output to a phone, and bound what the Relay queues for a stalled socket #1721.terminal.writeis wrapped so a throw cannot escape the dispatch loop, and a refused chunk is still acknowledged.A hidden or starved renderer parses on arrival, not on timers. xterm only parses from a timer, and a hidden page's timers are throttled: in a Chrome tab hidden for over a minute, a renderer with a backlog parsed about 540 chars/s. Pacing alone would therefore stall a program in a minimized window on Windows or Linux once it got ahead, and a phone attached to that pane with it. So when the page is hidden, when the host says its view is not shown (VS Code keeps a hidden webview alive and such a page still reports visible), or when a write has waited 250 ms with no parse progress, each arriving chunk parses one slice synchronously; message delivery is not throttled. It hands back to xterm's timers once the queue is empty and the page is visible, so a healthy visible window is untouched. Measured hidden for 7 minutes under
yes: 9.2 M chars/s with the queue at most 1 KB. In a hidden tab forced to report visible, the 250 ms starvation trigger alone gives 3.2 to 4.4 M chars/s (a 1 s threshold went quiet at 45 to 70 K chars/s, because each once-a-second wake finished a write). A visibleyesis unchanged: about 12 M chars/s at 60 fps, p95 frame 17 ms; parsing on arrival always would cost frames (36 fps), which is why it is conditional. The reach into xterm's internals (_innerWrite,_parseStack.paused) lives in one module,lib/src/lib/xterm-write-slice.ts, which feature-detects them and falls back to timers with one warning; a test pins their shape against the installed xterm, so a bump cannot silently turn this off.New message
pty:ack(pty_ackin standalone),{ id, chars }; new requiredPlatformAdapter.acknowledgePtyData. Rules are indocs/specs/transport.md-> "Output flow control".Numbers
Constants: acknowledge every 262,144 chars; resume at 1,048,576; pause at 2,097,152. At most about 4.2 MB is held per terminal.
yesin real Chrome: xterm's queueyesflood (real node-pty)VS Code's own constants (acknowledge every 5,000, pause at 100,000) cost 55% on log lines over this IPC, which is why they are larger here.
The trade-off for a phone
A paused PTY slows every viewer, a phone included, and a phone is usually in use because the laptop is unattended. Parse on arrival is what keeps that from mattering: a minimized window or a hidden view keeps up, so it does not pause its PTYs. What remains by design is a renderer that is truly hung: its PTYs stay paused for every viewer until it is reloaded or closed.
Where the design bent
pty-core. The renderer can only acknowledge the parseddata, and the host-side parse strips OSCs, so a raw count would never be paid back. It sits where each host sendspty:data(lib/src/host/pty-flow-host.ts): the sidecar, and VS Code's extension host, which tells its pty-host child to pause.cat,zsh -c, python3, perl and node as the leader). The periodic read lets the exit through with its tail; it stops at the same ceiling of twice the high watermark as the Windows input rule. A test with a 1,500-char tail fails without it.Independent review
A second agent with no part in writing this reviewed the branch read-only. It found no path on which a PTY stays paused forever, no counting drift, and one unit (UTF-16 code units of
data) at every hop. Its findings, all fixed here: the macOS loss above was wider than first claimed; nothing tested that Rust acknowledges what it drops (deleting the call left every test green); a long paceddor sendcould outlive the Windows input grace; the spec's stated bound was wrong once the grace existed; CI's Windows leg did not run the real-PTY cases. Its last point, that a hidden window would pace its programs to throttled timers, is what item 7 answers.Tests
Each was run against
origin/mainand fails there: four lib tests (batched acknowledgement, an unconsumed chunk, a throwing write, replays), eight real-node-pty sidecar tests (six can fail on old code, for example "expected 40500000 to be ≤ 165536"), five VS Code manager and router tests plus a real-PTY test ofpty-host.js, and the Rust drop-acknowledgement and adapter tests. A compile-time check failstsc -bifacknowledgePtyDatabecomes optional.Suites: lib 7180, standalone 323, sidecar 254, vscode-ext 275, cargo 182.
Windows, on a real machine
Run by hand on Windows 11 (node-pty 1.2.0-beta.18, ConPTY through
conpty.dll), since CI's Windows leg does not run these files.\x03or the win32-input-mode key record; paused and left paused, it is still alive after 2,500 ms with nothing read; paused and then resumed, it exits in 16 ms after about 290 K chars are read. The Windows input rule above is the fix (6f6dbec59).standalone/src/sidecar-flow-control.test.tspasses 7 of 7 three runs in a row (one case is POSIX-only and skipped),vscode-ext/test/pty-host-flow.test.tspasses, and the rule's unit tests pass.Not verified
hiddenwhen minimized on Windows and Linux. If it does not, the starvation trigger takes over; that path is measured in a hidden Chrome tab forced to report visible, not observed in a real minimized Tauri window. VS Code's hidden view is covered by the host's own signal.pnpm dev:standalone, runyes, minimize for a minute, restore. The log should show no dropped output, and Ctrl+C should give a prompt within a second.transport.md's word budget is re-baselined from 4700 to 5050 for the new section.🤖 Generated with Claude Code