diff --git a/.github/ISSUE_SPECS/376-browser-console-attachment-guard.md b/.github/ISSUE_SPECS/376-browser-console-attachment-guard.md new file mode 100644 index 00000000..320fe6ca --- /dev/null +++ b/.github/ISSUE_SPECS/376-browser-console-attachment-guard.md @@ -0,0 +1,79 @@ +# Browser host lacks the headless boundary's "no console attached" guard; refusal reason is misleading + +## Current approved scope — 2026-09-11 + +### 3. #376 — explain missing browser console attachment immediately + +Product outcome: requesting browser console controls without attaching a console gives an actionable usage error instead of a later misleading `unsupportedKind` refusal. + +Current evidence: `sdk/src/browser/engine.ts` lazily constructs the console without checking attachment; `sdk/src/browser/console.ts` requests the session map unconditionally. `hosts/host-web/src/lib.rs::submit_commands` maps missing console to `COMMAND_REASON_UNSUPPORTED_KIND`. The headless boundary already has the useful message. + +Smallest scope: check the effective browser boot console policy at the public console access boundary, reject its existing Promise with MisoUsageError directing callers to policy.console.commandQueueRecords, and document the raw lower-level `unsupportedKind` no-console case. The issue explicitly permits documentation instead of a new wire reason. Keep audio-only boot legal and explicit console opt-out intact. Do not auto-attach controls, redesign SDK defaults, or change ABI reason values. + +Acceptance: omitted, empty and explicit-zero console settings reject before sessionMap or command transport; later caller mutation cannot change the captured boot configuration; an attached console still admits commands; independent boot-policy handling and audio-only operation remain valid. Test browser/headless consistency and the packaged public SDK surface. Existing artifact pin stays fixed; required PR/main CI and synchronized closure. + +Risk/size: small, mostly SDK behavior and documentation. Coordinate overlap with broad #379 without adopting its entire API redesign. + + +Astra XHIGH scoped this issue and user authorized delivery. Astra LOW implements, Astra XHIGH verifies; five-attempt ceiling. Root owns checkpoint commits/pushes, artifact identity and GitHub synchronization. Start only after a slot is freed by #387/#211 delivery; at most two active issues. Own sdk/src/browser/engine.ts, necessary focused SDK tests and user-facing SDK documentation. No Rust/ABI/default-console policy or adapter changes; preserve async console API and captured boot-policy semantics. + +Run focused tests/type/package gates against the already qualified artifact, then independent review and required PR/main qualification. No artifact repin, new benchmark or compiler-IR captures. Preserve failed attempts. Close only after evidence is upstream, main qualification passes and remote issue state is synchronized; remove clean delivered worktree. + +## Historical issue body + +## Summary + +Two related SDK-level gaps make "I forgot to attach a console" a hard bug to diagnose in the browser, even though the SDK already has a good error message for this exact situation — it just doesn't fire on the path most browser integrations actually use. + +## 1. The headless boundary's clear guard doesn't exist on the browser/worklet host path + +Booting an engine session in-process (headless, via `@misofm/engine/headless`) without `console.commandQueueRecords` set and then touching `session.console` throws immediately with a clear, actionable message: + +``` +MisoUsageError: this engine booted with no console attached; set console.commandQueueRecords +``` + +Booting the same document the same way (no console configured) through the **browser** path (`@misofm/engine/browser`'s `createEngine`, as used by `@misofm/engine-web-adapter`) has no equivalent guard. Instead, the session opens successfully (`state: "ready"`, plays audio fine), and the absence of a console only becomes visible later, indirectly, as every `console.submit(...)` call resolving with: + +``` +{ ok: false, code: "unsupported", reasonName: "unsupportedKind", rejectedIndex: 0, admitted: 0 } +``` + +Traced this to `hosts/host-web/src/lib.rs`: `console_request()` maps `console_command_queue_records == 0` to `control_queue_depth: None`; `console_attached()` (checking `!ready.controls.is_empty()`) is then `false`; `submit_commands` unconditionally returns that refusal without decoding anything when `!self.console_attached()`. + +**Suggested fix:** give the browser host the same fail-fast behavior the headless boundary already has — either refuse to construct/return a working `session.console` object when no console was configured, or throw the same `MisoUsageError` message at the first `session.console` access, rather than requiring a caller to get all the way to a per-`submit()` refusal to find out. + +## 2. The Rust refusal reason for "no console attached" is indistinguishable from "unrecognized command kind" + +`submit_commands` reports the "no console attached" case using `COMMAND_REASON_UNSUPPORTED_KIND` — the same reason a genuinely malformed or unrecognized command kind would produce. The doc comment on that constant (around line 236) does name this specific case ("a host with no console attached at all"), so the distinction is understood internally, but it isn't exposed as a distinct value a caller can branch on or use to produce a clear error message. + +This reads very misleadingly from the caller's side: `faderDb`, `mute`, and `solo` are all long-established, correctly-encoded command kinds (verified: the JS-side `wireCommandKinds` table encodes them fine, no exception is thrown building the record) — "unsupported kind" strongly suggests an encoding bug on the caller's side, not "you never attached a console." + +**Suggested fix:** give this case its own reason (e.g. `COMMAND_REASON_NO_CONSOLE_ATTACHED`), or at least document `unsupportedKind` explicitly as covering this case so SDK consumers know to check console attachment first when they see it. + +## Why this matters + +Both gaps compounded to turn a one-line missing boot option into a debugging session that needed a full headless-Chrome CDP reproduction to pin down — the engine looked entirely healthy (correct session shape, correct track list, real audio playing) right up until every single real-time control silently failed. Root cause and full repro trace are in the companion issue misofm/engine-web-adapter#9, which is what actually caused this for us (it doesn't default or surface the missing `policy.console` option) — this issue is specifically about the two SDK/engine-side gaps that made the resulting failure hard to diagnose once it happened. + +## Astra LOW attempt 1 checkpoint + +BrowserEngine.console now returns a cached rejected Promise with MisoUsageError +and the browser policy path when captured commandQueueRecords is absent/zero. +Attached-console behavior, async contract and console-free boot/close remain. +Focused tests include mutation during boot, no transport on refusal, positive +command admission, and packaged public browser/headless error behavior. +Typecheck, 61 focused SDK tests and sdk-package check against the unchanged +qualified80abeec2 artifact passed. Actual commands/env/exits/logs: +`/tmp/issue376-attempt1`. No physical-browser opt-in run is claimed; required +CI/browser delivery remains. Root checkpoints; independent XHIGH review pending. + +## Independent Astra XHIGH attempt 1 PASS + +Reviewed sourcec1385c26bb4409f100ba9d68d30e9180282b4462. Captured-policy and +cached async refusal, zero session-map/command effects, positive attachment, +caller mutation and packaged API behavior verified. Typecheck, 61 tests and +package gate accepted. Isolated original-code negative control failed all +three no-console cases while positive attachment passed. All six qualified +artifact hashes unchanged; no source revision requested. Evidence: +`/tmp/issue376-xhigh-_l8tc6wa/review.md`, review.json and negative.json. +Required PR/main qualification and synchronized closure remain. diff --git a/sdk/README.md b/sdk/README.md index f49343e6..315a90ad 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -225,7 +225,15 @@ at most that source ring's capacity. Shared source runways remain available whil fill gradually, avoiding a first-callback burst proportional to every queued source quantum. `await engine.console()` binds the same semantic console shown above to the shipped browser host. -It resolves the browser session map once, then submits the same whole-batch edits over MessagePort. +Set `policy.console.commandQueueRecords` to a positive capacity when calling `createEngine` to +attach controls. Omitting it, passing an empty console policy, or setting it to zero keeps audio-only +boot valid; `engine.console()` then returns a rejected Promise with an actionable `MisoUsageError` +before requesting the session map or sending commands. The captured boot policy controls attachment; +changing the caller's policy object later cannot attach or detach a console. + +With controls attached, it resolves the browser session map once, then submits the same whole-batch +edits over MessagePort. At the raw host boundary, `unsupportedKind` also means no console was attached; +check the boot console capacity before interpreting it as an unrecognized command kind. All eleven live command kinds are available without numeric rack, channel, parameter, or tap IDs; the browser and headless acknowledgements carry the same generated result/reason names and exact `appliedAtSample`. diff --git a/sdk/src/browser/engine.ts b/sdk/src/browser/engine.ts index 945ceacc..7cbf288d 100644 --- a/sdk/src/browser/engine.ts +++ b/sdk/src/browser/engine.ts @@ -112,7 +112,7 @@ export interface BrowserEngine; /** Dispose the worklet host, then close its context. Safe to call more than once. */ close(): Promise; @@ -239,7 +239,11 @@ export async function createEngine(options: CreateEngineOptions): Promise { - semanticConsole ??= createBrowserConsole(host); + semanticConsole ??= (policy.console?.commandQueueRecords ?? 0) === 0 + ? Promise.reject(new MisoUsageError( + "this engine booted with no console attached; set policy.console.commandQueueRecords", + )) + : createBrowserConsole(host); return semanticConsole; }, close: () => { diff --git a/sdk/test/browser-defaults-evals.mjs b/sdk/test/browser-defaults-evals.mjs index 81aba685..71ad7be0 100644 --- a/sdk/test/browser-defaults-evals.mjs +++ b/sdk/test/browser-defaults-evals.mjs @@ -339,3 +339,47 @@ test("preparation admits several independent mono and stereo sources", async () ["s0", 1, 257n], ["s1", 2, 258n], ["s2", 1, 259n], ["s3", 2, 260n], ]); }); + + +for (const initialConsole of [undefined, {}, { commandQueueRecords: 0 }, { commandQueueRecords: 64 }]) { + test(`console attachment uses captured boot policy: ${JSON.stringify(initialConsole)}`, async () => { + const attached = (initialConsole?.commandQueueRecords ?? 0) > 0; + const policy = initialConsole === undefined ? {} : { console: { ...initialConsole } }; + let maps = 0; let commands = 0; const closed = []; + const engine = await createEngine({ + document: new Uint8Array([1]), policy, + scratchBoot: async () => { + policy.console ??= {}; + policy.console.commandQueueRecords = attached ? 0 : 64; + return shape; + }, + createContext: () => ({ sampleRate: 48000, renderQuantumSize: 128, state: "running", + audioWorklet: { async addModule() {} }, async close() { closed.push("context"); } }), + createHost: async request => { + assert.equal((request.options.console?.commandQueueRecords ?? 0) > 0, attached); + return { + async sessionMap() { maps++; return { tracks: ["t"], sources: [], metersAttached: false }; }, + async command(request) { commands++; return { result: 0, reason: 0, rejectedIndex: 0, + admitted: request.commands.length, appliedAtSample: 128n }; }, + async dispose() { closed.push("host"); }, + }; + }, + }); + let pending; + assert.doesNotThrow(() => { pending = engine.console(); }); + assert.ok(pending instanceof Promise); + assert.equal(engine.console(), pending, "console Promise is cached"); + if (attached) { + const controls = await pending; + assert.equal((await controls.submit(controls.edit.track("t").faderDb(-6))).ok, true); + assert.equal(maps, 1); assert.equal(commands, 1); + } else { + await assert.rejects(pending, error => error instanceof MisoUsageError && + error.message.includes("no console attached") && error.message.includes("policy.console.commandQueueRecords")); + assert.equal(maps, 0); assert.equal(commands, 0); + } + assert.equal(engine.context.state, "running"); + await engine.close(); await engine.close(); + assert.deepEqual(closed, ["host", "context"]); + }); +} diff --git a/sdk/test/package-tarball-smoke.mjs b/sdk/test/package-tarball-smoke.mjs index 777108b4..467318eb 100644 --- a/sdk/test/package-tarball-smoke.mjs +++ b/sdk/test/package-tarball-smoke.mjs @@ -249,6 +249,28 @@ const sibling = await imported["./headless"].createOfflineEngine( builtDocument.replace('"session_id": "tarball.boot"', '"session_id": "tarball.sibling"'), ); try { + const controls = engine.console(); + await assert.rejects(controls.submit(controls.edit.track("track").faderDb(-6)), error => + error.name === "MisoUsageError" && error.message.includes("no console attached") && + error.message.includes("console.commandQueueRecords")); + const browser = await imported["./browser"].createEngine({ + document: builtDocument, + scratchBoot: async () => engine.shape(), + createContext: () => ({ sampleRate: 48000, state: "running", + audioWorklet: { async addModule() {} }, async close() {} }), + createHost: async () => ({ + async sessionMap() { assert.fail("no-console access must not request sessionMap"); }, + async command() { assert.fail("no-console access must not send commands"); }, + async dispose() {}, + }), + }); + try { + let pending; + assert.doesNotThrow(() => { pending = browser.console(); }); + assert.ok(pending instanceof Promise); + await assert.rejects(pending, error => error.name === "MisoUsageError" && + error.message.includes("policy.console.commandQueueRecords")); + } finally { await browser.close(); } assert.equal(engine.asset.sha256?.length, 64); assert.equal(engine.asset, sibling.asset, "default engines share one verified compilation"); assert.equal(engine.shape().sampleRateHz, 48_000);