fix: wait for the TUI to exit before removing its temp dir (#1801) - #1814
Conversation
`done()` sent SIGTERM and then immediately `rmSync`'d the work dir, which doubles as the TUI's HOME. Nothing waited for the child, so the Ink process could still be writing into the dir while rmSync walked it — a file landing after a directory was read but before it was removed surfaces as ENOTEMPTY on macOS, failing `smoke:tui` after a successful render. Removal now happens only once the child's `exit` event fires: SIGTERM, then SIGKILL after SMOKE_TUI_EXIT_GRACE_MS (default 5s), then clean up regardless after twice that. The crash-before-render path short-circuits via a `childExited` flag set by the pre-registered exit handler. cleanup() also warns instead of throwing, so a leftover temp dir can never fail a passing smoke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MvKF2mqi4hoQSUqQpZXNTq
|
@claude review |
|
Claude finished @cliffhall's task in 3m 7s —— View job Review: fix: wait for the TUI to exit before removing its temp dir
Verdict: the fix is correct for the bug it targets — approve-with-one-nit. Waiting on the child's One concrete finding, plus nits. 1. The
|
…view #1814) A failed spawn emits `error` + `close` and never `exit`, so the previous `child.once("exit")` wait never fired on that path: done() sent SIGTERM to a process that never existed and resolved only via the give-up timer — 10s at the default grace, with a warning blaming a SIGTERM the child never received. Waiting on `close` covers both normal termination and spawn failure, and additionally guarantees the stdio pipes are drained. The short-circuit gate moves with it, `childExited` → `childClosed`: keying it on `exit` would have skipped the drain on the crash-before-render path, which is exactly where the quoted output matters. Messages that quote that output are now thunks rendered in finish(), after the drain, instead of being sliced at call time. SIGTERM is now sent only to a child that is still running, since on both the crash and spawn-failure paths there is nothing left to signal. finish() also gets a `finished` guard so its single-entry invariant is local rather than inferred from process.exit() being synchronous. Measured, spawn failure (grace shortened to 500ms): 1.04s + spurious warning before, 0.02s and silent after. Crash-before-render now prints the child's stderr detail line that the pre-drain slice could truncate. Happy path 3x, timeout path, and full `npm run smoke` + `npm run validate` all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MvKF2mqi4hoQSUqQpZXNTq
|
Good review — finding #1 is real, I reproduced it, and fixing it made #2 and #3 nearly free, so all three are in. #4 declined. 1.
|
| result | |
|---|---|
| before | 1.04s real, plus TUI did not exit within 1000ms of SIGTERM |
| after | 0.02s real, silent |
At the default grace that's the 10s you predicted. Took your suggested fix — waiting on close, which covers both normal termination and spawn failure and drains stdio as a bonus.
One thing your fix note missed, which I hit while applying it: leaving the short-circuit keyed on childExited quietly defeats the drain. exit fires before close, so on the crash-before-render path done() is called from the exit handler with childExited === true, short-circuits straight to finish(), and never waits for the pipes — precisely the path where the quoted stderr matters most. So the gate moved too: childExited → childClosed, set by a close handler registered before done()'s one-shot. I also narrowed the signal to a child that's actually still running (exitCode === null && signalCode === null), since on both the crash and spawn-failure paths there is nothing left to signal.
2. Diagnostics built before the drain — fixed here, not deferred
You suggested a follow-up issue; it turned out to be ~3 lines on top of #1, so it's in this PR. message may now be a thunk, rendered inside finish() after the drain. Verified with a child that writes a detail line to stderr and exits 3:
smoke:tui FAILED — TUI exited (code 3) before rendering "MCP Servers"
fatal: boom detail line
That trailing line is the part a pre-drain slice can lose.
3. finish() not idempotent — fixed
Added the finished guard. Agreed with your framing: it was true-by-construction but the invariant was inferred from process.exit() being synchronous, which is exactly the kind of thing a later edit breaks silently.
4. Number(env ?? 5000) on an empty override — declining
You flagged it as consistent-with-existing, and that's the reason I'm leaving it. TIMEOUT_MS two dozen lines up has identical semantics; hardening only the newer knob makes the file less predictable, and hardening both means changing TIMEOUT_MS's behavior in a PR about an rmSync race. If it's worth doing it's worth doing to both knobs, in all four smoke:* scripts, deliberately.
Verification after the change
Happy path ×3, timeout path, crash-before-render, spawn-failure, plus full npm run smoke (all five green, TUI included) and npm run validate. prettier --check clean via format:check:scripts. Temp-dir count in $TMPDIR unchanged across runs.
Also: thanks for checking whether an MCP stdio grandchild inherits HOME=work — that's the caveat that would actually reopen this bug, and you're right that it's moot only because the TUI doesn't auto-connect on boot today.
|
@claude review |
|
Claude finished @cliffhall's task in 3m 39s —— View job Review: fix: wait for the TUI to exit before removing its temp dir
Verdict: LGTM. The round-2 changes are correct, and I re-verified each path empirically rather than taking the previous round's reasoning on faith. Three small notes below, none blocking. What I verified (Node v22.23.1)Spawn failure resolves instantly, and the new Crash-before-render likewise skips the signal and waits only for the drain — The Scope also checks out: no sibling smoke has this race. 1. The
|
…r2) Three follow-ons from the second review pass: The give-up warning still said the TUI "did not exit within Xms of SIGTERM", but after switching the wait to `close` neither clause is reliably true — the timer's most plausible trigger is now a child that has already exited, and on the crash and spawn-failure paths no SIGTERM is sent at all. That is the same defect this PR fixed one round ago (a warning naming a signal never sent), so the deadline is now re-armed with a message describing what is actually being awaited. `close` is bounded by whoever holds the stdio pipes, which can outlive the direct child: a descendant that inherited them keeps it pending. That is the one real cost of `exit` → `close`, and it is moot only because the TUI spawns nothing at boot — a reason that lives entirely outside this file. Once `exit` has fired the deadline drops to DRAIN_MS, keeping the drain guarantee without inheriting an unrelated process's lifetime. Measured against a grandchild holding the pipes for 3s: 0.54s and an accurate warning, instead of 3s. The quoted output is sliced from the tail rather than the head. The thunk added last round exists to capture bytes that arrive late, which a head slice then discards as soon as output exceeds 800 chars — and on the timeout path the last frame is what diagnoses a stuck render, not the first. Verified: a 2KB stderr crash now reports its final line. The sibling scripts keep `slice(0, 800)`; changing them is a deliberate sweep, not a drive-by here. Also corrected the cleanup() comment — the wait lives in done(), and it is for `close`, not `exit`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MvKF2mqi4hoQSUqQpZXNTq
|
All three are in, including the two you'd have deferred — this is the last round, so anything worth doing gets done here rather than becoming a stale follow-up. 1.
|
|
@claude review |
|
Claude finished @cliffhall's task in 3m 44s —— View job Review: fix: wait for the TUI to exit before removing its temp dir
Verdict: LGTM — ship it. I rebuilt the round-3 state machine as a standalone harness and drove it against six real children rather than reasoning about it, and every path terminates promptly with an accurate message. The two changes this round are both improvements over what I'd suggested: the re-armed deadline is strictly better than the comment I proposed for #2, and you were right that the head-slice was an inconsistency this PR introduced, not a pre-existing house pattern to preserve. Only nits below, one of which is the comment-twin of the thing we've now fixed twice. Verified: all six paths (Node v22.23.1,
|
| path | trace | outcome |
|---|---|---|
| happy | +27ms SIGTERM → +31ms drainOnly → +31ms close |
OK, 31ms |
| crash-before-render | +26ms drainOnly → +26ms no signal (exitCode=3) → close |
FAILED, 26ms, tail quoted |
| spawn failure | +4ms no signal (exitCode=-2) → +6ms close |
FAILED, 6ms, no warning |
| render timeout | +304ms SIGTERM → drainOnly → close |
FAILED, 309ms |
| wedged (ignores SIGTERM) | SIGTERM → +1025ms SIGKILL → drainOnly → close |
OK, 1029ms |
| grandchild holds pipes 3s | SIGTERM → drainOnly → +534ms WARN … held its output streams open |
OK, 534ms |
The last row is the one worth calling out: the re-armed deadline turns a 3s hang into 534ms and the warning it prints names the actual cause. Worst case for a wedged child is now EXIT_GRACE_MS + DRAIN_MS (5.5s), not the EXIT_GRACE_MS * 2 the original description promised — the ladder is tighter than advertised.
I also re-derived the load-bearing bit independently: child.exitCode is -2 and pid is undefined inside the error handler, so the exitCode === null && signalCode === null && !killed guard declines to signal — which is why the spawn-failure row is 6ms and silent.
Scope re-check: smoke-cli.mjs hands its children the same temp HOME (:173, :469) and does an unguarded rmSync in a finally (:499), but every child there goes through runCli → spawnSync, which reaps before returning, so there's no async child to race. Genuinely TUI-only.
1. cleanup()'s comment is now the last thing in the file that can be false (nit)
scripts/smoke-tui.mjs:126-128:
This should not fire now that removal waits for the child's
close(seedone()), so say so loudly if it does.
But two branches call finish() without close having fired — the EXIT_GRACE_MS * 2 give-up and the DRAIN_MS drain give-up. Those are exactly the states where something may still hold the dir, so on those paths the warning is expected, not anomalous. A future debugger who hits it will read that comment and go looking for a regression in the wait.
This is the same defect class as the SIGTERM warning fixed in r2 and the give-up warning fixed in r3 — a message asserting something the code no longer guarantees — just relocated into a comment. One clause fixes it: "…should not fire on the normal path, where removal waits for close; the two give-up branches below can legitimately hit it." Fix this →
2. The DRAIN_MS give-up bounds the wait by re-opening the original window — worth half a sentence (nit)
The DRAIN_MS comment at :114-119 explains why the cap exists but not what it costs: on that path we rmSync at exit + 500ms while a descendant that inherited HOME=work may still be writing into it. That is the #1801 race, re-entered deliberately.
I tried to reproduce the failure and couldn't on Linux — a grandchild writing to HOME every 60ms while rmSync(recursive, force) walks the tree: removal succeeded and the dir stayed gone. ENOTEMPTY is the macOS-observed shape, so I can't rule it out there. Either way the blast radius is one warning plus a leaked temp dir, never a red smoke, because cleanup() warns instead of throwing — the two changes are load-bearing for each other in a way the comments don't currently connect. Right tradeoff; just say so where the constant is defined.
3. output.slice(-800) can begin mid-escape-sequence (micro)
Tail-slicing an Ink stream can start inside a CSI sequence, so the quoted diagnostic may lead with a fragment of an escape code and mangle the line after it. Cosmetic, and much less costly than the head-slice it replaced. If you ever touch it: slice, then drop through the first \n.
4. DRAIN_MS is the one timing constant with no env override
TIMEOUT_MS and EXIT_GRACE_MS both take one; DRAIN_MS is hard-coded. Flagging for symmetry only — it's consistent with declining the env-hardening nit last round (fewer knobs, not more), and 500ms needs no tuning on a path that shouldn't be reachable today. No action.
Checks run
prettier --check scripts/smoke-tui.mjs→ clean. Notescripts/is prettier-only per AGENTS.md ("the root has no eslint config for.mjs"), so the "eslint clean" in your verification is vacuous for this file — nothing to fix, just so the gate isn't over-credited.- Six-path harness above, plus the
error-handlerexitCodeprobe and the grandchild-vs-rmSyncexperiment, on Node v22.23.1. - Tests: none expected —
test:scriptsglobsscripts/**/*.test.mjsand covers only the pure parsers; thesmoke:*drivers have never been unit-tested. The manual matrix across three rounds is the right substitute. - Docs:
SMOKE_TUI_EXIT_GRACE_MSstays undocumented, consistent withSMOKE_TUI_TIMEOUT_MS/SMOKE_WEB_PORT. No gap introduced. - Did not run
smoke:tui— it self-skips underCI, which is why your local runs are the meaningful evidence.
Nothing here blocks the merge; all four are comment/cosmetic. Good place to stop.
· branch fix/1801-smoke-tui-enotempty
… (review #1814 r3) The cleanup() comment claimed its warning "should not fire now that removal waits for close" — true only of the normal path. Both give-up branches call finish() without close, and those are precisely the states where something may still hold the dir, so the warning is expected there. Same defect class as the two warnings already fixed in this PR (a message asserting what the code no longer guarantees), relocated into a comment. The DRAIN_MS comment explained why the cap exists but not what it costs: on that path the work dir is removed while a descendant holding HOME=work may still be writing to it — #1801's race, re-entered deliberately. That is only acceptable because cleanup() warns instead of throwing, so record the two as load-bearing for each other where the constant is defined. Tail-slicing an Ink stream can begin mid-CSI-sequence, leading a diagnostic with an escape-code fragment that mangles the line after it. outputTail() drops through the first newline when the output was actually truncated, and returns short output untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MvKF2mqi4hoQSUqQpZXNTq
|
Taking 1, 2, and 3 — all comment/cosmetic, so they go in and we stop here at three rounds. 1. 2. Your reproduction attempt is useful too — grandchild writing every 60ms while 3. Mid-escape-sequence tail — fixed. Extracted 4. On the eslint correction — you're right and I'll take it. The six-path harness is the most useful thing in this review — particularly worst-case |
Closes #1801
Problem
smoke:tuiintermittently failed withENOTEMPTYafter the TUI had already rendered successfully:done()calledchild.kill("SIGTERM")and thencleanup()synchronously. Nothing waited for the child, and the work dir doubles as the TUI'sHOME— so the Ink process could still be writing into it whilermSyncwalked it. A file created after a directory has been read but before it is removed producesENOTEMPTYon macOS.Because
smokeis part of the mandatory pre-push gate andsmoke:tuiself-skips underCI, this only bit local runs — exactly where the gate is supposed to be trusted.Fix
Removal now waits for the child's
exitevent instead of racing it:done()registers a one-shotexitlistener, then sendsSIGTERM; cleanup +process.exithappen infinish()from that listener.SIGKILLafterSMOKE_TUI_EXIT_GRACE_MS(default 5s), and cleans up anyway with a warning after twice that, so a wedged child can't hang the smoke.childExitedflag, set by the pre-registeredexithandler, short-circuits the wait on the crash-before-render path (wheredone()is called from that handler).cleanup()warns instead of throwing, so a leftover temp dir can never turn a passing smoke red.maxRetries/retryDelaywere deliberately not used — they'd widen the race window rather than close it.Verification
npm run smoke:tui× 5 — all OK, noENOTEMPTY, no cleanup warnings, temp-dir count in$TMPDIRunchanged across runs.SMOKE_TUI_TIMEOUT_MS=300, child still alive): fails as expected, cleans up.childExitedshort-circuit.npm run validateandnpm run smokeboth green.🤖 Generated with Claude Code
https://claude.ai/code/session_01MvKF2mqi4hoQSUqQpZXNTq