유대선
프로젝트로
·트러블슈팅·3

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)

  1. Ownership travels with the data. A layoutOwner state is set only where a layout is loaded for a startup, in the same batch as the tree. The Save effect refuses to persist unless layoutOwner === activeStartupId. The stale-closure commit can never pass; all entry points (switch, create, delete-active) go through the same gate.
  2. Boot-time self-heal. layoutSanitizer.ts runs 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