fix(login): shut the key reader down gracefully so nothing races its fd - #2009
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR removes a teardown Close() in a Linux-only regression test that was reintroducing the same Bubble Tea cancel-reader data race that production code intentionally avoids by leaking the TTY descriptor on the cancelled path.
Changes:
- Stop closing the
ttyinTestReadLoginURLActionFromTTY_CancelledLeavesDescriptorOpen, aligning the test’s ownership behavior withreadLoginURLActionFromTTY’s cancelled-path contract. - Add an in-test explanation documenting why the descriptor must be left open (unjoinable reader goroutine + race detector failure mode).
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Soph
force-pushed
the
soph/fix-login-tty-test-race
branch
2 times, most recently
from
August 17, 2026 10:24
13de017 to
89a3d94
Compare
… open TestReadLoginURLActionFromTTY_CancelledLeavesDescriptorOpen closed the descriptor in teardown, which is the same data race the production fix in f40fd73 exists to avoid — just moved from the function into its own regression test. That commit made readLoginURLActionFromTTY skip the close on the cancelled path, because Bubble Tea's shutdown(kill=true) skips waitForReadLoop() and its cancelreader goroutine can outlive Run, reading tty.Fd() from a wait loop that cannot be joined. The test then took the close back: // Ownership stays with us on this path, so this close is the real one. defer tty.Close() Ownership does not come back — nobody can safely own a descriptor a live unjoinable reader holds, in a test no less than in production. So the deferred close raced the reader, and because the race detector fails the whole package, one landing took ~20 unrelated parallel tests down with it: Read at 0x00c00088a730 by goroutine 11009: os.(*File).Fd() cancelreader.(*epollCancelReader).wait() cancelreader_linux.go:147 Previous write at 0x00c00088a730 by goroutine 4290: internal/poll.(*FD).destroy() os.(*File).Close() ...CancelledLeavesDescriptorOpen.deferwrap3() login_tty_test.go:209 Timing-dependent, so it landed already-flaky: it failed on its own merge run (31836107297) and again three days later on an unrelated PR, with green main runs in between. Only observed on Linux, but the hazard is not Linux-specific and the doc comment no longer claims it is: cancelreader reads file.Fd() after each wake on both backends — cancelreader_linux.go:147 after EpollWait, and cancelreader_bsd.go:139 after Kevent — and this file builds on darwin too. Which platform trips the detector is luck, not structure. Drop the close. Rather than only describing the rule, enforce it: t.Cleanup runs after the test body's deferred closes, so asserting the descriptor is still open from there fails deterministically, on every platform, if anyone re-adds a close — instead of waiting for the detector to catch it on a Linux CI run and blaming an unrelated PR. Verified in both directions: it passes as written and fails with "tty was closed during teardown" once a close is restored. The cleanup deliberately checks the descriptor NUMBER rather than the terminal state. Close sets Sysfd to -1 in shared code (internal/poll (*FD).destroy), so the check is platform-independent, whereas tcgetattr on the slave returns EIO on Linux once the deferred ptmx.Close has revoked the pty — reporting a perfectly open descriptor as closed. A first attempt used term.GetState there and failed exactly that way on Linux CI while passing on darwin. Scope, so the next flake is not misattributed to this: removing our close does NOT make this test race-free, because a second pair lives entirely upstream. cancelreader's Close() closes its own cancel-signal pipe (cancelreader_bsd.go:109, cancelreader_linux.go:118) while a still-blocked wait() reads cancelSignalReader.Fd() (:141, :150) — same shape, different descriptor, no frame from this repo. Reproduced locally on darwin at -count=2000: os.(*File).Close() cancelreader.(*kqueueCancelReader).Close() cancelreader_bsd.go:109 os.(*File).Fd() cancelreader.(*kqueueCancelReader).wait() cancelreader_bsd.go:141 It is far rarer than the close this commit removes. Triage rule: if a future test-core race names login_tty_test.go, it is a regression of this; if it names only cancelreader, it is the upstream pair and wants a patch to muesli/cancelreader (cache both fds at construction) or to bubbletea (join the read loop on the kill path). The leaked fd costs less than the old comment claimed: os.newFile registers a finalizer (os/file_unix.go:225), and the abandoned *os.File becomes unreachable once the reader exits, so the GC reclaims it rather than it being held to process exit. The sibling tests already omit this close because production closes the tty on their path. ptmx and observer stay closed: different descriptors, so neither touches the *os.File the reader holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Soph
force-pushed
the
soph/fix-login-tty-test-race
branch
from
August 17, 2026 10:33
89a3d94 to
14afae0
Compare
gtrrz-victor
previously approved these changes
Aug 17, 2026
The previous commit stopped the test closing a descriptor a live reader
held, which removed this repo's contribution to the flake. It did not make
the test race-free: a second pair lives in muesli/cancelreader, and it
survived. Reproduced on darwin at -count=2000, no frame from this repo:
os.(*File).Close()
cancelreader.(*kqueueCancelReader).Close() cancelreader_bsd.go:109
os.(*File).Fd()
cancelreader.(*kqueueCancelReader).wait() cancelreader_bsd.go:141
Both pairs have one cause. bubbletea's shutdown does:
if p.cancelReader.Cancel() {
if !kill {
p.waitForReadLoop()
}
}
_ = p.cancelReader.Close()
On a killed Run it skips the join, so Close lands while the input reader may
still be inside wait() — reading tty.Fd() (our descriptor) and
cancelSignalReader.Fd() (cancelreader's own pipe, which we cannot influence).
And Run treats a cancelled context as killed:
killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil
Passing ctx to tea.WithContext therefore made every cancellation take the
racy path — and cancellation is the normal path here, not an edge case:
waitForLoginURLResult cancels the reader as soon as authentication
completes, so every sign-in where the user does not press a key went
through it.
So stop cancelling and start quitting. Quit delivers a QuitMsg, which
eventLoop returns with a nil error, leaving killed false and shutdown
joining the read loop before it closes anything. Nothing holds either
descriptor by the time they are closed, which also lets tty be closed on
the normal cancelled path instead of leaked.
Not a bare swap, because Quit sends on an unbuffered channel: an event loop
that never takes it would block Quit forever, and finishActionRead joins
this read once authentication completes, so a hang there would strand a
completed sign-in — worse than the leak being removed. bubbletea keeps its
own context, cancelled only if the graceful path does not land within
loginURLQuitGrace, and the descriptor is left alone in exactly that case,
keyed on tea.ErrProgramKilled rather than on a proxy for it.
Verified on darwin under -race: 2000 iterations of the cancelled test and
2x1500 iterations of every TTY test, zero races. The same 2000-iteration
load reliably reproduced the cancelreader pair before this change.
TestReadLoginURLActionFromTTY_CancelledLeavesDescriptorOpen becomes
_CancelledClosesDescriptor: its premise inverted, since the cancelled path
now owns the descriptor like every other path. The comment, the teardown
tripwire, and the leak it guarded are all gone with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gtrrz-victor
approved these changes
Aug 17, 2026
timothybrush
pushed a commit
to timothybrush/cli-2
that referenced
this pull request
Aug 17, 2026
Cleanup pass over entireio#2009. No behaviour change on any reachable path; net -76/+24. The kill fallback is deleted, because it did not do the thing it existed to do. It was there so a wedged event loop could not strand a completed sign-in, finishActionRead being a blocking join. Probed with a model that blocks forever inside Update — the only way a loop can refuse a QuitMsg — and cancelling Bubble Tea's context does not recover it: eventLoop observes p.ctx.Done() only at its top-level select (tea.go), so a body that never returns never gets back there. Run never returns, `killed` is never computed, shutdown never runs. program.Kill() fails identically. So the fallback bought a 2s delay before the same hang. That also corrects the premise it was justified on: the tea.WithContext(ctx) form it replaced had no hang guarantee either, so nothing was traded away by dropping the context. Its one reachable effect was harmful — a graceful shutdown slower than 2s got converted into a killed Run, precisely the racy path this fix exists to avoid, plus a leaked fd. With the fallback gone, killCtx has no reason to exist either: it only existed to be cancelled by the fallback, and Program.Kill() is the same shutdown(true) anyway. context.AfterFunc is then exactly "run this once ctx is done", so the goroutine, both channels, both selects, the nested `go program.Quit()`, time.After and the grace constant all collapse to: stopQuit := context.AfterFunc(ctx, program.Quit) defer stopQuit() It is also cheaper: AfterFunc registers nothing when the parent can never be cancelled (propagateCancel returns early on a nil Done), where the old watchdog spawned a goroutine per call — and this function is re-entered after every copy action. closeTTY stays. The branch is not fallback-only: Run wraps any event-loop error as ErrProgramKilled, and readLoop feeds input-stream failures into p.errs, so a tty read failure (revoked pty, SIGHUP) lands there with the read loop unjoined — exactly what the branch assumes. The "only reachable via the fallback" comment was wrong twice over and is gone. Comments: the kill-skips-the-join fact was stated three times in source and again in the commit body; it now lives once, at the closeTTY site whose shape depends on it. The cancellation-is-a-Quit decision likewise now lives only where it is enacted. Dropped the triage history ("no care on this side avoids it") which, sitting beside the code that does avoid it, read as the opposite of the truth. The doc comment no longer claims closure "on every path" while the next line documents the exception. The test's nine-line mechanics comment described shutdown internals it never executes — the pre-cancelled context returns before any program is constructed — and duplicated the commit body. Its assertion now uses Stat + os.ErrClosed, matching the sibling test 130 lines up rather than introducing a second idiom for one question. Verified: 3000 iterations of every TTY test under -race with zero races (the sweep is what matters — it includes AuthCompletionCancelsTTY..., which actually exercises the graceful join), full-package -race, and lint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
https://entire.io/gh/entireio/cli/trails/1058
The flake
test-corefails intermittently with a data race, and because the race detector fails the whole package, one landing takes ~20 unrelated parallel tests down with it — each reporting onlyrace detected during execution of test, which hides the single real cause.It has hit main twice: on the merge run of #2006 (
31836107297, Aug 14) and again three days later on an unrelated PR (#1981), with green main runs in between.One cause, two racing pairs
bubbletea's
shutdowndoes this:On a killed
Runit skips the join, soCloselands while the input reader may still be insidewait(). That reader reads two descriptors on wake:tty.Fd()— ours. This is the pair fix(login): don't close the TTY a killed Bubble Tea reader still holds #2006 designed around by leaking the descriptor, and the pair its regression test then re-created by closing that descriptor in teardown.cancelSignalReader.Fd()— cancelreader's own cancel-signal pipe, whichClose()closes (cancelreader_bsd.go:109,cancelreader_linux.go:118) whilewait()reads it (:141,:150). Entirely upstream; nothing on our side can avoid it.And
Runtreats a cancelled context as killed:So passing
ctxtotea.WithContextmade every cancellation take the racy path. That matters more than it sounds: cancellation is the normal path here, not an edge case —waitForLoginURLResultcancels the reader as soon as authentication completes, so every sign-in where the user doesn't press a key went through it.The fix: stop cancelling, start quitting
Quitdelivers aQuitMsg, whicheventLoopreturns with a nil error, sokilledstays false andshutdownjoins the read loop before closing anything. Nothing holds either descriptor by the time they're closed — which kills both pairs, including the upstream one, without patching upstream.It also means
ttycan simply be closed on the cancelled path rather than leaked, so the conditional close, the long comment, the teardown tripwire, and the leak all go away.Not a bare swap.
Quitsends on an unbuffered channel, so an event loop that never takes it would blockQuitforever — andfinishActionRead(login.go:615) joins this read once authentication completes, so a hang there would strand a completed sign-in. That's a worse failure than the leak being removed. bubbletea therefore keeps its own context, cancelled only if the graceful path doesn't land withinloginURLQuitGrace, and the descriptor is left alone in exactly that case — keyed ontea.ErrProgramKilled, the upstream signal, rather than on a proxy for it.Verification
On darwin under
-race:Plus
go test -race ./cmd/entire/cli/,mise run test:ci, andmise run lintall green.The before/after comparison is the load-bearing evidence here: the same 2000-iteration run that reliably surfaced the upstream pair on the previous commit is now clean, which is what distinguishes this from the partial fix.
Test change
TestReadLoginURLActionFromTTY_CancelledLeavesDescriptorOpenbecomes_CancelledClosesDescriptor. Its premise inverted — the cancelled path now owns the descriptor like every other path. It asserts on the descriptor number (ClosesetsSysfdto -1 ininternal/poll.(*FD).destroy, so it's platform-independent) rather than terminal state, which would report EIO on Linux merely becauseptmxis still open.🤖 Generated with Claude Code