Skip to content

fix(login): shut the key reader down gracefully so nothing races its fd - #2009

Merged
Soph merged 2 commits into
mainfrom
soph/fix-login-tty-test-race
Aug 17, 2026
Merged

fix(login): shut the key reader down gracefully so nothing races its fd#2009
Soph merged 2 commits into
mainfrom
soph/fix-login-tty-test-race

Conversation

@Soph

@Soph Soph commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

https://entire.io/gh/entireio/cli/trails/1058

The flake

test-core fails 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 only race 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 shutdown does this:

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(). That reader reads two descriptors on wake:

  1. 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.
  2. cancelSignalReader.Fd() — cancelreader's own cancel-signal pipe, which Close() closes (cancelreader_bsd.go:109, cancelreader_linux.go:118) while wait() reads it (:141, :150). Entirely upstream; nothing on our side can avoid it.

And Run treats a cancelled context as killed:

killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil

So passing ctx to tea.WithContext made every cancellation take the racy path. That matters more than it sounds: 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 doesn't press a key went through it.

The fix: stop cancelling, start quitting

Quit delivers a QuitMsg, which eventLoop returns with a nil error, so killed stays false and shutdown joins 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 tty can 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. Quit sends on an unbuffered channel, so an event loop that never takes it would block Quit forever — and finishActionRead (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 within loginURLQuitGrace, and the descriptor is left alone in exactly that case — keyed on tea.ErrProgramKilled, the upstream signal, rather than on a proxy for it.

Verification

On darwin under -race:

Load Races before Races after
2000× the cancelled test reproduced the cancelreader pair 0
2×1500× every TTY test 0

Plus go test -race ./cmd/entire/cli/, mise run test:ci, and mise run lint all 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_CancelledLeavesDescriptorOpen becomes _CancelledClosesDescriptor. Its premise inverted — the cancelled path now owns the descriptor like every other path. It asserts on the descriptor number (Close sets Sysfd to -1 in internal/poll.(*FD).destroy, so it's platform-independent) rather than terminal state, which would report EIO on Linux merely because ptmx is still open.

🤖 Generated with Claude Code

@Soph
Soph requested a review from a team as a code owner August 17, 2026 09:58
Copilot AI lite review requested due to automatic review settings August 17, 2026 09:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 tty in TestReadLoginURLActionFromTTY_CancelledLeavesDescriptorOpen, aligning the test’s ownership behavior with readLoginURLActionFromTTY’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
Soph force-pushed the soph/fix-login-tty-test-race branch 2 times, most recently from 13de017 to 89a3d94 Compare August 17, 2026 10:24
… 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
Soph force-pushed the soph/fix-login-tty-test-race branch from 89a3d94 to 14afae0 Compare August 17, 2026 10:33
gtrrz-victor
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>
@Soph Soph changed the title test(login): stop the cancelled-TTY test closing the fd it asserts is open fix(login): shut the key reader down gracefully so nothing races its fd Aug 17, 2026
@Soph
Soph merged commit 34425fe into main Aug 17, 2026
11 checks passed
@Soph
Soph deleted the soph/fix-login-tty-test-race branch August 17, 2026 11:40
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants