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 intoAug 28, 2026
Conversation
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.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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.
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.
defaultLaunchattaches 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. SoensureStudioRunningentered the handle poll against a process thatwas already dead and burned
DEFAULT_TIMEOUT_MS— 120 ticks at the shipped 250ms cadence.And
inFlightis single-flight, not a cache: it clears in thefinally, so it only collapseslaunches that overlap. A crawl does not overlap —
src/fetch/router.ts:994reaches the bridgerung once per challenged page, sequentially, and
src/fetch/studio-bridge.ts:40awaitsensureStudioRunningeach time. 20 challenged pages against a substrate that cannot start slept~10 minutes and left 20 dead spawn attempts.
The fix
{ started, failed }. The failure wasalready 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 handleis the answer either way) and before the deadline.
boolean | voidlaunchers stay legal,normalized at the single call site, so the rest of the suite's seams are untouched.
instead of per URL. Short on purpose: every failure mode behind it (
chmod +x, an approvedGatekeeper 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 notfirst still see it. Sits after the
launchablegate: an absent substrate already declines inzero ticks, and folding it in would make a substrate installed mid-session wait for no reason.
(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:60.43s → 0.25s. One poll tick to let the failure land, then the memo answers the second fetch for
free.
spawn ... EACCESlogged once, not twice.Tests
Eight new arms in
tests/unit/studio/auto-launch.test.ts, all under a fake clock and all measuredred at the base commit:
expected 1, received 120at base — the stall itself, asserted as a tickcount against the shipped 30s/250ms budget rather than a shrunken one.
ensureStudioRunningcosts0ticks and does not re-spawn;expected 120 to be +0at base.
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, nolane-extra.