council: closing the terminal no longer leaves five agents running - #296
Merged
Conversation
On macOS and Linux, a room that ended on a signal took none of its seats
with it. Every vendor process kept running, holding a session and spending
quota, with no room attached and nothing on screen to say so.
Measured 2026-08-17 on the Mac (Intel x86_64, macOS 26.5.2) against
bubbletea v2.0.8, with a throwaway program that spawned a `sleep` child in
its own process group — the shape runner/proc_unix.go gives every seat:
signal what Bubble Tea did Update ran? child
SIGINT p.Run() returned "program was no orphaned
killed: program was interrupted"
SIGTERM p.Run() returned nil no orphaned
SIGHUP nothing; the default no orphaned
disposition killed the process
SIGKILL nothing, and nothing can no orphaned
Bubble Tea does end the program on two of the four. Ending the program is
the whole of what it does: its handler answers above the model's head — the
event loop returns on QuitMsg and InterruptMsg before it calls Update — so
council's teardown, which only the q and ctrl+c keys reach, never ran.
signals_unix.go now runs teardown on the three catchable signals. The two
arms are not the same act, and the doc comment says why: Bubble Tea already
ends the program on SIGINT and SIGTERM, so the watcher only kills the seats
there, while nothing answers SIGHUP at all, so the watcher ends the room too.
teardown is a one-shot now. A signal can land on a room the user is already
quitting, which puts two goroutines in a loop that deletes from the map it
walks. Sequentially teardown was already once-only by accident of that
drain; concurrently it killed each seat sixteen times and then crashed on a
nil turn. The new racing test witnesses both.
SIGKILL still orphans every seat and nothing here claims otherwise.
Windows is untouched. Its job object already reaps the tree when telltale's
process dies, however it dies, so watchExitSignals is a documented no-op
there. proc_unix.go claimed "the same guarantee the Windows job object
gives"; it gives half of it, and the comment now says which half. PARITY.md
records the measurement and the method.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The closure watchExitSignals returns is held next to roomCancel, which is a context.CancelFunc and idempotent by contract. A neighbour that panicked on a second call would be a trap rather than a difference anyone would look for, so stop closes its channel under a sync.Once and the test calls it twice. Co-Authored-By: Claude Fable 5 <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.
On macOS and Linux, a council room that ended on a signal took none of its
seats with it. Every vendor process kept running — holding a session, spending
quota — with no room attached and nothing on screen to say so. Five agents, on a
five-seat room.
Windows never had this: a seat there lives in a Job Object created with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so when telltale's process dies the handlecloses and Windows reaps the tree. A unix process group is not a lifetime. It is
a name for a set of processes, and it dies when something signals it and at no
other moment.
runner/proc_unix.goclaimed "the same guarantee the Windows jobobject gives"; it gives half of it. This PR corrects that comment and makes the
missing half happen.
Windows behaviour is unchanged.
watchExitSignalsis a documented no-opthere, and no Windows code path is touched.
Measurement
Everything below was run on the Mac (Intel x86_64, macOS 26.5.2) on 2026-08-17,
against
charm.land/bubbletea/v2 v2.0.8.The probe
A throwaway Go program, deleted before this branch was committed: a Bubble Tea
v2.0.8 model plus one
sleep 600child started withSysProcAttr{Setpgid: true}— the shaperunner/proc_unix.gogives every seat.It never spawned a vendor CLI. Its teardown was reachable only from a key
handler, which is how the shipped room is written. Each run signalled the
program, waited two seconds, and asked whether the child's pid was still alive.
What Bubble Tea already covered
Updaterun?SIGINTp.Run()returnedprogram was killed: program was interruptedSIGTERMp.Run()returnednilSIGHUPSIGKILLSo Bubble Tea does end the program on SIGINT and SIGTERM, and ending the
program is the whole of what it does. Its handler answers above the model's
head:
tea.go's event loop returns onQuitMsgandInterruptMsgbefore itcalls
model.Update, so the model is never handed the message. Council'steardown is reachable only from the
qand ctrl+c key handlers, so it ran onnone of the four. Nothing was covered. The
UPDATE saw …lines in the probelog are the three startup messages and nothing else — no
QuitMsg, noInterruptMsg, on any run.The
SIGKILLrow is recorded, not addressed. It is uncatchable, sokill -9ona room still orphans every seat, and no line in this PR claims otherwise.
With the handler installed
Why the two arms are not the same act
The watcher runs teardown on all three catchable signals, and calls
p.Kill()on SIGHUP only.
down, measured above, so a second shutdown initiator here would buy nothing.
It would also run inside a window the source shows is delicate: at that moment
Bubble Tea's signal goroutine is blocked on an unbuffered
p.msgs <- QuitMsg{}(tea.gomakesmsgswith no capacity) whilep.shutdownwaits for that same goroutine to return.program, and installing this handler has just displaced the default
disposition that would have.
Killis safe here for the same reason it isunnecessary above — no Bubble Tea goroutine is mid-send on a signal it did not
subscribe to — and it restores the terminal, which the default disposition
would not have.
Honesty note on that first bullet. A probe variant that did call
KillonSIGTERM was run 20 times and never hung:
So the claim is only that the
Killis unnecessary there. A deadlock wasnot measured; it is a source reading, and the doc comment says so in those
words.
Idempotency, and the test that actually witnesses it
teardownis a one-shot now (sync.Mutex+ a flag), because a signal can landon a room the user is already quitting and the loop deletes from the map it
walks.
The first version of the concurrency test passed with the guard deliberately
removed — the repo's own recorded failure mode, a test that checks the flag
instead of the effect. It was rewritten: each fake seat's
Killholds for 20 ms,which widens the window past anything a scheduler can close. With the guard
removed, the same test now fails hard:
That line is
m.turn.cancel(), on a turn another goroutine had already nil'd. Thecounts catch the softer half of the same overlap: sixteen callers each walk a map
none of them has drained yet, so every seat is killed sixteen times.
The sequential test is labelled in its own doc comment as a regression pin
rather than a witness: sequentially, teardown was already once-only by accident
of draining
m.procsas it walks it, and the guard must not break that.Tests
No test in this PR spawns a vendor. Every process is a
countedKillfake, socountSpawnshas nothing to guard.TestTeardownActsOnceHoweverOftenItIsCalled— sequential pin.TestATeardownRacingAnotherNeitherPanicsNorDoubleKills— 16 concurrentteardowns, one kill per seat, one turn cancel.
TestASignalKillsTheSeatsBeforeTheRoomGoesOut— table over SIGINT, SIGTERMand SIGHUP: the seats die on all three, and the program is killed on SIGHUP
alone. The watcher is driven against a
fakeProgramrather than a realtea.Program, so no terminal or event loop is involved.TestTheWatcherStopsWhenTheRoomEndsAnyOtherWay— the returnedstopreallyderegisters.
Gates
go vet ./...GOOS=windows go vet ./...go test ./internal/council -timeout 20mok … 44.054sgo test -race ./...internal/council 58.210s,internal/council/runner 16.892sThe race run is not one of this lane's declared gates, but CI's
ubuntu-latestjob runs exactly it, and this PR adds both a goroutine and a mutex. The new
signal tests were also run under
-race -count=3on their own: clean.What is NOT in this PR
STATE.md, Windows console-close handling (CTRL_CLOSE_EVENTis a differentmechanism from a POSIX signal, and the job object already covers the seats
through it), any change to what teardown does to each seat kind, and any claim
about SIGKILL.
🤖 Generated with Claude Code