Skip to content

fix(studio): auto-launch seam contract holes — throwing probe rejects past NEVER-THROWS, falsy launch reads as started, poll-observed handle leaves stale memo - #519

Merged
KnockOutEZ merged 2 commits into
studio-handoff-corefrom
sd-191-fix-studio-auto-launch-seam-cont
Aug 28, 2026
Merged

fix(studio): auto-launch seam contract holes — throwing probe rejects past NEVER-THROWS, falsy launch reads as started, poll-observed handle leaves stale memo#519
KnockOutEZ merged 2 commits into
studio-handoff-corefrom
sd-191-fix-studio-auto-launch-seam-cont

Conversation

@KnockOutEZ

Copy link
Copy Markdown
Owner

Closes #191 (PX0 exit-7).

Three contract holes in ensureStudioRunning's exported deps surface, all on shapes normalizeLaunch already accepts.

1 — a poll-loop fault escaped as a rejection. failed(), the handle reader and sleep() are all called a tick past the try that guards the launcher, the .then memo mapper had no rejection branch, and the body is try { return await inFlight } finally with no catch. So launch: () => ({ started: true, failed: () => { throw } }) rejected out into studioBridgeFetch, which awaits with no catch — a launch problem becoming the user's fetch error, which the module docstring promises never happens. It also skipped the memo write, so the fan-out re-paid the poll budget per caller. The poll loop is now wrapped and resolves {handle: null, attempted: true} (logged, memoed) — an attempt that published nothing, which is what the negative memo is for, and distinct from the launcher catch's un-memoized NOT_ATTEMPTED.

Same class, one line up: the top-of-call handle read sat outside every try too, so the plainest fake — a reader that always throws — rejected straight out of the entry point before the poll was ever reached. Guarded; a handle that cannot be read is not evidence the substrate is absent, so it falls through to the launch path (A-191-2).

2 — falsy non-false launch answers read as "started". normalizeLaunch ended in "anything else means started", so null/0/''/NaN — one token from a correct decline, and () => null is what a launcher written against readSubstrateRecord's own return shape produces — bought the full 30s handle poll for a process nobody launched and then armed the 60s negative memo. The legal answers are now enumerated (false, true, void, LaunchOutcome) and anything else throws the same TypeError class the seam already gives a thenable or a non-callable failed. Chosen over a quiet decline because the throw is synchronous, so the launcher catch turns it into exactly the zero-tick un-memoized decline the alternative gives — plus a logged reason. Allowlist rather than a falsy check, because the same branch also read 1 and 'yes' as started. Recorded as A-191-1.

3 — a handle observed IN the poll never cleared the memo. Only the top-of-call read did, so a recovery that ran through the launch path — a memo-bypassing caller whose substrate came up on the second attempt — left the stale window standing: when that session ended, a default caller still inside the original 60s was declined with no spawn attempt, contradicting the memo's own "a handle appearing invalidates it immediately". The clear now sits beside the memo write on the shared promise, so single-flight participants get it too.

Demo — the three shapes red on tip 11b58f3a, green on the fix

$ npx vitest run tests/unit/studio/auto-launch.test.ts        # at 816b99ea, tests only
 FAIL  a launch that produced no handle is remembered briefly > is invalidated by a handle observed INSIDE the poll, not only by the top-of-call read
       AssertionError: expected "vi.fn()" to be called 1 times, but got 0 times
 FAIL  normalizeLaunch rejects a shape it cannot honestly read > throws on any answer that is not `false`, `true`, `void` or a LaunchOutcome
       AssertionError: expected [Function] to throw an error
 FAIL  a fault inside the handle poll is an outcome, never a rejection > a `failed` probe that throws gives up the poll instead of rejecting, and is remembered
       AssertionError: promise rejected "Error: probe boom" instead of resolving
 FAIL  a fault inside the handle poll is an outcome, never a rejection > a `sleep` that rejects mid-poll resolves null rather than rejecting
       AssertionError: promise rejected "Error: sleep boom" instead of resolving
 FAIL  a fault inside the handle poll is an outcome, never a rejection > a `readHandleFn` that throws resolves null — at the top-of-call read as well as in the poll
       AssertionError: promise rejected "Error: handle read boom" instead of resolving
       ❯ ensureStudioRunning src/studio/auto-launch.ts:384:20
 FAIL  a fault inside the handle poll is an outcome, never a rejection > a falsy non-`false` launch answer costs zero poll ticks and is not remembered
       AssertionError: expected 120 to be +0    // `launch: () => null` paid the whole budget

      Tests  6 failed | 49 passed (55)

$ npx vitest run tests/unit/studio/auto-launch.test.ts        # with the fix
      Tests  55 passed (55)

Over-catching is guarded by a paired positive arm (still returns a handle the poll finds, and still honours a probe that reports failure); mutating if (h) return { handle: h, … } to return null reds it along with 8 pre-existing arms.

Local verification (CI deferred — quota)

npm run build            exit 0
npm test                 955 passed | 5 skipped (960 files) — 12088 passed, exit 0
npx tsc --noEmit         exit 0
npm run gate:studio      exit 0   (tests/ type-check debt holds at baseline 363)

Non-goals untouched: memo backoff/escalation, keying the memo by dataDir, defaultLaunch. Territory: src/studio/** + tests/unit/studio/** only.

…o-invalidation contracts

Six arms, all red on tip: a throwing failed/sleep/readHandleFn rejects out of ensureStudioRunning; a falsy non-false launcher answer reads as started and pays the full poll budget; a handle observed inside the poll leaves the negative memo standing.
…memo-invalidation contracts

The handle poll calls failed(), the handle reader and sleep() a tick past the try
that guarded the launcher, and the top-of-call read sat outside every try, so a
throw from any of them rejected out of ensureStudioRunning into a caller that
awaits with no catch — and skipped the memo write, making the fan-out re-pay the
poll budget per URL. Both are guarded now: the top read falls through to the
launch path, and a poll fault resolves as an attempt that published nothing.

normalizeLaunch ended in "anything else means started", so null/0/''/NaN bought
a 30s poll for a process nobody launched plus a 60s lockout. The legal answers
are now enumerated and anything else throws at the seam, which the launcher
catch already turns into a zero-tick decline.

A handle observed inside the poll now clears the negative memo, which only the
top-of-call read did — so a substrate that came up on the second attempt left a
stale window that declined the next caller after its session ended.
@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: 64e85611-9eab-4ed8-8170-485f33abe150

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 00c0b49 into studio-handoff-core Aug 28, 2026
18 of 20 checks passed
@KnockOutEZ
KnockOutEZ deleted the sd-191-fix-studio-auto-launch-seam-cont branch August 28, 2026 23:14
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