Skip to content

Transport: output flow control from the renderer back to the PTY - #1722

Draft
nedtwigg wants to merge 22 commits into
mainfrom
flow-control
Draft

nedtwigg wants to merge 22 commits into
mainfrom
flow-control

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

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 discarded once 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: yes filled 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.

  1. The renderer acknowledges what it parsed. Every char of live pty:data is 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.

  2. 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 write blocks; at the low watermark it resumes.

  3. 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.

  4. 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.

  5. Only the local renderer exerts backpressure. A phone can never pause a PTY on the laptop; Pocket's and the fake adapter's acknowledgePtyData are 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.

  6. terminal.write is wrapped so a throw cannot escape the dispatch loop, and a refused chunk is still acknowledged.

  7. 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 visible yes is 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_ack in standalone), { id, chars }; new required PlatformAdapter.acknowledgePtyData. Rules are in docs/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.

Before After
yes in real Chrome: xterm's queue 50 M chars (discard) within 4 s Stays under 1.84 M chars
Ctrl+C on a yes flood (real node-pty) 4 to 25 s 48 to 77 ms
Ctrl+C in real Chrome No prompt after 2 minutes 128 ms
Throughput, 80-column log lines Baseline Within 4% at 2 ms each way; 19% slower at 5 ms

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

  • The counter is not in pty-core. The renderer can only acknowledge the parsed data, and the host-side parse strips OSCs, so a raw count would never be paid back. It sits where each host sends pty:data (lib/src/host/pty-flow-host.ts): the sidecar, and VS Code's extension host, which tells its pty-host child to pause.
  • Standalone's Rust acknowledges output it drops (mid-transfer, or no owner), or a suppressed transfer would leave the PTY paused.
  • macOS reads a paused PTY once every 150 ms. When a PTY's only process exits while paused, macOS holds the exit about 610 ms and then discards everything past the first 1 KB of unread output (reproduced with 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 paced dor send could 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/main and 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 of pty-host.js, and the Rust drop-acknowledgement and adapter tests. A compile-time check fails tsc -b if acknowledgePtyData becomes 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.

  • The first run found that Ctrl+C does not reach a program whose PTY is paused. A probe separated the causes: never paused, the child exits 14 to 17 ms after \x03 or 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).
  • After it: standalone/src/sidecar-flow-control.test.ts passes 7 of 7 three runs in a row (one case is POSIX-only and skipped), vscode-ext/test/pty-host-flow.test.ts passes, and the rule's unit tests pass.
  • CI's Linux leg runs the real-PTY sidecar tests and is green.

Not verified

  • Whether Tauri's webview reports hidden when 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.
  • An inline image whose decode takes over 12 ms resumes on xterm's timer, one throttled wake per image, while hidden.
  • A real Tauri window, and a minimized one on Windows or Linux, the case that motivated this: pnpm dev:standalone, run yes, 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

nedtwigg and others added 9 commits November 11, 2026 00:35
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]>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9c8a59a
Status: ✅  Deploy successful!
Preview URL: https://012329e9.mouseterm.pages.dev
Branch Preview URL: https://flow-control.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

nedtwigg and others added 7 commits November 11, 2026 01:54
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 dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/specs/transport.md Resolved
nedtwigg and others added 3 commits November 11, 2026 03:21
… 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 dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/src/lib/parse-on-arrival.ts Resolved
Comment thread lib/src/lib/parse-on-arrival.ts Resolved
nedtwigg and others added 3 commits November 11, 2026 03:45
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]>

This branch has not been deployed

No deployments
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