fix: keep PTY output flow control bounded - #66
Conversation
32ce66c to
3df4d3e
Compare
3df4d3e to
9d777bb
Compare
|
Refreshed this PR onto current |
9d777bb to
45890f0
Compare
GOODBOY008
left a comment
There was a problem hiding this comment.
Reviewed the rebased head (45890f0, which includes the maintainer rebase onto current main — conflict resolution in pty-terminal.tsx plus removal of the now-unused ptyTerminal.outputLimitReached locale keys).
Verdict: correct, and the invariant finally holds end-to-end. What I verified against the backend protocol:
- Exact credit accounting. The backend reader consumes exactly one permit per sent frame (
credits.acquire()+forget()before eachflush_output— both branches inwebsocket_server.rs), and this change returns exactly one credit per received frame:frameCountis captured at flush time and granted only from theterm.write()callback, i.e. after xterm has actually processed the batch. The old leak (creditsGrantedzeroed right after every grant, so thecreditsGranted < CREDIT_BATCH * 2guard never bound anything) is gone along with the variable. - No deadlock paths.
writeBufferandbufferedFrameCountare only mutated together synchronously; frames arriving mid-write reschedule the flush; and the empty-decode immediate grant covers the streaming decoder holding an incomplete UTF-8 sequence. I couldn't construct a sequence that loses a permit or double-grants. - Bounded under stall. If rAF stops (hidden window), credits stop returning, the backend blocks on
credits.acquire(), and buffering is capped at ~2 frames + the 256-message WS queue + the 16 KB accumulator — a few MB worst case, self-recovering on visibility. - 2 MiB reset removal is safe. Scrollback is hard-capped at
MAX_TERMINAL_SCROLLBACK = 50000lines with xterm trimming internally, so the removed guard bought nothing except destroyed history/alt-screen state (#60). - Tests are substantive — they hold the write callback to prove grants happen only after xterm processes, assert exact permit totals (2→3, 2+3, 2+1+1 for split UTF-8, 132 across >2 MiB), and verify decoder reassembly across frames.
CI is green on all three platforms. Two non-blocking notes inline.
| // Ongoing credits are managed by the watermark-based flow | ||
| // control in the flush callback above. | ||
| // Ongoing credits are returned by the flush callback above. | ||
| const INITIAL_WINDOW = 2; |
There was a problem hiding this comment.
The window of 2 is adequate today, but only because of a coupling with the backend: the reader flushes at most OUTPUT_FLUSH_BYTES = 16 KB per OUTPUT_FLUSH_INTERVAL_MS = 10, capping production at ~1.6 MB/s — just below what 2 frames per rAF cycle (~2 MB/s at 60 Hz) can drain. If either backend constant is ever raised (e.g. for high-throughput cat of large files), this window silently becomes the bottleneck and would need to scale with it. Worth a one-line note here so future tuning doesn't miss it. Non-blocking.
| if (ws.readyState === WebSocket.OPEN) { | ||
| // Send a single Resume per credit (backend Semaphore.add_permits(1)) | ||
| const msg = JSON.stringify({ type: 'Resume', connection_id: connectionId }); | ||
| for (let i = 0; i < count; i++) { |
There was a problem hiding this comment.
Each credit is its own WS message, so a burst of N frames flushed together (e.g. output queued while the window was hidden) sends N tiny JSON Resume frames back-to-back. Fine at current volumes, but a natural follow-up would be extending the protocol to Resume { connection_id, count } with the backend doing add_permits(count) — the frontend already knows the batch size here. Non-blocking.
| // producing text. It has already consumed the frame, so return that | ||
| // credit immediately to avoid stalling on multi-frame characters. | ||
| if (!text) { | ||
| grantCredits(1); |
There was a problem hiding this comment.
Nice catch — without this immediate return, a multi-byte character split across frames would park a permit in the decoder forever (the frame is consumed but produces no text), eventually starving the backend reader after INITIAL_WINDOW such frames. The split-中 regression test covers exactly this.
GOODBOY008
left a comment
There was a problem hiding this comment.
Approving after full review (see previous COMMENTED review for the detailed verification): credit invariant holds end-to-end against the backend protocol, no permit-loss paths, bounded under rAF stall, 2 MiB reset removal safe under the 50k-line scrollback cap, substantive regression tests, CI green on all platforms. The two inline notes are non-blocking follow-ups (Resume batch count, INITIAL_WINDOW coupling note). CI: green.
Summary
reset()/clear()path and rely on the existing bounded xterm scrollbackRoot cause
The backend consumes one semaphore permit per output frame, but the frontend replenished a fixed four permits after processing any low-water batch.
creditsGrantedwas never decremented when frames arrived and was reset immediately after granting, so a low-rate stream could grow the backend credit window by roughly three permits per frame.The separate 2 MiB guard counted all decoded output since the last clear, not retained terminal memory. Normal sustained output eventually called both
term.reset()andterm.clear()even though xterm scrollback is already bounded by its configured line limit.After this change, the invariant is: available credits plus frames awaiting xterm completion remain equal to the initial window. An incomplete UTF-8 sequence retains only the decoder's pending bytes and returns that frame credit immediately, avoiding deadlock.
Related work
Validation
Reproduced on current
main(547e1bb) before the fix:Resumemessages instead of 3 totalterm.reset()called oncePassing after the fix:
pnpm test -- src/__tests__/pty-terminal-activation.test.tsx src/__tests__/terminal-scrollback.test.ts src/__tests__/terminal-tab-portals.test.tsx- 14 passedpnpm test- 569 passedpnpm exec tsc --noEmitpnpm i18n:check- 950 keys in both localespnpm buildpnpm exec eslint src/components/pty-terminal.tsx --quietgit diff --checkRepository-wide
pnpm lintremains blocked by the pre-existingsrc/components/settings-modal.tsx:308no-unnecessary-type-assertionerror; the changed source file passes strict lint. Localcargo testreached the unrelatedopenssl-sysbuild script and exceeded the 304-second validation limit; this PR changes no Rust files, and PR CI runs the Rust suite on Windows, macOS, and Ubuntu.Risk / rollback
The change reduces the maximum in-flight output to the existing two-frame window. If xterm stops completing writes, output backpressures instead of accumulating permits. Rollback is the single commit in this PR.