Repository navigation
fix(pty): pace the EAGAIN write retry so a stalled reader can't saturate the daemon thread - #15319
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📥 CommitsReviewing files that changed from the base of the PR and between 4fc429e95853f51efd484b99e451c932389a1103 and 26cbdda. ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (1)
Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review. 📝 WalkthroughWalkthroughThe node-pty patch updates Linux and Windows build compatibility. It adds glibc symbol pins, close-on-exec handling, and guarded PTY cleanup. Unix writes use timed EAGAIN retries and retired file descriptors. ConPTY gains fallback enumeration, synchronized job management, and lifecycle controls. Windows socket errors trigger terminal cleanup. macOS PTY spawning reports structured operation-specific errors. Priority: ⬆️ High Severity of issue fixed: High Merge Risk: ⚪ Minimal · up to The patched retry behavior consistently uses the intended 1 ms backoff in both source and shipped code. No actionable current-head risk remains. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Description checkExplanation The description is detailed and explains the change, rationale, testing, platform impact, AI use, and checklist status. However, it omits the required Linked Issue section and issue reference, and it does not provide the required Review, Agent skill upstream boundary, or Notes sections. The stated 1 ms delay also conflicts with the objective summary, which describes a 5 ms delay. Resolution Add the required template sections, especially Linked Issue with the applicable issue reference. Resolve the 1 ms versus 5 ms delay discrepancy so the description matches the implemented change and PR objectives. Complete or explicitly mark the remaining required sections as applicable or not applicable.
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
config/patches/node-pty@1.1.0.patch (1)
9-11: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winRestore the Windows Spectre mitigation setting
Restore
SpectreMitigation: 'Spectre'inbinding.gypand both Windows targets indeps/winpty/src/winpty.gyp.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ea8b7170-10ee-4cf5-8783-578ff792b383
📥 Commits
Reviewing files that changed from the base of the PR and between 2f0f9a8 and b2c77b7d891255397a0c79276ef8f1ae89c7e8ab.
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (1)
config/patches/node-pty@1.1.0.patch
Included review availability: Your plan includes up to 10 reviews per rolling hour; 9 remain after this review.
|
Thanks — one of these is a real bug and I've fixed it. The other is outside this diff. I also have a correction to make to my own numbers. 1. Disposal race — valid, fixedYou're right, and it's worse than the note I left in #11178. I'd only spotted that I reproduced it before fixing it. Instrumenting
(329 EAGAINs before disposal in the guarded run, so the race window was genuinely exercised rather than skipped.) Two things worth recording: the race is pre-existing — stock node-pty does it too, identically — but this PR widens the window, because a 5 ms retry gives the fd far more time to be closed than a next-tick Fixed as you suggested, in both One judgement call I did not make silently: 2. Spectre mitigation — pre-existing, not part of this changeThis one is a false positive for this PR. Lines 1–30 of The bot appears to be reviewing the whole patch file rather than the diff, which is an easy trap with Whether that mitigation should be restored is a fair question for the owners of that patch, but it's not something this PR should change silently while fixing an unrelated spin. 3. Correction to my own numbersWhile re-testing I found an error in my original description, and I'd rather flag it than let it stand. I claimed the fix writes "11.5× more bytes". That is wrong — it came from a single run, and repeat trials do not reproduce it. Bytes written in 12 s lands between 65 536 and 393 216 in both arms, with no systematic difference. I've struck that claim from the description. The real result holds up, and here it is across trials rather than from one run:
So the reduction is ~280–690× depending on how loaded the machine is, not the single 685× figure I first quoted. The baseline is noisier than I represented; the patched arm is remarkably stable. |
10cedf0 to
0d7d9a5
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Team
Run ID: 49e2ae95-8a8b-4d6a-b8e0-55c052d6bacc
📥 Commits
Reviewing files that changed from the base of the PR and between b025367 and 0d7d9a5fbf58aba679e0ba3fa63df69de736e8fb.
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (1)
config/patches/node-pty@1.1.0.patch
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.
…ate the daemon thread node-pty's CustomWriteStream retries an EAGAIN write with setImmediate, which re-attempts within microseconds. A pty whose child has stopped draining stdin keeps that branch EAGAIN-ing, so the retry becomes a busy-loop on the thread that owns every pty on the runtime. Measured against this commit's parent on macOS arm64: 121,316 EAGAIN/s at 101.6% CPU, versus 805/s at 4.1% with the retry paced to 1ms. The delay is 1ms rather than longer because the cost lands on readers that drain in bursts -- what an agent does between event-loop ticks. Delivering 2MB to a reader that drains 20ms out of every 100ms: 689ms unpaced, 907ms at 1ms, 1414ms at 5ms. 1ms keeps essentially all of the CPU saving without the delivery regression. clearImmediate -> clearTimeout in dispose() is required, not cosmetic: once the handle is a Timeout, clearImmediate does not cancel it and a pending retry can fire after dispose. The disposal guards that make that harmless (_fd = -1, queue drop) are already on main; this mirrors them into src/unixTerminal.ts so the TypeScript twin no longer drifts from the compiled lib. Scope: this fixes the CPU saturation. It does not stop other terminals from being serviced -- a second live pty kept answering echo round-trips throughout the storm in every configuration tested (1 and 8 stalled writers, macOS and Linux, 8 CPUs and 1), with throughput down ~20-50% rather than hung. The "every terminal froze" symptom in stablyai#11178 has another cause and that issue stays open. Upstream chose setImmediate deliberately (microsoft/node-pty#831, stablyai#833) to fix large-paste latency, and rejected polling POLLOUT because it reports writable rather than flushed. That reasoning targets a per-write delay in an interactive terminal; this delays only the EAGAIN branch in a long-lived daemon. Pastes to a draining reader are unaffected (0-3 EAGAINs per MB in every arm). Verified: patch applies to a pristine node-pty@1.1.0 tarball, the patched src/unixTerminal.ts compiles byte-identical to the patched lib/unixTerminal.js, patch_hash matches the file, and on Windows the changed code never executes (WindowsTerminal, 0 EAGAINs on a 300KB conpty write).
4fc429e to
26cbdda
Compare
|
Merged as I changed two things before merging, so flagging them rather than letting you find them in the log. The delay is 1 ms, not 5 msI built a harness against real 2 MB to a reader that drains 20 ms out of every 100 ms, macOS arm64:
And fully stalled, 8 s storm in isolation: main 121,316 EAGAIN/s @ 101.6% CPU; 1 ms 805/s @ 4.1%; 5 ms 178/s @ 1.6%. So 5 ms buys a few more points of CPU on a case that 1 ms has already fixed, and pays for it with 2–3× slower prompt delivery on the case every busy agent hits constantly. A steady-rate sweep confirms the penalty band is narrow — a reader draining at 17 MB/s or at 48 KB/s is unaffected at any delay — but bursty readers sit inside it. Also worth knowing for your upstream PR: microsoft/node-pty#833 shows Tyriar chose The description was rewrittenThree claims didn't survive verification:
Everything else held up, including all four patch-mechanics checks. I added Windows verification on a real box: the changed code never executes there ( Your commit authorship is preserved on the squash. The original 5 ms revision is still at |
ELI5
When Orca writes to a terminal whose program has stopped reading, the kernel says "not now, buffer is full" (
EAGAIN). Today we immediately ask again — over 100,000 times a second — which pins a CPU core on the daemon thread that owns every terminal on the runtime. This waits 1 ms before asking again: ~150× less spinning, and the CPU goes from ~101% to ~4%.What Changed
Two hunks in
config/patches/node-pty@1.1.0.patch, applied to both the compiledlib/unixTerminal.jsand itssrc/unixTerminal.tstwin, plus the resultingpatch_hashinpnpm-lock.yaml.clearImmediate→clearTimeoutis required, not cosmetic: once the handle is aTimeout,clearImmediatedoes not cancel it (verified directly on node 24), so a pending retry can fire after dispose. The guards that make that harmless —_fd = -1and dropping the queue — are already on main; this mirrors them intosrc/unixTerminal.ts, which had drifted from the compiledlib.Why 1 ms
The retry delay is not free: it costs delivery time to any reader that is draining slowly rather than not at all. A real agent drains in bursts — hard during an event-loop tick, not at all while blocked — which lands squarely in that range. Delivering 2 MB to a reader that drains 20 ms out of every 100 ms, macOS arm64:
setImmediate)And with the reader fully stopped (the pathological case this fixes), 8 s storm in isolation:
1 ms takes essentially the whole CPU win without the delivery regression. Steady-rate sweeps agree: a reader draining at 17 MB/s or at 48 KB/s is unaffected by any delay value; only the ~0.2–1 MB/s band pays, and 5 ms costs 2.6× there where 1 ms costs ~4%.
Reproduced on Linux (Docker, native arm64 and emulated x64) with the same ordering; the Linux curve is flatter (main 43% CPU, 1 ms 19%, 5 ms 7%).
Scope — what this does not fix
It does not unfreeze other terminals. A second live pty doing continuous echo round-trips kept answering throughout the storm in every configuration tested — 1 and 8 stalled writers, macOS and Linux, 8 CPUs and
--cpus=1. Throughput fell ~20% (one stalled writer) to ~50% (eight), and max event-loop lag stayed ≤140 ms. Nothing hung.So this fixes the CPU saturation in #11178, not the reported "every terminal froze for ~17 minutes, no input accepted". #11178 should stay open. Two follow-ups worth filing separately: node-pty's write queue is unbounded while Orca allows 16 MB per send, so a wedged child silently accumulates memory in the daemon; and Orca has no "this terminal is not accepting input" signal, so a wedged agent is indistinguishable from a frozen Orca.
Upstream
setImmediatethere is deliberate. In microsoft/node-pty #831/#833 the maintainer removed JS-sidesetTimeoutthrottling to fix large-paste corruption and a macOS hang (50 KB paste: ~5 s → ~500 ms), and explicitly planned to "move tosetImmediateon EAGAIN". He also tried pollingPOLLOUTand abandoned it — it reports writable, not flushed, and node'sfs.writegoes through the libuv threadpool.That reasoning targets a delay on every write in an interactive terminal. This delays only the EAGAIN branch, in a long-lived daemon whose children block for minutes at a time. Pastes to a draining reader are unaffected: 0–3 EAGAINs per MB in every arm, and a 50 KB paste to a fast reader lands in 3–6 ms regardless of delay.
microsoft/node-pty#956 carries the same idea at 5 ms; it is CLA-blocked and unreviewed.
Testing
Both arms built from the pristine
node-pty@1.1.0tarball with the respective patch revisions applied viagit apply, sharing one native binary; EAGAINs counted by wrappingfs.writebefore requiring node-pty, so the code under test is unmodified. Harness scenarios: stalled reader, slow steady reader (drain-rate sweep), bursty reader, fast-reader paste, live-victim terminal, disposal race.Disposal,
destroy()mid-storm: pristine node-pty leaves 1 post-dispose write (EBADF); main and this branch leave 0, with 341 EAGAINs before disposal so the window was exercised.Patch mechanics, since pnpm applies this file rather than CI compiling it:
node-pty@1.1.0tarball; every section other than the two touched files is byte-identical to main's patchsrc/unixTerminal.tscompiled with node-pty's own TypeScript 3.8.3 is byte-identical to the patchedlib/unixTerminal.jspatch_hashinpnpm-lock.yamlis the sha256 of the patch file, andpnpm installapplied it end-to-end (node_modules showsEAGAIN_RETRY_DELAY_MS = 1in both JS and TS)Platforms. macOS arm64 and Linux (arm64 + x64) measured. Windows verified on a real machine: the patch applies under
core.autocrlf=truewith a byte-identical hash (.gitattributespins-text),windowsTerminal.jsreferences neitherCustomWriteStreamnorunixTerminal, and a live conpty run reportsWindowsTerminalwith 0 EAGAINs on a 300 KB write. The changed code provably never executes there.AI Disclosure
Diagnosis, patch, and this description were produced with Claude Opus (Claude Code). The measurements are real runs.
Checklist
N/A, no UI surface; the observable change is CPU behaviour, quantified above