Two startups, one terminal: a stale-closure Save effect cross-wrote pane layouts — and the race was in our own post-mortem two weeks ago
Startup 'wtf-ai-engineer' and startup 'Dalkkak' rendered the exact same live terminals. Root cause (3 parallel investigators + 2 adversarial refuters, both confirmed): the layout Save effect fired on every startup switch with a stale closure — the new startup id but the OLD startup's tree — writing A's panes under B's key. A quit or updater relaunch inside that window made it permanent and self-reinforcing. The kicker: commit 51cac0a post-mortemed this exact race on May 26, but the fix only covered the null variant, and its wrong comment protected the live bug. Fixed with an ownership tag that travels with the tree + a boot-time sanitizer that self-heals any cross-startup pane sharing.
The symptom
"wtf-ai-engineer… 그거랑 지금 DalkkakAI랑 터미널이 완전히 똑같은 창이야"
Two different startups (workspaces) showed literally the same terminals. Decoded localStorage made
it concrete: both layouts contained exactly pane-mq4jvqec-kho7 and pane-mq4jyg1w-9zoe. Pane ids
map 1:1 to tmux sessions, so both startups were attached to the same two live shells — one of which
was running the very Claude conversation doing the debugging (DALKKAK_PANE_ID matched).
The mechanism
// Save effect — deps [activeStartupId, layout], guard: layout === null
useEffect(() => {
if (!activeStartupId || layout === null) return;
saveLayoutFor(activeStartupId, layout);
}, [activeStartupId, layout]);On a switch A→B, selectStartup swaps the id but never clears layout. React's first commit
renders {activeStartupId: B, layout: A's tree}. The Load effect (same flush) only queues the
new tree — it can't change what the Save effect's closure already sees. The null guard never trips
(layout is only nulled when there's no active startup at all). Net: every startup switch wrote
A's tree under B's key, corrected one commit later. Quit, crash, an auto-update relaunch, or a
fast second switch inside that window → the bad write persists. And then it's self-reinforcing:
both startups load the same tree, mount the same panes, and re-save it under both keys forever.
The corruption happened during the June 7 layout-revert evening: the free-form build had saved an incompatible format under the same keys, the revert's loader nulled them all, every startup regenerated panes amid heavy switching and updater relaunches — the shared panes' tmux creation times sit exactly in that window.
The part that stings
Commit 51cac0a (May 26) is this repo's own post-mortem of this exact race — "The Save effect
closed over the stale layout… wrote the wrong layout into the new key… force-kill or fast user
actions could leave bad state persisted." Its fix covered the null variant only, and left a
comment asserting layout "is briefly null during transitions" — which is false on the switch path.
Every later reader trusted the comment. A wrong comment is worse than no comment: it actively
defends the bug.
The fix (two layers)
- Ownership travels with the data. A
layoutOwnerstate is set only where a layout is loaded for a startup, in the same batch as the tree. The Save effect refuses to persist unlesslayoutOwner === activeStartupId. The stale-closure commit can never pass; all entry points (switch, create, delete-active) go through the same gate. - Boot-time self-heal.
layoutSanitizer.tsruns before first render: scans all startups' layouts in list order; any pane id already claimed by an earlier startup gets replaced with a fresh leaf in the later layout. The existing Dalkkak ↔ wtf-ai-engineer collision repairs itself on next launch (the older startup keeps its live panes), and the invariant — a pane id belongs to exactly one startup — now survives any past or future corruption.
Lessons
- An effect persisting
dataunder keyk, with deps[k, data], is a textbook stale-closure cross-write. Prove the pair belongs together (owner tag); never infer it from effect timing. - A half-fixed race is a shipped race. When a post-mortem names a race class, close every variant — or write the invariant down so the next variant gets caught (it's in INVARIANTS.md now).
- For persisted state that can corrupt and once did, a boot sanitizer turns "permanent corruption" into "one weird launch."