fix: bound the PTY drain, drop the viewer's theme transition, correct the MSRV - #200
Open
vitorbaptista wants to merge 3 commits into
Open
fix: bound the PTY drain, drop the viewer's theme transition, correct the MSRV#200vitorbaptista wants to merge 3 commits into
vitorbaptista wants to merge 3 commits into
Conversation
vitorbaptista
force-pushed
the
fix/pty-drain-and-theme-race
branch
2 times, most recently
from
August 16, 2026 13:37
c278a61 to
7a35951
Compare
`rust-version = "1.75"` had not been true for some time: `idna`, via `url` and `reqwest`, pulls in `icu_*` 2.x, which declares 1.86. Nothing caught it, because CI builds on stable - so anyone on 1.75 through 1.85 got a compile error out of rustc rather than cargo's clean "package requires a newer rustc". Declaring the real floor also lets clippy apply the lints it had been holding back, which turns out to include the raw-pointer borrows in the raw-mode guard. `&raw` says what those always meant: take a pointer, without a reference to the terminal settings existing even briefly. The local rename comes with it - `&raw const raw` reads like a mistake.
`shellshare exec -- sh -c 'trap "" HUP; (trap "" HUP; sleep 600) & exit 3'`
never returned: rc=137 after SIGKILL, no `{"event":"end"}`. Plain
`shellshare` hangs the same way when the user leaves a quiet background
job behind and exits the shell.
The command exiting is not the end of its terminal. Anything it left
running holds the slave open, so the master never reaches EOF and the
reader sits in `read()`. The drain watchdog was supposed to bound that,
but it only flips a flag, and a thread parked in a blocking read never
gets back to the top of the loop to see it - so `stream_thread.join()`
waited forever, and no signal could clear it. The watchdog's comment
named `ping example.com & exit` as the case it handled, and for that one
it did: a chatty orphan's writes keep returning from `read()`, and the
flag is checked between them. Silence is what it never survived.
So wait for the terminal with a deadline instead of waiting on it
indefinitely: `poll` for readability, 100ms at a time, and the flag
becomes reachable again. This costs no output latency - the wait ends
the moment there are bytes - and measures as noise on 1.4MB through the
PTY (492/478/492/490ms against a 451/484/514/483ms baseline). Hang-up
must fall through to the read rather than end the loop: the last output
of a short command arrives with POLLHUP already set beside POLLIN, so
bailing there would truncate the tail of every `exec -- echo hi`.
The descriptor being waited on is our own duplicate, claimed before the
child exists. Waiting on the master's own would be equivalent right up
until the watchdog closes it, at which point the number could be
reissued to one of the sender thread's sockets. Claiming it before the
spawn means failing to get it can't strand a running command, and it is
fatal rather than best-effort, because degrading to an uninterruptible
read is exactly the failure being removed.
Windows gets the same bound by the only lever it offers. There, master
and slave are two references to one refcounted pseudoconsole, so
dropping the slave releases nothing at all - the previous comment
claiming otherwise was wrong. Releasing the last reference is what
closes the pseudoconsole, and with it the pipe the reader is parked on,
so the watchdog now drops the master before flipping the flag.
Bounding the wait also meant no longer discarding what it collected: the
sender thread breaks wherever it stood when the flag drops, and
`shutdown` only flushes what already reached the transport. It now waits
briefly for the reader's last chunk - itself deadlined, so the fix
doesn't reintroduce the wait it removes - rather than racing it.
Covered by an e2e test that pins all of it: exit code, a deadline the
old code blows through, the final JSON event still well-formed, and
output produced before the orphan surviving the bounded drain.
The page chrome faded over 200ms whenever the theme changed. In practice the only change a viewer reliably sees is the default chrome becoming the broadcast's - which is a repaint, not a change, and must not animate. The theme is fixed when the broadcaster connects and stamped into every size message, so a genuine re-theme needs a second broadcaster reclaiming the room while someone is still watching. The transition therefore fired almost exclusively where it was wrong. And it did fire: the terminal is built from the webfont promise, which on a fast connection resolves before the first size message, so the paint that armed the transition was the default theme - after which the real theme washed in from the wrong colors. `test_page_chrome_follows_theme[light]` sampled that wash and failed on it, every run locally and intermittently on macOS CI. Fixing it took a flag for "a size with usable dimensions has been seen", a second for "chrome has been painted", a third to make arming idempotent across the two paths that can complete the pair, a two-frame wait to keep the class out of the same style recalculation as the colors that scheduled it, and a rule spanning <html> and <body> because <body> is a centred column and the gutters would otherwise snap while the column faded. All of that to smooth a case nobody asked to see smoothed. So it goes. Every theme lands in one step now, in every ordering, and there is nothing left to get the ordering wrong about. The test keeps its webfont stub: it pins the ordering where the chrome is painted twice, which is still the one that would notice a dropped repaint.
vitorbaptista
force-pushed
the
fix/pty-drain-and-theme-race
branch
from
August 16, 2026 22:02
7a35951 to
e240914
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Chasing the E2E break in #198 turned up two more real hangs/races. Both are user-visible, not just CI noise. Not for merge — yours to land when you're happy.
Three commits, each independently coherent.
1.
build:— declare the MSRV the graph actually requiresrust-version = "1.75"had not been true for a while:idna, viaurl→reqwest, pullsicu_*2.x, which declares 1.86. Nothing caught it, because CI builds on stable — so anyone on 1.75–1.85 got a compile error out of rustc rather than cargo's clean "requires a newer rustc".Declaring the real floor also let clippy apply lints it had been holding back —
borrow_as_ptron the pre-existing raw-mode guard.&rawsays what those always meant: take a pointer, without a reference to the terminal settings existing even briefly.Fixing the number without fixing the mechanism would just let it rot again on the next
icubump, so there's now anmsrvjob that readsrust-versionout ofCargo.tomland builds against exactly that toolchain. It passes, which makes 1.86 verified rather than asserted — the first time this claim has been checked at all.2.
fix:— a silent orphan hangs the clientPlain
shellsharehangs the same way when you leave a quiet background job and exit the shell.The command exiting isn't the end of its terminal — anything it left running holds the slave open, so the master never reaches EOF and the reader sits in
read(). The drain watchdog was meant to bound that, but it only flips a flag, and a thread parked in a blocking read never returns to the top of the loop to see it.Worth being precise, because the old comment wasn't wrong about the case it named:
ping example.com & exitdoes work — a chatty orphan's writes keep returning fromread(). Silence is what it never survived.endsleep 60 &thenexitseq 1 50000room snapshotFix: bounded
pollbefore each read. Windows gets the same bound via its only lever — master and slave are twoArcs over one refcounted ConPTY (portable-ptywin/conpty.rs:26-38), sodrop(pair.slave)releases nothing; the old comment claiming otherwise was wrong.Bounding the wait also meant no longer discarding what it collected: the sender thread broke wherever it stood when the flag dropped, and
shutdownonly flushes what already reached the transport. It now waits (deadlined) for the reader's last chunk instead of racing it.New e2e test pins exit code, a deadline the old code blows through, a well-formed final JSON event, and pre-orphan output surviving the drain both locally and in the room. Verified it fails on the old binary.
One honest gap: the sender-side drain closes a measured ~50ms window (output reaches the room with it, is lost without it), and that window is not covered by the test — the marker is written at t≈0, far outside it. Hitting it would take output timed to land within ~20ms of the deadline, which is the kind of timing-dependent test CLAUDE.md says not to add. The reasoning and the measurement are in the code comment instead.
3.
refactor:— drop the viewer's theme transitionThe page chrome faded over 200ms on any theme change. But the theme is fixed when the broadcaster connects and stamped into every size message, so the only change a viewer reliably sees is the default chrome becoming the broadcast's — a repaint, not a change, which must not animate. A genuine re-theme needs a second broadcaster reclaiming the room while someone is still watching.
So the transition fired almost exclusively where it was wrong. And it did fire: the terminal is built from the webfont promise, which usually resolves before the first size message, so the paint that armed the transition was the default theme, and the real theme then washed in from the wrong colors.
test_page_chrome_follows_theme[light]sampled that wash — failing 5/5 locally, intermittently on macOS CI. (My first framing of this as a rare flake was wrong; terminal-before-size is the normal ordering, and CI's Linux leg just happens to hit the other one.)I did fix it first, and that is the argument for deleting it: the fix needed a second flag for "a size with usable dimensions has been seen", a third to keep arming idempotent across the two paths that can complete the pair, a two-frame wait to keep the class out of the same style recalculation as the colors that scheduled it (one frame was measured still fading ~24% of loads), and a rule spanning
<html>and<body>, since<body>is a centred column and the gutters would otherwise snap while the column faded. All to smooth a case nobody asked to see smoothed. Every theme lands in one step now, in every ordering, and there is nothing left to get the ordering wrong about.The test's webfont stub went too — with no fade to catch, it was measurably inert (the test fails 5/5 with or without it when the repaint is dropped). Verified the test still fails if the repaint is removed, so it is not vacuous.
Verification
make lintclean · full e2e 260/260 · both fixes confirmed to fail on the pre-fix binary · all 9 CI checks green, including both e2e legs, Windows, and the newmsrvjob.Reviewed adversarially across several rounds (subagents + Codex CLI). Findings that changed the code along the way:
&raw mutneeds 1.82 whileCargo.tomlsaid 1.75 — found independently by two reviewers, and the thread that led to commit 1.spawn_command, so a failure can't strand a running child.F_DUPFD_CLOEXECrather thandup, which drops the flagportable-ptysets.EINTRpoll failure ends the session, as a failing read always did, rather than falling through to an uninterruptible read or stalling the broadcast silently..binassertion above, after a reviewer showed the sender-side drain had no test at all.mainhad one flag and one deferred frame, not the pile my own intermediate work had grown.