Skip to content

fix(studio): auto-launch dead-spawn stall — break the poll on an observed spawn failure, and memo a no-handle launch - #514

Merged
KnockOutEZ merged 3 commits into
studio-handoff-corefrom
sd-183-fix-studio-auto-launch-dead-spaw
Aug 28, 2026
Merged

fix(studio): auto-launch dead-spawn stall — break the poll on an observed spawn failure, and memo a no-handle launch#514
KnockOutEZ merged 3 commits into
studio-handoff-corefrom
sd-183-fix-studio-auto-launch-dead-spaw

Conversation

@KnockOutEZ

Copy link
Copy Markdown
Owner

Closes wigolo-studio-run#183 (PX0 exit-3, PERF).

The two stalls

#165 closed "the launcher declined". The commoner real-world case is "the launcher started
something that died", and it was still paying the full 30s budget per URL.

defaultLaunch attaches the spawn 'error' listener (required, #167 — an unlistened 'error'
is a dead MCP process, not a logged one), but the listener only logged, and the launcher had
already returned true. So ensureStudioRunning entered the handle poll against a process that
was already dead and burned DEFAULT_TIMEOUT_MS — 120 ticks at the shipped 250ms cadence.

And inFlight is single-flight, not a cache: it clears in the finally, so it only collapses
launches that overlap. A crawl does not overlap — src/fetch/router.ts:994 reaches the bridge
rung once per challenged page, sequentially, and src/fetch/studio-bridge.ts:40 awaits
ensureStudioRunning each time. 20 challenged pages against a substrate that cannot start slept
~10 minutes and left 20 dead spawn attempts.

The fix

  • Widened the launcher's return from a bare boolean to { started, failed }. The failure was
    already observed by the listener; it just had nowhere to be reported to. The poll reads
    failed() each tick — after the handle read (a spawn can publish and then error, and the handle
    is the answer either way) and before the deadline. boolean | void launchers stay legal,
    normalized at the single call site, so the rest of the suite's seams are untouched.
  • A 60s negative memo on a launch that produced no handle, so a fan-out pays the timeout once
    instead of per URL. Short on purpose: every failure mode behind it (chmod +x, an approved
    Gatekeeper dialog, a reinstall, starting the app by hand) is fixed in seconds, and the memo must
    not outlive the fix. Cleared by a handle appearing — the read is ahead of the memo check —
    so a human starting the app mid-crawl recovers rather than being locked out. Recorded on the
    shared promise, not in the first caller's await, so single-flight participants that were not
    first still see it. Sits after the launchable gate: an absent substrate already declines in
    zero ticks, and folding it in would make a substrate installed mid-session wait for no reason.
  • Docstring now states both windows and which mechanism closes each, plus the residual case
    (a start that neither declines nor errors) and that the memo is what bounds it.

Demo — real clocks, base worktree vs tip

Substrate installed, execute bit removed, two consecutive challenged fetches through
ensureStudioRunning:

[BEFORE] substrate installed, +x removed. launchable=true
[BEFORE] challenged fetch #1: handle=none after 30.22s
[BEFORE] challenged fetch #2: handle=none after 30.21s
--- dead spawn attempts: 2

[AFTER]  substrate installed, +x removed. launchable=true
[AFTER]  challenged fetch #1: handle=none after 0.25s
[AFTER]  challenged fetch #2: handle=none after 0.00s
--- dead spawn attempts: 1

60.43s → 0.25s. One poll tick to let the failure land, then the memo answers the second fetch for
free. spawn ... EACCES logged once, not twice.

Tests

Eight new arms in tests/unit/studio/auto-launch.test.ts, all under a fake clock and all measured
red at the base commit:

  • dead-spawn poll break: expected 1, received 120 at base — the stall itself, asserted as a tick
    count against the shipped 30s/250ms budget rather than a shrunken one.
  • memo: second ensureStudioRunning costs 0 ticks and does not re-spawn; expected 120 to be +0
    at base.
  • recovery: a handle appearing is answered and clears the memo, proven by the call after that
    session ends launching again instead of being declined.

Paired positive arms so no blanket short-circuit can satisfy them: a spawn with no reported failure
still polls to tick 4; the memo expires and retries; an absent substrate is not memoized; a
declined launch carries no failure probe.

Verification

CI deferred — quota.

Territory: src/studio/** + tests/unit/studio/** only. No widening, no lane-extra.

defaultLaunch's error listener kept the MCP process alive but only logged,
and the launcher had already returned true, so ensureStudioRunning entered
the handle poll against a dead process and burned the whole 30s budget --
120 ticks at the shipped 250ms cadence, once per challenged URL.

Widen the launcher's return to { started, failed } and read failed() each
poll tick. The failure was already observed; it just had nowhere to go.
boolean/void launchers stay legal, normalized at the one call site.
inFlight is single-flight, not a cache -- it clears in the finally, so it
only collapses launches that overlap. A crawl does not overlap: the router
reaches the bridge rung once per page, sequentially. Against a substrate
that cannot start, 20 challenged pages paid 20 separate 30s budgets.

Memo the no-handle outcome briefly so a fan-out pays it once. Cleared by a
handle appearing, so a human starting the app mid-crawl recovers instead of
being locked out; set on the shared promise so single-flight participants
that were not first still see it.
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d1e246b1-4650-43c9-8029-7c97e8d006bc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@KnockOutEZ
KnockOutEZ merged commit 2cdc4e9 into studio-handoff-core Aug 28, 2026
20 checks passed
@KnockOutEZ
KnockOutEZ deleted the sd-183-fix-studio-auto-launch-dead-spaw branch August 28, 2026 04:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant