diff --git a/.github/ISSUE_SPECS/393-give-the-browser-host-sole-ownership-of-its-request-id-ledger.md b/.github/ISSUE_SPECS/393-give-the-browser-host-sole-ownership-of-its-request-id-ledger.md new file mode 100644 index 000000000..6dc75e8ac --- /dev/null +++ b/.github/ISSUE_SPECS/393-give-the-browser-host-sole-ownership-of-its-request-id-ledger.md @@ -0,0 +1,222 @@ +# Give the browser host sole ownership of its request-id ledger + +**Issue:** #393, `Give the browser host sole ownership of its request-id ledger` +**Baseline:** `origin/main` at `0e248bb0` +**Status:** scope, decision record, and objective gates approved for Luna attempt 1; Astra performs the adversarial PR review. These explicit user model assignments replace the default Terra/Sol implementation/review roles for this issue only. + +## Smallest closable product slice + +Make `MisoAudioWorkletHost` the sole allocator of request IDs for every request it sends. Public request objects no longer accept `requestId`; wire messages still carry it, and acknowledgements/errors still return it for correlation. Migrate the two in-repository consumers of the public seam: the SDK browser console and the browser qualification harness. + +This closes the demonstrated collision: `status()`, `sessionMap()`, and `dispose()` already allocate from the host's private ledger, while `command`, `observe`, `meters`, `telemetry`, `submitSource`, and `seekSource` currently accept independently allocated IDs. The SDK console compounds that by deriving its own counter from one `sessionMap()` acknowledgement. + +No worklet, wire, Rust, render, ABI, session, source-shape, console receipt, or lifecycle behavior changes. In particular, the raw worklet's stale-ID branch is outside this symptom and outside this issue. + +## Contract and implementation decision + +1. The host has one private allocation point in the common request path. Callers pass payload only. The host stamps a positive, strictly increasing safe-integer ID on the outbound port message immediately before transport admission. +2. Existing validation and per-class saturation checks remain before allocation. A malformed request, a request refused because its response class is at capacity, and a request after disposal must not consume an ID or post a message. Their local error remains typed and uses `requestId: 0`, because no correlation ID was allocated. +3. A synchronous `postMessage` failure may leave a consumed ID: reuse would be unsafe because delivery cannot be inferred from an exception. Existing pending-release and sticky/failure behavior remains authoritative. The issue must not promise a gapless ledger across transport failure. +4. Before incrementing, the allocator checks `Number.MAX_SAFE_INTEGER`. Exhaustion rejects locally with the existing invalid-request result and `requestId: 0`, posts nothing, does not wrap or reuse an ID, and remains deterministic on repeated calls. Do not add a public counter accessor or test hook; exercise exhaustion through a private implementation seam only if the current harness can do so without production API expansion. If it cannot, use a source mutation/static discriminator rather than adding public surface. +5. Remove `requestId` from the exact-field guards and TypeScript request types for command, observation, source submission, source seek, meter lease, and telemetry lease. Preserve `readonly requestId` on response types and frames where already present. Old request objects containing `requestId` are rejected as extra-field invalid input at runtime and fail TypeScript excess-property checking; there is no compatibility shim. +6. `observe()` delegates to `command()` without allocating twice. `status()`, `sessionMap()`, and `dispose()` also enter the same allocator without computing a candidate ID themselves. +7. Preserve command-batch semantics byte-for-byte: whole-batch validation/admission, reason, rejected index, admitted count, applied-at sample, and returned record ownership are unchanged. + +## Allowed paths + +- `.github/ISSUE_SPECS/393-give-the-browser-host-sole-ownership-of-its-request-id-ledger.md` +- `hosts/host-web/web/miso-engine-v1-audio-worklet-host.js` +- `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts` +- `sdk/src/browser/shipped-host.d.ts` +- `sdk/src/browser/console.ts` +- `scripts/test-web-audioworklet.mjs` +- `sdk/test/console-evals.mjs` +- `sdk/test/console-types.ts` +- `hosts/host-web/qualification/qualification.js` +- `sdk/test/package-tarball-smoke.mjs` only if needed to prove the packed consumer-facing declaration and runtime shape + +Generated package staging output and untracked build output are evidence, not committed paths. If implementation requires any other tracked path, stop and amend the issue before editing it. + +## Explicit exclusions + +- `hosts/host-web/web/miso-engine-v1-audio-worklet.js`, all Rust/Cargo paths, Wasm or artifact hashes, port-message field sets/tags, ABI and boot options. +- New error vocabulary, request broker, counter accessor, compatibility layer, cancellation, retry, render fence, console coalescing, session/readback/revision/automation, source metadata, storage, decoder, adapter, app, or lifecycle redesign. +- Issues #369 and #370 and every other DX-plan slice. + +## Objective gates + +1. **Public shape and hard break.** Type tests prove all six caller-controlled request categories omit `requestId`, all affected responses retain their existing readonly ID, and an old-shaped request is a compile error. Runtime tests prove an extra caller ID is rejected before `postMessage` with invalid-request and ID zero. +2. **Mixed-call ownership regression.** On one host, run at least 200 interleaved calls covering `command`, `observe`, `meters`, `telemetry`, `submitSource`, `seekSource`, `status`, and `sessionMap`, including SDK-console calls mixed with direct host calls. All successful responses have unique strictly increasing IDs in actual send order, with no caller counter and no retry. Include both orderings of meter/command and the reproducer `createBrowserConsole(host) -> direct meters -> console.submit`. +3. **Capacity/disposal do not burn IDs.** Hold each relevant bounded class at capacity, verify the next local refusal is ID zero and sends nothing, then settle capacity and verify the next success follows the prior allocated ID. After disposal, requests reject locally with ID zero and no send. Existing pending counters and source ownership remain balanced. +4. **Transfer ownership.** A valid `submitSource` transfers each unique `ArrayBuffer` once and returns the existing planes on acknowledgement. Invalid shape and local saturation leave buffers usable in the caller. Mixed source, seek, and control traffic cannot collide IDs. +5. **Safe-integer exhaustion.** A discriminating test proves the last safe ID can be allocated once; every later request refuses locally, never posts, never wraps/reuses, and returns the same typed terminal result. Restore unchecked `+ 1` as a red mutation and require failure. +6. **Single allocator and delegation.** Static/runtime evidence shows one production increment/allocation site, no `#lastRequestId + 1` call-site allocation, no SDK counter, and exactly one ID consumed by `observe()` despite its command delegation. No public next-ID accessor exists. +7. **Receipts unchanged.** Existing command tests continue to prove atomic whole-batch success/refusal, `appliedAtSample`, `admitted`, rejection reason/index, and transferred record return. This issue adds no new receipt meaning. +8. **Actual consumers.** Browser qualification uses payload-only requests and passes. The SDK console uses payload-only commands. A freshly packed SDK consumer typechecks the new browser declaration; where the existing tarball harness can instantiate the host fixture, it also exercises a payload-only call from package output. +9. **Proportional gates.** Run and attach exact results for `scripts/test-web-audioworklet.sh`, `scripts/check-sdk-types.sh`, `scripts/check-sdk-headless.sh`, `scripts/check-sdk-generated.sh`, `scripts/sdk-package.sh check`, and the focused browser qualification command already used by this repository. Run the packed tarball smoke when gate 8 changes it. Do not rebuild or test every Rust binary for this JavaScript/TypeScript-only slice unless a repository gate directly invokes a focused generator/oracle. +10. **Scope proof.** Diff contains only allowed paths; the worklet JS, Wasm, artifact hash, Rust, wire fields, and render callback are byte-identical to baseline. + +## Review questions for Astra + +- Can any public request still choose, predict, or indirectly allocate an ID twice? +- Can validation, saturation, disposal, or safe-integer exhaustion mutate the ledger or transfer buffers? +- Does `observe()` consume exactly one ID? +- Can any acknowledged command batch later be dropped, or did this refactor alter the existing atomic receipt fields? +- Do source buffers retain the same transfer/return behavior on success, refusal, and capacity pressure? +- Does packed TypeScript expose the same request shape tested in source? + +## Delivery record + +Root must first synchronize this approved body to the correctly named local issue spec and GitHub issue #393, then commit that issue-only checkpoint. Luna may implement only after that checkpoint. One coherent implementation tranche is followed by a root status/commit audit and Astra adversarial review; the standard three-attempt stop remains in force. + +## Luna attempt 1 implementation evidence (commit-ready, not yet reviewed) + +Changed only the approved host, SDK declaration/consumer, and qualification paths. The browser +host now validates payloads and saturation before allocating one private strictly increasing safe +integer, stamps that ID immediately before `postMessage`, and returns request ID zero for local +refusals. `observe()` delegates to `command()` with no second allocation. The SDK console and +qualification consumers no longer carry a caller counter or request ID in public requests. + +Evidence so far: + +- `./sdk/node_modules/.bin/tsc -p sdk/tsconfig.json --noEmit` passed. +- `bash scripts/check-sdk-types.sh` passed, including the shipped-host mirror pin. +- `node scripts/test-web-audioworklet.mjs` passed; output is saved at + `/private/tmp/dx-393-evidence/test-web-audioworklet.log`. +- `node --check` passed for changed JavaScript/ESM files and `git diff --check` passed. +- The full `scripts/test-web-audioworklet.sh` reached its browser-harness checks but stopped in the + environment WebDriver self-test because binding `127.0.0.1` is denied (`PermissionError`); no + source workaround was made. The direct hermetic runtime gate passed independently. + +Root must checkpoint these exact paths before further edits; Astra review remains pending. +# Issue #393 — attempt 2 addendum (Sol approved) + +**Attempt 1 verdict:** FAIL at `f21c426e`; Astra review: `/private/tmp/dx-393-astra-review.md`. +**Implementer/reviewer:** Luna performs this bounded revision under the user's explicit override; Astra re-reviews attempt 2. +**Scheduling:** do not begin while adapter issue #19 has an uncheckpointed tranche. + +The approved product contract is unchanged: the shipped browser host alone allocates request IDs; public request payloads omit them; wire messages and acknowledgements retain them. No raw-worklet, Rust, ABI, render, receipt, or artifact-pin change is authorized. + +## Required corrections + +1. Update the authoritative `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts` so command, observation, source, seek, meter, and telemetry request payloads exactly match the payload-only runtime. Keep every response `readonly requestId`. Keep `sdk/src/browser/shipped-host.d.ts` byte-identical to that authority. +2. Narrowly update `scripts/check-command-reason-vocabulary.py` observation-shape assertions from `[requestId, subscriptions]` to `[subscriptions]`. Preserve every command-reason, subscription, binding, acknowledgement, and mutation discriminator. Add/adjust self-test mutations so restoring `requestId` in either the declaration or `observe()` exact-field guard turns the validator red; do not loosen comparison or delete a check. +3. Complete the frozen regression evidence rather than substituting source inspection: + - compile-time and runtime old-shape refusal for all six caller-controlled request categories, each local refusal asserting `requestId === 0` and no post; + - at least 200 successful mixed calls on one real host fixture spanning command, observe, meters, telemetry, source, seek, status, and sessionMap, with strictly increasing unique acknowledgements in send order; + - both meter/command orderings and `createBrowserConsole(realHost) -> direct meters -> console.submit`, with no retry; + - every bounded response class: saturation refuses locally with ID zero/no send/no burn, then the next accepted request advances by exactly one; repeat after disposal; + - malformed source retains caller ownership, while accepted and engine-refused source paths preserve the existing transfer/return behavior; + - safe-integer boundary: a test-only transformed import starts the private counter at `MAX_SAFE_INTEGER - 1`, proves the last safe ID is emitted once, and proves repeated exhaustion rejects locally with invalid-request/ID zero, no post, wrap, or reuse. Do not add a production accessor or hook. The named mutation removing the exhaustion guard must fail this test. +4. Extend the packed SDK consumer gate to compile payload-only calls for all six request categories against the staged declaration. Runtime packed-host coverage may reuse the existing host harness; no new browser matrix is required. + +## Meter callback note + +Attempt 1 removed caller IDs but did not intentionally redesign lease callbacks. Astra must compare the `meters()`/`telemetry()` callback assignment timing with `0e248bb0`. Tests for saturation/no-burn must not silently normalize unrelated pre-existing callback behavior. If attempt 1 introduced a callback-state regression, restore baseline behavior in the host JS; if baseline already mutates callbacks before admission, record it outside #393 and do not broaden this revision. + +## Amended exact allowed paths + +- `.github/ISSUE_SPECS/393-give-the-browser-host-sole-ownership-of-its-request-id-ledger.md` (attempt/evidence record only) +- `hosts/host-web/web/miso-engine-v1-audio-worklet-host.js` +- `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts` +- `sdk/src/browser/shipped-host.d.ts` +- `sdk/src/browser/console.ts` +- `hosts/host-web/qualification/qualification.js` +- `scripts/test-web-audioworklet.mjs` +- `scripts/test-web-audioworklet.sh` only for the safe-integer red mutation runner if the existing module override cannot express it in the `.mjs` test +- `scripts/check-command-reason-vocabulary.py` (newly approved, narrow observation request-shape update plus discriminating mutations) +- `sdk/test/console-evals.mjs` +- `sdk/test/console-types.ts` +- `sdk/test/package-tarball-smoke.mjs` + +No other path is authorized. In particular, do not touch `hosts/host-web/web/miso-engine-v1-audio-worklet.js`, any Rust/Wasm/generated ABI asset, or `hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256`. The artifact-pin mismatch is under separate baseline investigation and must not be repinned or attributed to #393. + +## Validation required before Astra re-review + +- Focused: `node scripts/test-web-audioworklet.mjs`; `python3 -B scripts/check-command-reason-vocabulary.py --self-test`; `python3 -B scripts/check-command-reason-vocabulary.py`; `scripts/check-sdk-types.sh`; `scripts/check-sdk-generated.sh`. +- Full proportional gates from the frozen spec: `scripts/test-web-audioworklet.sh`, `scripts/check-sdk-headless.sh`, `scripts/check-sdk-deletions.py`, `scripts/sdk-package.sh check`, focused browser correctness, and browser qualification. +- Attach the safe-integer red-mutation failure, exact mixed-call count, acknowledgement range/order, per-class saturation/no-burn table, all-six type/runtime shape results, packed-consumer result, and diff proof that raw worklet/Rust/Wasm/artifact pin remain unchanged. + +Attempt 2 passes only when both P1 findings are corrected and every previously missing frozen gate has executable evidence. Do not lower a gate because attempt 1 omitted it. + +Root baseline qualification ruling: unchanged base `0e248bb0` and PR `f21c426e` both rebuild on Darwin arm64/Rust 1.97.1 to digest `2f7941af57dbbee29f9407ee8a65cd58eac376f45f336ac40bd92690d415563b`, while the frozen pin is `22e4c25cba7f97b66db720ad8ac8cf653de0afcabe84101693f4fa166b90d4e6`. This repeats the existing cross-host reproducibility limitation recorded in #333/#345, not a Rust change in this issue. Use the exact successful baseline CI run `33930536895`, artifact `9958403991` (`audioworklet-0e248bb07cfbf7dd136ec48649ec61ee9171d15b`), retaining its unchanged pinned Wasm/worklet/metadata and overlaying only this PR's host JS/declaration for package/browser checks. Do not repin. Root retains baseline logs and downloaded originals separately. + +## Luna attempt 2 checkpoint evidence + +Corrected the authoritative host declaration and the observation-shape validator, with its old-shape mutations retained as red discriminators. Added all-six type/runtime probes, readonly response IDs, 250 mixed real-host calls and real SDK-console/direct-host interleaving, bounded-class no-send/no-burn and disposal assertions, malformed-source ownership, and safe-integer exhaustion with the unchecked-increment red mutation. Packed consumer probes cover all six payload categories. + +Focused host tests, full `scripts/test-web-audioworklet.sh`, observation validator/self-test, SDK types, SDK generated surface and `git diff --check` pass. Headless evals: 133 pass, one platform-capability skip. `scripts/sdk-package.sh check /private/tmp/dx-393-current-artifacts` passes the freshly staged and packed consumer gate using the verified CI Wasm closure described above, overlaid only with current host JS/declaration. Logs are under `/private/tmp/dx-393-evidence/attempt2-*`. Raw worklet, Rust, Wasm, ABI assets and artifact pin are unchanged. Browser qualification and Astra attempt 2 verdict remain pending; this checkpoint is not a PASS claim. +# Issue #393 — attempt 3 test-only addendum (Sol approved) + +**Attempt 2 verdict:** FAIL at `beeb8557` for two missing persistent evidence cases only. Astra found no remaining production defect; review: `/private/tmp/dx-393-astra-review-attempt2.md`. +**Implementer/reviewer:** Luna adds the bounded tests after adapter issue #19 reaches its checkpoint; Astra performs the final adversarial review. This is attempt 3, so failure triggers the mandatory stop/rescope. + +## Authorized work + +Integrate the same proof demonstrated by Astra's temporary probes into existing repository tests. Do not alter production logic. + +1. Complete `sdk/test/console-types.ts`: + - add negative old-shape probes for `meters({requestId,...})` and `telemetry({requestId,...})`; + - assert `requestId` is absent from all six public request parameter types; + - add readonly-assignment failures for `MisoAck`, `MisoCommandAck`, `MisoObservationAck`, `MisoStatus`, `MisoSessionMap`, and `MisoError` (covering source/seek/lease acknowledgements through `MisoAck`). + Astra's `/private/tmp/dx-393-astra-types.mts` is the behavioral template; adapt it to repository-relative imports and existing style. +2. Complete `scripts/test-web-audioworklet.mjs`: + - make at least one source submission in the committed mixed-call fixture receive `result === 0`; + - assert its input buffer transfers exactly once, returned planes restore ownership with the expected shared buffer/offsets, and the acknowledgement is result zero; + - retain the existing separate result-6 backpressure, processor-error, malformed-source, and saturated-source ownership cases; + - compare the mixed acknowledgements directly with recorded outbound send IDs, require adjacent IDs to differ by exactly one, and explicitly prove `observe()` consumed one allocation. + Astra's `/private/tmp/dx-393-astra-mixed.mjs` demonstrates the required result: 250 result-zero calls, send IDs 2 through 251, and one observe allocation. Integrate the proof without copying its absolute paths or weakening other cases. +3. Update the issue spec evidence/attempt record truthfully after gates run. + +## Exact allowed paths + +- `.github/ISSUE_SPECS/393-give-the-browser-host-sole-ownership-of-its-request-id-ledger.md` +- `sdk/test/console-types.ts` +- `scripts/test-web-audioworklet.mjs` +- `sdk/test/package-tarball-smoke.mjs` only if the existing packed type probe cannot exercise the readonly/negative request assertions; prefer the source type test and do not duplicate it unnecessarily + +No production `.js`, `.ts`, `.d.ts`, Python gate, worklet, Rust, Wasm, ABI asset, artifact pin, qualification code, or package surface may change. + +## Validation and evidence + +- Run `scripts/check-sdk-types.sh` and show the added `@ts-expect-error` probes are consumed. A red mutation restoring meter/telemetry caller IDs or writable response IDs must fail typecheck. +- Run `node scripts/test-web-audioworklet.mjs`; report total mixed calls, result-zero count, exact first/last send ID, consecutive ordering, observe allocation count, and successful-source transfer/return assertions. +- Run the existing safe-integer/source/backpressure mutations through `scripts/test-web-audioworklet.sh`; no gate is weakened. +- Re-run the already proportional issue gates needed for the final evidence record. Existing full result was 133 pass/1 platform skip, packed SDK passed, and Chromium 151 qualification passed; record fresh results if code/test changes cause those gates to rerun. No additional browser matrix is required. +- Diff proof must show test/spec-only changes and zero production or artifact-pin changes. + +Attempt 3 passes only when both missing persistent regressions are committed and all frozen gates remain green. No fourth attempt is permitted. + +## Luna attempt 3 evidence + +This final attempt changes tests only. `sdk/test/console-types.ts` now covers negative caller IDs +for all six request categories, type-level absence of `requestId` from every request parameter, +and readonly assignment failures for all six response-ID families. The hermetic host fixture now +records 250 result-zero mixed calls across command, observe, meters, telemetry, source, seek, +status, and sessionMap; it compares acknowledgements directly with outbound send IDs 2 through +251, requires adjacent IDs, and checks that observe consumes exactly one allocation. Each mixed +source uses one shared transferred buffer, asserts caller detachment, returned plane offsets and +shared returned storage, and exactly one transferred backing buffer. Existing source backpressure, +processor-error, malformed-source, and saturated-source ownership cases remain in place. + +Fresh evidence: + +- `./sdk/node_modules/.bin/tsc -p sdk/tsconfig.json --noEmit` and `bash scripts/check-sdk-types.sh` + passed, consuming all new negative and readonly probes. +- `node scripts/test-web-audioworklet.mjs` passed with the recorded line + `Issue393 mixed calls: 250 result-zero, send IDs 2..251, adjacent, observe single allocation`. +- `PATH=/opt/homebrew/opt/gnu-sed/libexec/gnubin:$PATH bash scripts/test-web-audioworklet.sh` + passed, including the MAX_SAFE_INTEGER boundary and unchecked-increment red mutation. The log + is `/private/tmp/dx-393-evidence/attempt3-web-gate.log`. +- `git diff --check` passed. The diff is limited to this evidence record and the two approved test + files; production host, declarations, worklet, Rust, Wasm and artifact pin are unchanged. + +Root browser evidence on `beeb8557`: existing qualification passed in Playwright Chromium 151.0.7922.34, Firefox 153.0, and WebKit 26.5 against the verified pinned CI Wasm plus current host JS/declaration. Logs are `/private/tmp/dx-393-evidence/attempt2-chromium-qualification.log`, `firefox-qualification.log`, and `webkit-qualification.log`. These are automated browser-engine results, not shipping Safari/iOS/device qualification. Attempt 2's 250-call fixture contained 225 result-zero replies and 25 resolved source-backpressure replies; attempt 3 adds the missing persistent successful-source case and corrects that evidence claim. All product code is unchanged by attempt 3. + +## Final Astra verdict — PASS + +Astra independently reviewed attempt 3 at the exact pushed commit `bed7634c7bb86ede24b577dc09ab9895208d803f` and verified that remote head with `git ls-remote`. Hermetic and type gates passed independently. Restoring source backpressure in the successful-source fixture makes its new assertion red; permitting optional meter/telemetry caller IDs makes both negative and key-exclusion type probes red. The final tranche changes only tests/spec, with product code unchanged from the fully qualified attempt 2. Astra found no remaining mandatory evidence gap. Full review is attached to PR #398; local copy `/private/tmp/dx-393-astra-review-attempt3.md`. The feature is implemented and reviewed upstream; npm publication is outside this issue and no registry-release claim is made. + +## Delivery isolation (2026-09-05) + +The approved request-ID capability and independently approved #409 CI fixture correction are isolated on codex/dx-host-control for a focused PR. This branch excludes all unqualified #405 PCM changes. No host implementation changed during isolation; source remains the Astra-reviewed #393 checkpoint. Existing PR #398 preserves the unqualified PCM branch and review evidence. No merge or publication is claimed. diff --git a/.github/ISSUE_SPECS/409-document-host-exhaustion-test-seam-and-make-telemetry-fixture-deterministic.md b/.github/ISSUE_SPECS/409-document-host-exhaustion-test-seam-and-make-telemetry-fixture-deterministic.md new file mode 100644 index 000000000..d5fa436bc --- /dev/null +++ b/.github/ISSUE_SPECS/409-document-host-exhaustion-test-seam-and-make-telemetry-fixture-deterministic.md @@ -0,0 +1,68 @@ +# Document host exhaustion test seam and make telemetry fixture deterministic + +**Status:** Sol brief approved by root; matching GitHub issue misofm/engine#409. This is a candid merge-blocker correction for PR #398 / qualification run `33935625430`; it is not issue #393 attempt 4. Issue #393 already exhausted its three attempts and received Astra PASS at `bed7634c`. Its host product result remains closed. + +## Smallest closable slice + +Make the two required CI checks deterministic and truthful without changing browser-host behavior: + +1. Document the existing private safe-integer test selector `MISO_ENGINE_WEB_HOST_MAX_SAFE_TEST` in the enforced environment vocabulary. It was introduced in reviewed #393 commit `beeb8557a`, is set only by `scripts/test-web-audioworklet.sh`, and selects the transformed-host exhaustion branch in `scripts/test-web-audioworklet.mjs`. No variable is renamed and no second selector is added. +2. Replace the telemetry fixture's use of the Node process's real `performance.now()` with a test-local deterministic clock installed only while the telemetry processors are constructed and exercised. Keep exact assertions for zero misses and add a positive case with exactly one injected over-budget block. + +The second failure predates #393: `scripts/test-web-audioworklet.mjs:1502` and its `deadlineMisses === 0` assertion are from #137 baseline `1f8d3f0df`. Node 22.23.2 observed one scheduler-sensitive miss. Record that as a real-clock test-fixture defect, not a Node product regression established by evidence. + +## Exact correction + +### Vocabulary + +Add one row beside the existing host test-module selector in `docs/ENGINE_ENV_VOCABULARY.md`: + +`MISO_ENGINE_WEB_HOST_MAX_SAFE_TEST` — hermetic host allocator test selector; when `1`, the existing test runs the transformed private counter at `MAX_SAFE_INTEGER - 1`, proves the final safe ID once, then proves repeatable local exhaustion with no post, wrap or reuse. + +The existing bidirectional checker and its self-test already discriminate undocumented and unused rows. Do not exempt a path, weaken `check-env-vocabulary.sh`, rename the environment variable, add an alias, or edit the #393 implementation/spec merely to hide the failure. + +### Deterministic telemetry clock + +In `scripts/test-web-audioworklet.mjs`, keep the existing worklet source and `makeProcessor()` path. Add a small local clock fixture that returns monotonic start/end samples for each render block. Temporarily replace `globalThis.performance` (preserving/restoring its original value in `finally`) before constructing the processor, so the existing `renderClock()` probe and `telemetryMessage.resolutionMs` use the same injected clock naturally. Do not assign `processor.clock` after construction or patch the imported worklet. + +Run two exact 128-block windows through the normal `process()` path: + +- a no-miss window whose per-block elapsed value is a fixed positive duration safely below the 64-frame/48-kHz budget; require one telemetry frame and `deadlineMisses === 0`; +- a fresh processor/window with that same duration except for exactly one elapsed value above budget; require one telemetry frame and `deadlineMisses === 1`. + +Retain the existing assertions for block/window count, sequence, budget range, positive reported resolution, `belowResolution`, finite/nonnegative CPU fields, lease release, and no clock reads/messages after release. The injected clock should count reads so the fixture also proves two reads per leased rendered block and no reads after release. Restore the real global clock even if an assertion fails. + +The exact one-miss case is the red discriminator: a correction that clamps, ignores or loosens deadline misses must fail. Do not replace `=== 0` with a range, retry the test, increase the budget, skip on Node 22, sleep, mock render output, or change telemetry/product arithmetic. + +## Exact allowed paths + +- `.github/ISSUE_SPECS/409-document-host-exhaustion-test-seam-and-make-telemetry-fixture-deterministic.md` — new stateless tooling successor and evidence +- `docs/ENGINE_ENV_VOCABULARY.md` +- `scripts/test-web-audioworklet.mjs` + +No other tracked path is allowed. In particular, do not edit `hosts/host-web/**`, SDK source/declarations, `scripts/test-web-audioworklet.sh`, either environment-vocabulary checker/test, workflows, generated assets, Rust/Wasm/ABI files, issue #393, or issue #405. If the existing shell wrapper cannot pass after only these corrections, stop and amend this successor rather than broadening it during implementation. + +## Gates and evidence + +1. `bash scripts/check-env-vocabulary.sh` passes and reports the incremented documented-name count. +2. `bash scripts/test-env-vocabulary.sh` passes its existing undocumented-name, unused-row and deleted-row red cases unchanged. +3. On Node 22.23.2, `node scripts/test-web-audioworklet.mjs` passes once and records the deterministic `0`-miss and injected `1`-miss windows. One invocation is evidence; no retry loop or repeated-until-green run. +4. `bash scripts/test-web-audioworklet.sh` passes, including the existing transformed safe-integer host and unchecked-increment red mutation. The selector remains private to the harness. +5. Run the proportional qualification lint/test route that failed in run `33935625430`, then `git diff --check` and an exact-path audit. The diff must contain zero host/product/worklet/generated changes. + +The issue evidence must say plainly that the real-clock zero-miss test was scheduler-sensitive and that the deterministic one-miss case preserves the behavioral assertion. It must not claim a Node 22 engine bug or new host qualification. + +## Delivery and review + +Root creates and synchronizes this separately numbered tooling issue before implementation. Luna implements one coherent correction checkpoint; a dedicated Astra review verifies the exact diff, runs the zero/one-miss discriminator and the unchanged safe-integer mutation, and confirms no product file changed. Because this successor is independent from closed #393, its attempt count starts at one. Merge PR #398 only after the required qualification context is green and the successor evidence is upstream; do not reopen or amend #393 as a disguised fourth attempt. + +## Decision record + +- 2026-09-05: Root approved this independent tooling correction in isolated /private/tmp/miso-dx-ci, branch codex/dx-ci-fixtures. It can run alongside SDK #405 without shared edits or broad workspace gates. Luna implements; dedicated Astra reviews. No product scope is added. Root checkpoints each coherent tranche before more edits. +- 2026-09-05 attempt 1 evidence: `docs/ENGINE_ENV_VOCABULARY.md` now documents the existing private `MISO_ENGINE_WEB_HOST_MAX_SAFE_TEST` selector, and `scripts/test-web-audioworklet.mjs` runs two fresh 128-block telemetry windows through `process()`: a fixed positive 0-miss window and a same-duration window with exactly one injected over-budget block. The local clock fixture proves 256 reads (two per leased block) and no reads or messages after release, while restoring the original `globalThis.performance` descriptor in `finally`. +- Focused gates pass on Node `v22.23.2`: `/private/tmp/node-v22.23.2-darwin-arm64/bin/node scripts/test-web-audioworklet.mjs`; `bash scripts/check-env-vocabulary.sh` reports 99 documented names; `PATH=/opt/homebrew/opt/gnu-sed/libexec/gnubin:$PATH bash scripts/test-env-vocabulary.sh` passes unchanged mutation coverage; and the full `PATH=/opt/homebrew/opt/gnu-sed/libexec/gnubin:$PATH bash scripts/test-web-audioworklet.sh` passes, including the safe-integer boundary and unchecked-increment red mutation. +- The prior real-clock zero-miss assertion was scheduler-sensitive; the deterministic one-miss case keeps the behavioral `deadlineMisses === 1` discriminator. This evidence does not claim a Node 22 engine bug or new host qualification. `git diff --check` passes and the exact diff contains only the issue spec, environment vocabulary, and test harness paths; no host/product/worklet/generated file changed. + +## Dedicated Astra attempt 1 verdict — PASS (2026-09-05) + +Astra independently reviewed `04bbf4e5` and verified exact three-path scope, focused Node22.23.2 suite, 99-name vocabulary and unchanged mutation suite, and full unchanged browser wrapper. Force-zero, count-every-block, and read-after-release mutations each fail. Original performance property descriptors restore on normal completion and callback exceptions. No product source changed. The full review is attached to PR #398. This tooling result fixes the demonstrated CI causes; combined PR qualification still requires its own green run and separate PCM review. diff --git a/docs/ENGINE_ENV_VOCABULARY.md b/docs/ENGINE_ENV_VOCABULARY.md index ebfdba4a9..e99c709cc 100644 --- a/docs/ENGINE_ENV_VOCABULARY.md +++ b/docs/ENGINE_ENV_VOCABULARY.md @@ -161,6 +161,7 @@ Read by one subject each. | `MISO_ENGINE_WEB_STRIP` | AudioWorklet build: the `wasm-strip` binary. | | `MISO_ENGINE_WEB_WORKLET_TEST_MODULE` | Hermetic worklet test: override module path for the bootstrap-under-test (#132). | | `MISO_ENGINE_WEB_HOST_TEST_MODULE` | Hermetic worklet test: override module path for the main-realm host under test, so a red mutation of the host runs the same suite (#151). | +| `MISO_ENGINE_WEB_HOST_MAX_SAFE_TEST` | Hermetic host allocator test selector; when `1`, the existing test runs the transformed private counter at `MAX_SAFE_INTEGER - 1`, proves the final safe ID once, then proves repeatable local exhaustion with no post, wrap or reuse. | | `MISO_ENGINE_PRINT_HELPER_MANIFEST` | native PCM runner portability gate: helper manifest path. | | `MISO_ENGINE_EFFECT_CONTRACT_V1_H` | the C include guard `check-effect-contract.sh` asserts. Not an environment variable. | | `MISO_ENGINE_BENCH_POLICY_NEEDLE` | `check-bench-policy.sh`'s `sole_owner_or_delegate`: internal transport of the four-character backslash char-literal needle from bash to the `awk` subprocess through `ENVIRON`, chosen over `-v` because `-v` assignments go through awk's own C-style escape processing a second time. Set and read only inside that one function invocation; not user-facing. | diff --git a/hosts/host-web/qualification/qualification.js b/hosts/host-web/qualification/qualification.js index 8bdbcf3c6..40f65b0da 100644 --- a/hosts/host-web/qualification/qualification.js +++ b/hosts/host-web/qualification/qualification.js @@ -132,9 +132,8 @@ function corpusPlanes(description) { return [left, right]; } -function corpusRequest(requestId, description) { +function corpusRequest(description) { return { - requestId, sourceId: "fixture-source", generation: 1n, startFrame: BigInt(description.startFrame), @@ -158,7 +157,7 @@ async function renderCorpusSegment(createHost, sessionDocument, descriptions) { if (host.backend !== "simd128") throw new Error("corpus worklet backend mismatch"); host.node.connect(context.destination); for (const [index, description] of descriptions.entries()) { - const acknowledgement = await host.submitSource(corpusRequest(index + 1, description)); + const acknowledgement = await host.submitSource(corpusRequest(description)); if (acknowledgement.result !== 0) throw new Error("corpus prefill rejected"); } const rendered = await context.startRendering(); @@ -296,7 +295,6 @@ async function runConsoleQualification(createHost, sessionDocument) { inputPeak = Math.max(inputPeak, Math.abs(planes[0][frame]), Math.abs(planes[1][frame])); } const acknowledgement = await host.submitSource({ - requestId: block + 1, sourceId: "console-source", generation: 1n, startFrame: BigInt(block * QUANTUM_FRAMES), @@ -312,18 +310,15 @@ async function runConsoleQualification(createHost, sessionDocument) { // taken after the prefill they will be observed over. const map = await host.sessionMap(); const meterLease = await host.meters({ - requestId: 10001, enabled: true, onFrame: (frame) => meterFrames.push(frame), }); const telemetryLease = await host.telemetry({ - requestId: 10002, enabled: true, onFrame: (frame) => telemetryFrames.push(frame), }); const command = await host.command({ - requestId: 20001, commands: [{ kind: COMMAND_MATRIX, rack: 255, @@ -405,7 +400,6 @@ async function runObservationRun(createHost, sessionDocument, armed) { const frames = []; for (let block = 0; block < OBSERVATION_BLOCKS; block += 1) { const acknowledgement = await host.submitSource({ - requestId: block + 1, sourceId: "console-source", generation: 1n, startFrame: BigInt(block * QUANTUM_FRAMES), @@ -418,7 +412,6 @@ async function runObservationRun(createHost, sessionDocument, armed) { } const meterLease = await host.meters({ - requestId: 30001, enabled: true, onFrame: (frame) => frames.push({ trackGrDb: Array.from(frame.trackGrDb), @@ -436,11 +429,10 @@ async function runObservationRun(createHost, sessionDocument, armed) { windowBlocks: Number(CONSOLE_METER_BLOCKS), armed: true, }; - const subscribed = await host.observe({ requestId: 30002, subscriptions: [subscription] }); + const subscribed = await host.observe({ subscriptions: [subscription] }); let unsubscribed = null; if (!armed) { unsubscribed = await host.observe({ - requestId: 30003, subscriptions: [{ ...subscription, armed: false }], }); } @@ -514,7 +506,6 @@ async function runStallQualification(createHost, sessionDocument) { expected[0].set(planes[0], block * QUANTUM_FRAMES); expected[1].set(planes[1], block * QUANTUM_FRAMES); const acknowledgement = await host.submitSource({ - requestId: block + 1, sourceId: "stall-source", generation: 1n, startFrame: BigInt(block * QUANTUM_FRAMES), @@ -527,7 +518,6 @@ async function runStallQualification(createHost, sessionDocument) { } const meterLease = await host.meters({ - requestId: 30001, enabled: true, onFrame: (frame) => stallMeterFrames.push(frame), }); @@ -536,7 +526,6 @@ async function runStallQualification(createHost, sessionDocument) { // digest still applies, while the control path, its queue and the meter fold are all live // across the stall. const stallCommand = await host.command({ - requestId: 30002, commands: [{ kind: COMMAND_MATRIX, rack: 255, diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts index 9d0461504..79d32c558 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts @@ -323,7 +323,6 @@ export interface MisoCommand { } export interface MisoCommandRequest { - requestId: number; commands: MisoCommand[]; } @@ -441,7 +440,6 @@ export interface MisoObservationSubscription { /// One observation batch. Like a command batch, it is one transaction (issues 143, 151). export interface MisoObservationRequest { - requestId: number; /// At least one and at most `256` subscriptions, arming and disarming freely mixed. subscriptions: MisoObservationSubscription[]; } @@ -595,7 +593,6 @@ export interface MisoError { } export interface MisoSourceRequest { - requestId: number; sourceId: string; generation: bigint; startFrame: bigint; @@ -606,7 +603,6 @@ export interface MisoSourceRequest { } export interface MisoSeekRequest { - requestId: number; sourceId: string; generation: bigint; sourceFrame: bigint; @@ -636,11 +632,11 @@ export interface MisoAudioWorkletHost { sessionMap(): Promise; /// Take or release the decimated meter lease (issue 137 D2). meters( - request: { requestId: number; enabled: boolean; onFrame: ((frame: MisoMeterFrame) => void) | null }, + request: { enabled: boolean; onFrame: ((frame: MisoMeterFrame) => void) | null }, ): Promise; /// Take or release the render-telemetry lease (issue 137 D3). telemetry( - request: { requestId: number; enabled: boolean; onFrame: ((frame: MisoTelemetryFrame) => void) | null }, + request: { enabled: boolean; onFrame: ((frame: MisoTelemetryFrame) => void) | null }, ): Promise; dispose(): Promise; } diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js index 92388aeff..c5849ec80 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js @@ -36,7 +36,6 @@ const BOOT_OPTION_FIELDS = [ ]; const SOURCE_FIELDS = [ - "requestId", "sourceId", "generation", "startFrame", @@ -46,7 +45,7 @@ const SOURCE_FIELDS = [ "endOfRegion", ]; -const SEEK_FIELDS = ["requestId", "sourceId", "generation", "sourceFrame"]; +const SEEK_FIELDS = ["sourceId", "generation", "sourceFrame"]; // Issue #137 D1: the frozen 48-byte little-endian command record. const COMMAND_RECORD_BYTES = 48; const MAXIMUM_COMMAND_RECORDS = 256; @@ -367,6 +366,12 @@ class MisoAudioWorkletHost { return 0; } + #allocateRequestId() { + if (this.#lastRequestId >= Number.MAX_SAFE_INTEGER) return null; + this.#lastRequestId += 1; + return this.#lastRequestId; + } + #release(pending) { this.#pending.delete(pending.requestId); if (pending.response === "source") { @@ -574,33 +579,34 @@ class MisoAudioWorkletHost { expectedPlanes = undefined, sourceId = undefined, ) { - if (this.#disposed) return Promise.reject(webError(3, message.requestId)); + if (this.#disposed) return Promise.reject(webError(3)); if (this.#stickyError !== null && !allowSticky) return Promise.reject(this.#stickyError); if (this.#saturated(response, sourceId)) { - return Promise.reject(webError(RESULT_BACKPRESSURE, message.requestId)); + return Promise.reject(webError(RESULT_BACKPRESSURE)); } - if (!validRequestId(message.requestId) || message.requestId <= this.#lastRequestId) { - return Promise.reject(webError(1, message.requestId)); + const requestId = this.#allocateRequestId(); + if (requestId === null) { + return Promise.reject(webError(1)); } - this.#lastRequestId = message.requestId; + const stamped = { ...message, requestId }; return new Promise((resolve, reject) => { const pending = { - requestId: message.requestId, + requestId, sourceId, leaseKind: sourceId, - commandCount: message.count ?? 0, + commandCount: stamped.count ?? 0, resolve, reject, response, planeShape: expectedPlanes === undefined ? undefined : planeShape(expectedPlanes), }; - this.#pending.set(pending.requestId, pending); + this.#pending.set(requestId, pending); this.#reserve(response, sourceId); try { - this.#port.postMessage(message, transfer); + this.#port.postMessage(stamped, transfer); } catch (error) { this.#release(pending); - reject(webError(255, message.requestId)); + reject(webError(255, requestId)); } }); } @@ -620,7 +626,7 @@ class MisoAudioWorkletHost { || !(plane.buffer instanceof ArrayBuffer) || (typeof SharedArrayBuffer !== "undefined" && plane.buffer instanceof SharedArrayBuffer))) { - return Promise.reject(webError(1, request?.requestId ?? 0)); + return Promise.reject(webError(1)); } const transfer = [...new Set(request.planes.map((plane) => plane.buffer))]; return this.#request( @@ -638,7 +644,7 @@ class MisoAudioWorkletHost { || typeof request.sourceId !== "string" || typeof request.generation !== "bigint" || request.generation <= 0n || typeof request.sourceFrame !== "bigint" || request.sourceFrame < 0n) { - return Promise.reject(webError(1, request?.requestId ?? 0)); + return Promise.reject(webError(1)); } return this.#request( { tag: "miso.seek.v1", ...request }, @@ -651,11 +657,7 @@ class MisoAudioWorkletHost { } status() { - return this.#request( - { tag: "miso.status.v1", requestId: this.#lastRequestId + 1 }, - [], - "status", - ); + return this.#request({ tag: "miso.status.v1" }, [], "status"); } /// Submit one live-console command batch (issue #137 D1). @@ -664,18 +666,17 @@ class MisoAudioWorkletHost { /// every record was admitted, and `appliedAtSample` is the exact absolute sample the batch takes /// effect at. A refusal names `reason` and `rejectedIndex` and admits nothing. command(request) { - if (!hasExactFields(request, ["requestId", "commands"]) + if (!hasExactFields(request, ["commands"]) || !Array.isArray(request.commands) || request.commands.length === 0 || request.commands.length > MAXIMUM_COMMAND_RECORDS || !request.commands.every(validCommand)) { - return Promise.reject(webError(1, request?.requestId ?? 0)); + return Promise.reject(webError(1)); } const records = encodeCommands(request.commands); return this.#request( { tag: "miso.command.v1", - requestId: request.requestId, count: request.commands.length, records, }, @@ -698,12 +699,12 @@ class MisoAudioWorkletHost { /// it is updated only on `result === 0`, and it is cleared whenever the plan is replaced -- /// subscriptions belong to the plan they were applied to (D7). async observe(request) { - if (!hasExactFields(request, ["requestId", "subscriptions"]) + if (!hasExactFields(request, ["subscriptions"]) || !Array.isArray(request.subscriptions) || request.subscriptions.length === 0 || request.subscriptions.length > MAXIMUM_COMMAND_RECORDS || !request.subscriptions.every(validSubscription)) { - return Promise.reject(webError(1, request?.requestId ?? 0)); + return Promise.reject(webError(1)); } const commands = request.subscriptions.map((subscription) => ({ kind: subscription.armed @@ -717,7 +718,7 @@ class MisoAudioWorkletHost { smoothingSamples: subscription.windowBlocks, values: [0, 0, 0, 0], })); - const ack = await this.command({ requestId: request.requestId, commands }); + const ack = await this.command({ commands }); if (ack.result === 0) { for (const subscription of request.subscriptions) { const key = [ @@ -759,14 +760,7 @@ class MisoAudioWorkletHost { /// This is the addressing authority for `trackIndex`: the app never guesses an index, and never /// sends a string on the command path. sessionMap() { - return this.#request( - { tag: "miso.sessionmap.v1", requestId: this.#lastRequestId + 1 }, - [], - "sessionMap", - false, - undefined, - "sessionMap", - ); + return this.#request({ tag: "miso.sessionmap.v1" }, [], "sessionMap", false, undefined, "sessionMap"); } /// Take or release the decimated meter lease (issue #137 D2). @@ -774,14 +768,14 @@ class MisoAudioWorkletHost { /// `onFrame` receives every `miso.meter.v1` frame while the lease is held. Passing /// `enabled: false` releases it and detaches the callback. meters(request) { - if (!hasExactFields(request, ["requestId", "enabled", "onFrame"]) + if (!hasExactFields(request, ["enabled", "onFrame"]) || typeof request.enabled !== "boolean" || (request.onFrame !== null && typeof request.onFrame !== "function")) { - return Promise.reject(webError(1, request?.requestId ?? 0)); + return Promise.reject(webError(1)); } this.#onMeterFrame = request.enabled ? request.onFrame : null; return this.#request( - { tag: "miso.meters.v1", requestId: request.requestId, enabled: request.enabled }, + { tag: "miso.meters.v1", enabled: request.enabled }, [], "lease", false, @@ -792,14 +786,14 @@ class MisoAudioWorkletHost { /// Take or release the render-telemetry lease (issue #137 D3). telemetry(request) { - if (!hasExactFields(request, ["requestId", "enabled", "onFrame"]) + if (!hasExactFields(request, ["enabled", "onFrame"]) || typeof request.enabled !== "boolean" || (request.onFrame !== null && typeof request.onFrame !== "function")) { - return Promise.reject(webError(1, request?.requestId ?? 0)); + return Promise.reject(webError(1)); } this.#onTelemetryFrame = request.enabled ? request.onFrame : null; return this.#request( - { tag: "miso.telemetry.v1", requestId: request.requestId, enabled: request.enabled }, + { tag: "miso.telemetry.v1", enabled: request.enabled }, [], "lease", false, @@ -810,8 +804,7 @@ class MisoAudioWorkletHost { async dispose() { if (this.#disposed) return; - const requestId = this.#lastRequestId + 1; - await this.#request({ tag: "miso.dispose.v1", requestId }, [], "dispose", true); + await this.#request({ tag: "miso.dispose.v1" }, [], "dispose", true); this.#disposed = true; this.#observations.clear(); this.#onMeterFrame = null; diff --git a/scripts/check-command-reason-vocabulary.py b/scripts/check-command-reason-vocabulary.py index 9c061fcca..de0d7f557 100644 --- a/scripts/check-command-reason-vocabulary.py +++ b/scripts/check-command-reason-vocabulary.py @@ -220,14 +220,14 @@ def check_observe_typing(js: str, dts: str) -> None: f"declared {declared}, implemented {implemented}", ) require( - dts_interface_fields(dts, "MisoObservationRequest") == ["requestId", "subscriptions"], + dts_interface_fields(dts, "MisoObservationRequest") == ["subscriptions"], "MisoObservationRequest does not match observe()'s accepted request fields", ) body = block_after(js, "async observe(request) ", "{", "}") request_fields = js_string_array(body, "hasExactFields(request, ") require( - request_fields == ["requestId", "subscriptions"], - f"observe() no longer accepts exactly requestId/subscriptions: {request_fields}", + request_fields == ["subscriptions"], + f"observe() no longer accepts exactly subscriptions: {request_fields}", ) # The response shapes are whatever `observe()` actually builds, exactly. @@ -365,6 +365,22 @@ def apply(state: dict[pathlib.Path, str]) -> None: "reason <= 11;", ), ), + ( + "the observation declaration restores caller requestId", + mutate( + HOST_DTS, + "export interface MisoObservationRequest {\n ///", + "export interface MisoObservationRequest {\n requestId: number;\n ///", + ), + ), + ( + "the observation guard restores caller requestId", + mutate( + HOST_JS, + 'hasExactFields(request, ["subscriptions"])', + 'hasExactFields(request, ["requestId", "subscriptions"])', + ), + ), ( "the .d.ts enum drops ObservationUnbound", mutate(HOST_DTS, " ObservationUnbound = 11,\n", ""), diff --git a/scripts/test-web-audioworklet.mjs b/scripts/test-web-audioworklet.mjs index e21e86388..cbe905574 100644 --- a/scripts/test-web-audioworklet.mjs +++ b/scripts/test-web-audioworklet.mjs @@ -63,6 +63,12 @@ function errorResult(promise, result) { ); } +async function localErrorResult(promise, result) { + const error = await errorResult(promise, result); + assert.equal(error.requestId, 0, "local refusal carries no allocated ID"); + return error; +} + async function testMainRealm() { const original = { fetch: globalThis.fetch, @@ -80,6 +86,7 @@ async function testMainRealm() { let statusMutation = null; let planeMutation = null; let commandResult = 0; + let mixedSuccess = false; let commandMutation = null; class FakePort { @@ -101,7 +108,7 @@ async function testMainRealm() { response = { tag: failSource ? "miso.error.v1" : "miso.ack.v1", requestId: received.requestId, - result: failSource ? 1 : 6, + result: failSource ? 1 : (mixedSuccess ? 0 : 6), planes: received.planes, }; responseTransfer = [...new Set(received.planes.map((plane) => plane.buffer))]; @@ -211,6 +218,19 @@ async function testMainRealm() { assert.deepEqual(events.slice(0, 2), [ ["compile", "simd.wasm"], ["addModule", "processor.js"], ]); + if (process.env.MISO_ENGINE_WEB_HOST_MAX_SAFE_TEST === "1") { + const last = await host.status(); + assert.equal(last.requestId, Number.MAX_SAFE_INTEGER); + await localErrorResult(host.status(), 1); + await localErrorResult(host.status(), 1); + assert.deepEqual( + events.filter((event) => event[0] === "request").map((event) => event[2]), + [Number.MAX_SAFE_INTEGER], + "safe-integer exhaustion never posts or wraps", + ); + await host.dispose().catch(() => undefined); + return; + } const storage = new ArrayBuffer(32); const left = new Float32Array(storage, 0, 2); @@ -219,7 +239,7 @@ async function testMainRealm() { right.set([3, 4]); holdSource = true; const sourcePromise = host.submitSource({ - requestId: 1, sourceId: "source", generation: 1n, startFrame: 0n, + sourceId: "source", generation: 1n, startFrame: 0n, sampleRateHz: 48000, planes: [left, right], frames: 2, endOfRegion: false, }); assert.equal(storage.byteLength, 0, "postMessage transfers caller ownership"); @@ -241,7 +261,7 @@ async function testMainRealm() { // Request 2 was consumed by the status above, which now settles independently. const seek = await host.seekSource({ - requestId: 3, sourceId: "source", generation: 2n, sourceFrame: 10n, + sourceId: "source", generation: 2n, sourceFrame: 10n, }); assert.deepEqual(seek, { tag: "miso.ack.v1", requestId: 3, result: 0 }); const status = await host.status(); @@ -250,7 +270,7 @@ async function testMainRealm() { failSource = true; const failedStorage = new ArrayBuffer(16); const failedSource = host.submitSource({ - requestId: 5, sourceId: "source", generation: 3n, startFrame: 2n, + sourceId: "source", generation: 3n, startFrame: 2n, sampleRateHz: 48000, planes: [new Float32Array(failedStorage)], frames: 4, endOfRegion: true, }); @@ -310,7 +330,7 @@ async function testMainRealm() { }); const malformedStorage = new ArrayBuffer(16); await errorResult(planeHost.submitSource({ - requestId: 1, sourceId: "source", generation: 1n, startFrame: 0n, + sourceId: "source", generation: 1n, startFrame: 0n, sampleRateHz: 48000, planes: [new Float32Array(malformedStorage)], frames: 4, endOfRegion: false, }), 255); @@ -329,14 +349,13 @@ async function testMainRealm() { }); failSource = false; holdAll = true; - const chunk = (requestId, sourceId) => { + const chunk = (sourceId, startFrame) => { const buffer = new ArrayBuffer(8); return { request: pipelineHost.submitSource({ - requestId, sourceId, generation: 1n, - startFrame: BigInt(requestId), + startFrame: BigInt(startFrame), sampleRateHz: 48000, planes: [new Float32Array(buffer)], frames: 2, @@ -345,27 +364,31 @@ async function testMainRealm() { buffer, }; }; - const inFlight = [1, 2, 3, 4].map((requestId) => chunk(requestId, "source")); + const inFlight = [1, 2, 3, 4].map((startFrame) => chunk("source", startFrame)); for (const [index, entry] of inFlight.entries()) { assert.equal(entry.buffer.byteLength, 0, `chunk ${index} was transferred`); } - const overflow = chunk(5, "source"); - await errorResult(overflow.request, 6); + const beforeSourceOverflow = events.length; + const overflow = chunk("source", 5); + await localErrorResult(overflow.request, 6); + assert.equal(events.length, beforeSourceOverflow, "source saturation posts no refusal"); assert.equal( overflow.buffer.byteLength, 8, "a locally refused chunk keeps its planes: nothing is transferred and the caller can retry", ); // A different source has its own budget and is accepted while the first is saturated. - const other = chunk(6, "other-source"); + const other = chunk("other-source", 6); assert.equal(other.buffer.byteLength, 0, "the bound is per source, not per host"); // One unsettled seek per source: the ring carries a single command slot. const firstSeek = pipelineHost.seekSource({ - requestId: 7, sourceId: "source", generation: 2n, sourceFrame: 0n, + sourceId: "source", generation: 2n, sourceFrame: 0n, }); - await errorResult(pipelineHost.seekSource({ - requestId: 8, sourceId: "source", generation: 3n, sourceFrame: 0n, + const beforeSeekOverflow = events.length; + await localErrorResult(pipelineHost.seekSource({ + sourceId: "source", generation: 3n, sourceFrame: 0n, }), 6); + assert.equal(events.length, beforeSeekOverflow, "seek saturation posts no refusal"); holdAll = false; for (const respond of heldAll) respond(); heldAll.length = 0; @@ -376,13 +399,13 @@ async function testMainRealm() { ]); assert.deepEqual( settled.map((message) => message.requestId), - [1, 2, 3, 4, 6, 7], + [1, 2, 3, 4, 5, 6], "acknowledgements arrive in request order", ); // With the budget released the source accepts chunks again. - const again = chunk(9, "source"); + const again = chunk("source", 9); assert.equal(again.buffer.byteLength, 0); - await again.request; + assert.equal((await again.request).requestId, 7, "source saturation does not burn an ID"); await pipelineHost.dispose(); // W4-D1: a browser that cannot validate simd128 is refused with the typed record, before any @@ -436,7 +459,99 @@ async function testMainRealm() { kind: 1, rack: 255, channel: 255, trackIndex: 1, effectIndex: 0, parameterId: 0, smoothingSamples: 64, values: [-0.5, 0.5, 0, 0], }; - const commandAck = await consoleHost.command({ requestId: 200, commands: [pan] }); + // Issue #393: one real host fixture owns every request ID across interleaved request classes. + // Twenty-five rounds cover 250 successful calls, including both lease orderings around a + // command and direct source/seek traffic. No caller-side ID is supplied anywhere in this loop. + const mixedAcks = []; + const mixedSendStart = events.length; + const mixedSubscription = { + trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 1, armed: true, + }; + for (let round = 0; round < 25; round += 1) { + mixedAcks.push((await consoleHost.command({ commands: [pan] })).requestId); + mixedAcks.push((await consoleHost.observe({ subscriptions: [mixedSubscription] })).requestId); + mixedAcks.push((await consoleHost.meters({ enabled: true, onFrame: null })).requestId); + mixedAcks.push((await consoleHost.meters({ enabled: false, onFrame: null })).requestId); + mixedAcks.push((await consoleHost.telemetry({ enabled: true, onFrame: null })).requestId); + mixedAcks.push((await consoleHost.telemetry({ enabled: false, onFrame: null })).requestId); + const mixedBuffer = new ArrayBuffer(16); + const mixedLeft = new Float32Array(mixedBuffer, 0, 2); + const mixedRight = new Float32Array(mixedBuffer, 8, 2); + mixedLeft.set([round, round + 1]); + mixedRight.set([round + 2, round + 3]); + mixedSuccess = true; + const mixedSourceAck = await consoleHost.submitSource({ + sourceId: "mixed-source", generation: BigInt(round + 1), startFrame: 0n, + sampleRateHz: 48000, planes: [mixedLeft, mixedRight], frames: 2, endOfRegion: true, + }); + assert.equal(mixedSourceAck.result, 0, "mixed source admission succeeds"); + assert.equal(mixedBuffer.byteLength, 0, "successful mixed source transfers its buffer"); + assert.equal(mixedSourceAck.planes.length, 2); + assert.equal(mixedSourceAck.planes[0].byteOffset, 0); + assert.equal(mixedSourceAck.planes[1].byteOffset, 8); + assert.equal(mixedSourceAck.planes[0].buffer, mixedSourceAck.planes[1].buffer); + assert.equal(mixedSourceAck.planes[0].buffer.byteLength, 16); + const mixedSourceEvent = events.findLast((event) => event[1] === "miso.source.v1"); + assert.equal(mixedSourceEvent[2], mixedSourceAck.requestId); + assert.equal(mixedSourceEvent[3], 1, "shared mixed source storage transfers once"); + mixedAcks.push(mixedSourceAck.requestId); + mixedAcks.push((await consoleHost.seekSource({ + sourceId: "mixed-source", generation: BigInt(round + 1), sourceFrame: 0n, + })).requestId); + mixedAcks.push((await consoleHost.status()).requestId); + mixedAcks.push((await consoleHost.sessionMap()).requestId); + } + assert.equal(mixedAcks.length, 250, "mixed host regression covers 250 successful calls"); + const mixedSendIds = events.slice(mixedSendStart).map((event) => event[2]); + assert.deepEqual(mixedAcks, mixedSendIds, "acknowledgements match actual outbound send order"); + assert.equal(mixedAcks[0], 2, "sessionMap consumes the first host allocation"); + assert.equal(mixedAcks.at(-1), 251, "250 mixed calls occupy IDs 2 through 251"); + assert(mixedAcks.every((id, index) => index === 0 || id === mixedAcks[index - 1] + 1), + "mixed acknowledgements have adjacent IDs"); + assert(mixedAcks.every((_id, index) => index % 10 !== 1 || mixedAcks[index] === mixedAcks[index - 1] + 1), + "observe delegates with exactly one allocation"); + assert.equal(new Set(mixedAcks).size, mixedAcks.length, "mixed acknowledgements are unique"); + console.log("Issue393 mixed calls: 250 result-zero, send IDs 2..251, adjacent, observe single allocation"); + // The shipped SDK consumer uses the same real host: sessionMap -> direct meter lease -> + // semantic console submit. This is the collision reproducer that the host-owned ledger fixes. + const { createBrowserConsole } = await import(new URL("./sdk/src/browser/console.ts", root)); + const browserConsole = await createBrowserConsole(consoleHost); + await consoleHost.meters({ enabled: true, onFrame: null }); + const sdkReport = await browserConsole.submit(browserConsole.edit.track("kick").faderDb(-1)); + assert.equal(sdkReport.ok, true, "SDK console submits through the real host after direct meters"); + // Every bounded response class refuses locally with requestId 0 and leaves the next accepted + // request exactly one ID later. Hold each class independently so the fake port cannot answer. + const assertBoundedNoBurn = async (label, heldCall, refusedCall, nextCall) => { + const before = events.length; + holdAll = true; + const held = heldCall(); + await localErrorResult(refusedCall(), 6); + assert.equal(events.length, before + 1, `${label}: refusal posted no message`); + holdAll = false; + for (const respond of heldAll.splice(0)) respond(); + const first = await held; + const next = await nextCall(); + assert.equal(next.requestId, first.requestId + 1, `${label}: refusal did not burn an ID`); + return next; + }; + await assertBoundedNoBurn( + "status", () => consoleHost.status(), () => consoleHost.status(), () => consoleHost.status(), + ); + await assertBoundedNoBurn( + "sessionMap", () => consoleHost.sessionMap(), () => consoleHost.sessionMap(), + () => consoleHost.sessionMap(), + ); + await assertBoundedNoBurn( + "meters", () => consoleHost.meters({ enabled: false, onFrame: null }), + () => consoleHost.meters({ enabled: true, onFrame: null }), + () => consoleHost.meters({ enabled: false, onFrame: null }), + ); + await assertBoundedNoBurn( + "telemetry", () => consoleHost.telemetry({ enabled: false, onFrame: null }), + () => consoleHost.telemetry({ enabled: true, onFrame: null }), + () => consoleHost.telemetry({ enabled: false, onFrame: null }), + ); + const commandAck = await consoleHost.command({ commands: [pan] }); assert.equal(commandAck.tag, "miso.ack.v1"); assert.equal(commandAck.result, 0); assert.equal(commandAck.admitted, 1); @@ -459,19 +574,34 @@ async function testMainRealm() { // A malformed command never reaches the port. const beforeMalformed = events.length; await errorResult( - consoleHost.command({ requestId: 201, commands: [{ ...pan, kind: 99 }] }), + consoleHost.command({ commands: [{ ...pan, kind: 99 }] }), 1, ); await errorResult( - consoleHost.command({ requestId: 202, commands: [{ ...pan, values: [0, 0, 0, NaN] }] }), + consoleHost.command({ commands: [{ ...pan, values: [0, 0, 0, NaN] }] }), 1, ); - await errorResult(consoleHost.command({ requestId: 203, commands: [] }), 1); + await errorResult(consoleHost.command({ commands: [] }), 1); + await localErrorResult(consoleHost.command({ requestId: 999, commands: [pan] }), 1); + await localErrorResult(consoleHost.observe({ requestId: 999, subscriptions: [{ + trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 1, armed: true, + }] }), 1); + const oldShapeBuffer = new ArrayBuffer(256); + await localErrorResult(consoleHost.submitSource({ + requestId: 999, sourceId: "extra", generation: 1n, startFrame: 0n, + sampleRateHz: 48000, planes: [new Float32Array(oldShapeBuffer)], frames: 64, endOfRegion: false, + }), 1); + assert.equal(oldShapeBuffer.byteLength, 256, "malformed source keeps caller ownership"); + await localErrorResult(consoleHost.seekSource({ + requestId: 999, sourceId: "extra", generation: 1n, sourceFrame: 0n, + }), 1); + await localErrorResult(consoleHost.meters({ requestId: 999, enabled: true, onFrame: null }), 1); + await localErrorResult(consoleHost.telemetry({ requestId: 999, enabled: true, onFrame: null }), 1); assert.equal(events.length, beforeMalformed, "a malformed batch costs no message"); // Engine backpressure is a resolved acknowledgement that admits nothing. commandResult = 6; - const refused = await consoleHost.command({ requestId: 204, commands: [pan] }); + const refused = await consoleHost.command({ commands: [pan] }); assert.equal(refused.result, 6); assert.equal(refused.admitted, 0); assert.equal(refused.reason, 8); @@ -480,23 +610,31 @@ async function testMainRealm() { // Local backpressure: the worklet-side queue depth is 4, so a fifth unsettled batch is // refused here, before any transfer, and the caller keeps its records. holdAll = true; - const held4 = [205, 206, 207, 208].map((requestId) => - consoleHost.command({ requestId, commands: [pan] })); - await errorResult(consoleHost.command({ requestId: 209, commands: [pan] }), 6); + const commandEventsBefore = events.length; + const held4 = Array.from({ length: 4 }, () => consoleHost.command({ commands: [pan] })); + await localErrorResult(consoleHost.command({ commands: [pan] }), 6); + await localErrorResult(consoleHost.observe({ subscriptions: [mixedSubscription] }), 6); + assert.equal(events.length, commandEventsBefore + 4, "command/observe saturation posts no refusal"); holdAll = false; for (const respond of heldAll) respond(); heldAll.length = 0; - await Promise.all(held4); + const heldCommandAcks = await Promise.all(held4); + const commandAfterBound = await consoleHost.command({ commands: [pan] }); + assert.equal( + commandAfterBound.requestId, + Math.max(...heldCommandAcks.map((ack) => ack.requestId)) + 1, + "command saturation does not burn an ID", + ); // Leases and their unsolicited frames. const meterFrames = []; const telemetryFrames = []; assert.equal( - (await consoleHost.meters({ requestId: 210, enabled: true, onFrame: (frame) => meterFrames.push(frame) })).result, + (await consoleHost.meters({ enabled: true, onFrame: (frame) => meterFrames.push(frame) })).result, 0, ); assert.equal( - (await consoleHost.telemetry({ requestId: 211, enabled: true, onFrame: (frame) => telemetryFrames.push(frame) })).result, + (await consoleHost.telemetry({ enabled: true, onFrame: (frame) => telemetryFrames.push(frame) })).result, 0, ); const node = FakeNode.latest; @@ -578,7 +716,6 @@ async function testMainRealm() { // is canonically ordered, `windowBlocks: 0` resolves to the plan default, and an unsubscribe // removes exactly one entry. const observeAck = await consoleHost.observe({ - requestId: 213, subscriptions: [ { trackIndex: 1, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 0, armed: true }, { trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 8, armed: true }, @@ -595,7 +732,6 @@ async function testMainRealm() { "`windowBlocks: 0` resolves to the plan default, and the map says which one it got"); assert.equal(Object.isFrozen(observeAck.bindings), true); const unsubscribed = await consoleHost.observe({ - requestId: 214, subscriptions: [ { trackIndex: 1, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 0, armed: false }, ], @@ -606,14 +742,13 @@ async function testMainRealm() { { trackIndex: -1 }, { rack: 3 }, { tapId: 0 }, { armed: "yes" }, { windowBlocks: -1 }, ]) { await errorResult(consoleHost.observe({ - requestId: 215, subscriptions: [{ trackIndex: 0, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 0, armed: true, ...broken, }], }), 1); } - await errorResult(consoleHost.observe({ requestId: 216, subscriptions: [] }), 1); + await errorResult(consoleHost.observe({ subscriptions: [] }), 1); // Issue #143's two reasons, and the #151 defect they exposed: a refused subscription is a // typed *per-request* rejection and costs the host nothing. @@ -629,8 +764,6 @@ async function testMainRealm() { // every assertion below fails with the sticky signature, starting with `refused.tag` because // the promise rejects with `{tag: "miso.error.v1", result: 255}` instead of settling. // `scripts/test-web-audioworklet.sh` runs exactly that mutation and requires this file red. - let refusalRequestId = 240; - const nextRequestId = () => (refusalRequestId += 10); const mapBeforeRefusal = unsubscribed.bindings; for (const { reason, result, what } of [ // The address resolves and the tap id does not. A bad address, like every other unknown, so @@ -643,7 +776,6 @@ async function testMainRealm() { commandResult = result; commandMutation = (response) => ({ ...response, reason, rejectedIndex: 0, admitted: 0 }); const refused = await consoleHost.observe({ - requestId: nextRequestId(), subscriptions: [ { trackIndex: 0, rack: 1, effectIndex: 0, tapId: 9, windowBlocks: 0, armed: true }, ], @@ -664,7 +796,7 @@ async function testMainRealm() { // have failed with `{tag: "miso.error.v1", result: 255}`. assert.equal((await consoleHost.status()).result, 0, `${what}: status still answers`); const laterCommand = await consoleHost.command({ - requestId: nextRequestId(), commands: [pan], + commands: [pan], }); assert.equal(laterCommand.result, 0, `${what}: the command path still admits a batch`); assert.equal(laterCommand.admitted, 1); @@ -691,7 +823,6 @@ async function testMainRealm() { // And a *correct* subscription still arms, so nothing about the map machinery was poisoned. const recovered = await consoleHost.observe({ - requestId: nextRequestId(), subscriptions: [ { trackIndex: 1, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 4, armed: true }, ], @@ -700,7 +831,6 @@ async function testMainRealm() { assert.equal(recovered.reason, 0); assert.deepEqual(recovered.bindings.map((binding) => binding.trackIndex), [0, 1]); const undo = await consoleHost.observe({ - requestId: nextRequestId(), subscriptions: [ { trackIndex: 1, rack: 1, effectIndex: 0, tapId: 1, windowBlocks: 4, armed: false }, ], @@ -710,7 +840,7 @@ async function testMainRealm() { // A released lease detaches the callback: a late frame is delivered nowhere. const framesAtRelease = meterFrames.length; - await consoleHost.meters({ requestId: nextRequestId(), enabled: false, onFrame: null }); + await consoleHost.meters({ enabled: false, onFrame: null }); node.port.onmessage({ data: { tag: "miso.meter.v1", sequence: 2, windows: 1, trackCount: 2, @@ -733,6 +863,21 @@ async function testMainRealm() { }); await errorResult(doomed, 255); await consoleHost.dispose(); + const disposedEvents = events.length; + await localErrorResult(consoleHost.status(), 3); + await localErrorResult(consoleHost.command({ commands: [pan] }), 3); + await localErrorResult(consoleHost.observe({ subscriptions: [mixedSubscription] }), 3); + await localErrorResult(consoleHost.sessionMap(), 3); + await localErrorResult(consoleHost.meters({ enabled: false, onFrame: null }), 3); + await localErrorResult(consoleHost.telemetry({ enabled: false, onFrame: null }), 3); + await localErrorResult(consoleHost.seekSource({ + sourceId: "disposed", generation: 1n, sourceFrame: 0n, + }), 3); + await localErrorResult(consoleHost.submitSource({ + sourceId: "disposed", generation: 1n, startFrame: 0n, sampleRateHz: 48000, + planes: [new Float32Array(2)], frames: 2, endOfRegion: true, + }), 3); + assert.equal(events.length, disposedEvents, "disposed refusals post no messages or consume IDs"); // Issue #143 D7 / #151: recompile and re-subscribe, the way the app re-arms after the plan is // replaced. @@ -757,7 +902,7 @@ async function testMainRealm() { const beforeEdit = await prepare("{\"schema_version\":0}"); const armedBefore = await beforeEdit.observe({ - requestId: 1, subscriptions: [tap(0, 0, true), tap(1, 0, true)], + subscriptions: [tap(0, 0, true), tap(1, 0, true)], }); assert.equal(armedBefore.result, 0); assert.deepEqual(armedBefore.bindings.map((binding) => binding.trackIndex), [0, 1]); @@ -769,7 +914,7 @@ async function testMainRealm() { commandResult = 1; commandMutation = (response) => ({ ...response, reason: 10, rejectedIndex: 0, admitted: 0 }); const staleRearm = await afterEdit.observe({ - requestId: 1, subscriptions: [tap(0, 0, true), tap(1, 0, true)], + subscriptions: [tap(0, 0, true), tap(1, 0, true)], }); commandMutation = null; commandResult = 0; @@ -781,7 +926,7 @@ async function testMainRealm() { // second call rather than a rebuild. assert.deepEqual((await afterEdit.sessionMap()).tracks, ["kick", "snare"]); const rearmed = await afterEdit.observe({ - requestId: 3, subscriptions: [tap(0, 1, true), tap(1, 1, true)], + subscriptions: [tap(0, 1, true), tap(1, 1, true)], }); assert.equal(rearmed.result, 0, "the replacement plan re-arms after the refusal"); assert.deepEqual(rearmed.bindings.map((binding) => binding.effectIndex), [1, 1], @@ -794,7 +939,7 @@ async function testMainRealm() { const rearmedFrames = []; assert.equal( (await afterEdit.meters({ - requestId: 4, enabled: true, onFrame: (frame) => rearmedFrames.push(frame), + enabled: true, onFrame: (frame) => rearmedFrames.push(frame), })).result, 0, ); @@ -807,7 +952,7 @@ async function testMainRealm() { }); assert.equal(rearmedFrames.length, 1, "the replacement's meter sequence restarts at 1"); assert.deepEqual([...rearmedFrames[0].trackGrDb], [1.5, 2.5]); - assert.equal((await afterEdit.command({ requestId: 5, commands: [pan] })).result, 0); + assert.equal((await afterEdit.command({ commands: [pan] })).result, 0); assert.equal((await afterEdit.status()).result, 0); await afterEdit.dispose(); } finally { @@ -971,6 +1116,39 @@ function createFakeExports(quantum, backend = 1) { return { exports, calls, trackIds, sourceRows, meterFrameFloats }; } +function createTelemetryClock(elapsedMsByBlock) { + let reads = 0; + let timeMs = 0; + return { + now() { + const block = Math.floor(reads / 2); + if (block >= elapsedMsByBlock.length) throw new Error("telemetry clock read past fixture"); + if (reads % 2 === 1) timeMs += elapsedMsByBlock[block]; + reads += 1; + return timeMs; + }, + get reads() { + return reads; + }, + }; +} + +function withTelemetryClock(clock, callback) { + const originalPerformance = Object.getOwnPropertyDescriptor(globalThis, "performance"); + Object.defineProperty(globalThis, "performance", { + configurable: true, + enumerable: originalPerformance?.enumerable ?? true, + writable: true, + value: { now: clock.now }, + }); + try { + return callback(); + } finally { + if (originalPerformance === undefined) delete globalThis.performance; + else Object.defineProperty(globalThis, "performance", originalPerformance); + } +} + async function testProcessor() { const originalProcessor = globalThis.AudioWorkletProcessor; const originalRegister = globalThis.registerProcessor; @@ -1336,33 +1514,60 @@ async function testProcessor() { { // Issue #137 D3: a full telemetry window posts exactly one frame, and the frame is honest - // about the resolution of the clock it actually found. - const { processor } = makeProcessor(); - processor.receive({ tag: "miso.telemetry.v1", requestId: 1, enabled: true }); - assert.deepEqual(processor.port.posts.at(-1).message, { - tag: "miso.ack.v1", requestId: 1, result: 0, - }); - const left = new Float32Array(64); - const right = new Float32Array(64); - let frames = 0; - for (let block = 0; block < 128; block += 1) { - assert.equal(processor.process([], [[left, right]]), true); - const last = processor.port.posts.at(-1).message; - if (last.tag === "miso.telemetry.v1") frames += 1; - } - assert.equal(frames, 1, "one frame per 128-block window and no more"); - const telemetry = processor.port.posts.at(-1).message; - assert.equal(telemetry.blocks, 128); - assert.equal(telemetry.sequence, 1); - assert.equal(telemetry.deadlineMisses, 0); - assert(telemetry.budgetMs > 1.3 && telemetry.budgetMs < 1.4, telemetry.budgetMs); - assert(telemetry.resolutionMs > 0); - assert.equal(typeof telemetry.belowResolution, "boolean"); - assert(telemetry.cpuPercent >= 0); - processor.receive({ tag: "miso.telemetry.v1", requestId: 2, enabled: false }); - const quiet = processor.port.posts.length; - for (let block = 0; block < 200; block += 1) processor.process([], [[left, right]]); - assert.equal(processor.port.posts.length, quiet, "a released lease reads no clock"); + // about the resolution of the clock it actually found. The real process clock made the + // zero-miss assertion scheduler-sensitive, so each window uses a local monotonic fixture. + const belowBudgetMs = 0.5; + const aboveBudgetMs = 2; + const noMissWindow = Array.from({ length: 128 }, () => belowBudgetMs); + const oneMissWindow = noMissWindow.map((duration, block) => block === 64 + ? aboveBudgetMs + : duration); + const runTelemetryWindow = (elapsedMsByBlock, expectedDeadlineMisses) => { + const clock = createTelemetryClock(elapsedMsByBlock); + return withTelemetryClock(clock, () => { + const { processor } = makeProcessor(); + processor.receive({ tag: "miso.telemetry.v1", requestId: 1, enabled: true }); + assert.deepEqual(processor.port.posts.at(-1).message, { + tag: "miso.ack.v1", requestId: 1, result: 0, + }); + const left = new Float32Array(64); + const right = new Float32Array(64); + let frames = 0; + let telemetry; + for (let block = 0; block < 128; block += 1) { + assert.equal(processor.process([], [[left, right]]), true); + const last = processor.port.posts.at(-1).message; + if (last.tag === "miso.telemetry.v1") { + frames += 1; + telemetry = last; + } + } + assert.equal(frames, 1, "one frame per 128-block window and no more"); + assert.equal(telemetry.blocks, 128); + assert.equal(telemetry.sequence, 1); + assert.equal(telemetry.deadlineMisses, expectedDeadlineMisses); + assert(telemetry.budgetMs > 1.3 && telemetry.budgetMs < 1.4, telemetry.budgetMs); + assert(telemetry.resolutionMs > 0); + assert.equal(typeof telemetry.belowResolution, "boolean"); + for (const field of ["cpuPercent", "peakBlockMs", "meanBlockMs"]) { + assert(Number.isFinite(telemetry[field]) && telemetry[field] >= 0, field); + } + assert.equal(clock.reads, 256, "two clock reads per leased rendered block"); + processor.receive({ tag: "miso.telemetry.v1", requestId: 2, enabled: false }); + const quiet = processor.port.posts.length; + const readsAfterRelease = clock.reads; + for (let block = 0; block < 200; block += 1) { + assert.equal(processor.process([], [[left, right]]), true); + } + assert.equal(clock.reads, readsAfterRelease, "a released lease reads no clock"); + assert.equal(processor.port.posts.length, quiet, "a released lease posts nothing"); + }); + }; + + // The first fresh processor has 128 positive sub-budget blocks; the second has exactly one + // over-budget block, preserving the behavioral deadline-miss discriminator. + runTelemetryWindow(noMissWindow, 0); + runTelemetryWindow(oneMissWindow, 1); } { diff --git a/scripts/test-web-audioworklet.sh b/scripts/test-web-audioworklet.sh index f7e1b12fb..eefb7bf76 100755 --- a/scripts/test-web-audioworklet.sh +++ b/scripts/test-web-audioworklet.sh @@ -3,7 +3,30 @@ set -euo pipefail repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) if command -v node >/dev/null; then +node "$repo_root/scripts/test-web-audioworklet.mjs" + +# Issue #393: exercise the private allocator boundary through a transformed test module. The +# production host has no counter accessor or test hook; this copy starts at MAX_SAFE_INTEGER - 1. +safe_host=$(mktemp "${TMPDIR:-/tmp}/miso-engine-host.XXXXXX") +mv "$safe_host" "$safe_host.mjs" +safe_host="$safe_host.mjs" +unchecked_host=$(mktemp "${TMPDIR:-/tmp}/miso-engine-host.XXXXXX") +mv "$unchecked_host" "$unchecked_host.mjs" +unchecked_host="$unchecked_host.mjs" +cleanup_safe() { rm -f -- "$safe_host" "$unchecked_host"; } +trap cleanup_safe EXIT +sed 's/#lastRequestId = 0;/#lastRequestId = Number.MAX_SAFE_INTEGER - 1;/' \ + "$repo_root/hosts/host-web/web/miso-engine-v1-audio-worklet-host.js" >"$safe_host" +MISO_ENGINE_WEB_HOST_TEST_MODULE="$safe_host" MISO_ENGINE_WEB_HOST_MAX_SAFE_TEST=1 \ node "$repo_root/scripts/test-web-audioworklet.mjs" +sed 's/if (this.#lastRequestId >= Number.MAX_SAFE_INTEGER) return null;//' "$safe_host" >"$unchecked_host" +if MISO_ENGINE_WEB_HOST_TEST_MODULE="$unchecked_host" MISO_ENGINE_WEB_HOST_MAX_SAFE_TEST=1 \ + node "$repo_root/scripts/test-web-audioworklet.mjs" >/dev/null 2>&1; then + echo "unchecked safe-integer allocator mutation escaped the red test" >&2 + exit 1 +fi +rm -f -- "$safe_host" "$unchecked_host" +echo "safe-integer allocator boundary and red mutation passed" elif command -v bun >/dev/null; then bun "$repo_root/scripts/test-web-audioworklet.mjs" else diff --git a/sdk/src/browser/console.ts b/sdk/src/browser/console.ts index 31a6aa82d..846e8bc43 100644 --- a/sdk/src/browser/console.ts +++ b/sdk/src/browser/console.ts @@ -37,15 +37,8 @@ export async function createBrowserConsole(host: MisoAudioWorkletHost): Promise< sources: Object.freeze(remoteMap.sources.map((source) => Object.freeze({ ...source }))), metersAttached: remoteMap.metersAttached, }); - // `sessionMap()` itself consumes the host's next request ID. Continue from the acknowledgement; - // restarting at one would be locally well-typed and rejected by the host's monotonic ledger. - let requestId = remoteMap.requestId; return new EngineConsole(map, async (edits): Promise => { - requestId += 1; - const ack = await host.command({ - requestId, - commands: edits.map(browserCommand), - }); + const ack = await host.command({ commands: edits.map(browserCommand) }); return Object.freeze({ ok: ack.result === 0, result: ack.result, diff --git a/sdk/src/browser/shipped-host.d.ts b/sdk/src/browser/shipped-host.d.ts index 9d0461504..79d32c558 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -323,7 +323,6 @@ export interface MisoCommand { } export interface MisoCommandRequest { - requestId: number; commands: MisoCommand[]; } @@ -441,7 +440,6 @@ export interface MisoObservationSubscription { /// One observation batch. Like a command batch, it is one transaction (issues 143, 151). export interface MisoObservationRequest { - requestId: number; /// At least one and at most `256` subscriptions, arming and disarming freely mixed. subscriptions: MisoObservationSubscription[]; } @@ -595,7 +593,6 @@ export interface MisoError { } export interface MisoSourceRequest { - requestId: number; sourceId: string; generation: bigint; startFrame: bigint; @@ -606,7 +603,6 @@ export interface MisoSourceRequest { } export interface MisoSeekRequest { - requestId: number; sourceId: string; generation: bigint; sourceFrame: bigint; @@ -636,11 +632,11 @@ export interface MisoAudioWorkletHost { sessionMap(): Promise; /// Take or release the decimated meter lease (issue 137 D2). meters( - request: { requestId: number; enabled: boolean; onFrame: ((frame: MisoMeterFrame) => void) | null }, + request: { enabled: boolean; onFrame: ((frame: MisoMeterFrame) => void) | null }, ): Promise; /// Take or release the render-telemetry lease (issue 137 D3). telemetry( - request: { requestId: number; enabled: boolean; onFrame: ((frame: MisoTelemetryFrame) => void) | null }, + request: { enabled: boolean; onFrame: ((frame: MisoTelemetryFrame) => void) | null }, ): Promise; dispose(): Promise; } diff --git a/sdk/test/console-evals.mjs b/sdk/test/console-evals.mjs index 545c343cb..bdb95f6bd 100644 --- a/sdk/test/console-evals.mjs +++ b/sdk/test/console-evals.mjs @@ -149,7 +149,7 @@ describe("issue 322 -- shared semantic console", () => { request = value; return { tag: "miso.ack.v1", - requestId: value.requestId, + requestId: 2, result: 0, reason: 0, rejectedIndex: 0, @@ -167,7 +167,6 @@ describe("issue 322 -- shared semantic console", () => { assert.equal(report.reasonName, "none"); assert.equal(report.appliedAtSample, 256n); assert.deepEqual(request, { - requestId: 2, commands: [{ kind: 3, rack: 255, diff --git a/sdk/test/console-types.ts b/sdk/test/console-types.ts index 702b7c7ec..502766e57 100644 --- a/sdk/test/console-types.ts +++ b/sdk/test/console-types.ts @@ -1,6 +1,65 @@ /** Issue #322 compile-time red probes for the catalog-derived live console. */ import { ConsoleEdits } from "../src/core/console.ts"; +import type { + MisoCommandAck, + MisoCommandRequest, + MisoError, + MisoAudioWorkletHost, + MisoObservationRequest, + MisoObservationAck, + MisoSessionMap, + MisoSeekRequest, + MisoSourceRequest, + MisoAck, + MisoStatus, +} from "../src/browser/shipped-host.d.ts"; + +// @ts-expect-error request IDs belong to the host, never to public request payloads +const oldBrowserRequest: MisoCommandRequest = { requestId: 1, commands: [] }; +// @ts-expect-error observation request IDs belong to the host +const oldObservationRequest: MisoObservationRequest = { requestId: 1, subscriptions: [] }; +const oldSourceRequest: MisoSourceRequest = { + // @ts-expect-error source request IDs belong to the host + requestId: 1, sourceId: "s", generation: 1n, startFrame: 0n, sampleRateHz: 48_000, + planes: [new Float32Array()], frames: 0, endOfRegion: true, +}; +// @ts-expect-error seek request IDs belong to the host +const oldSeekRequest: MisoSeekRequest = { requestId: 1, sourceId: "s", generation: 1n, sourceFrame: 0n }; +void [oldBrowserRequest, oldObservationRequest, oldSourceRequest, oldSeekRequest]; + +declare const commandAck: MisoCommandAck; +declare const acknowledgement: MisoAck; +declare const observationAck: MisoObservationAck; +declare const sessionMap: MisoSessionMap; +declare const engineError: MisoError; +declare const status: MisoStatus; +// @ts-expect-error response request IDs remain readonly +commandAck.requestId = 1; +// @ts-expect-error response request IDs remain readonly +status.requestId = 1; +declare const host: MisoAudioWorkletHost; +// @ts-expect-error meter request IDs belong to the host +host.meters({ requestId: 1, enabled: false, onFrame: null }); +// @ts-expect-error telemetry request IDs belong to the host +host.telemetry({ requestId: 1, enabled: false, onFrame: null }); +// @ts-expect-error every response request ID remains readonly +acknowledgement.requestId = 1; +// @ts-expect-error every response request ID remains readonly +observationAck.requestId = 1; +// @ts-expect-error every response request ID remains readonly +sessionMap.requestId = 1; +// @ts-expect-error every response request ID remains readonly +engineError.requestId = 1; + +type Assert = T; +type NoCallerId = "requestId" extends keyof T ? false : true; +type _CommandPayload = Assert[0]>>; +type _ObservationPayload = Assert[0]>>; +type _SourcePayload = Assert[0]>>; +type _SeekPayload = Assert[0]>>; +type _MeterPayload = Assert[0]>>; +type _TelemetryPayload = Assert[0]>>; const edits = new ConsoleEdits({ tracks: ["t"], diff --git a/sdk/test/package-tarball-smoke.mjs b/sdk/test/package-tarball-smoke.mjs index 2b8e6ee7a..0816e9f9d 100644 --- a/sdk/test/package-tarball-smoke.mjs +++ b/sdk/test/package-tarball-smoke.mjs @@ -92,11 +92,23 @@ await writeFile(consumer, ` import { CATALOG, session } from "@misofm/engine"; import { createOfflineEngine, loadBundledEngineAsset } from "@misofm/engine/headless"; import { createEngine } from "@misofm/engine/browser"; +import type { BrowserEngine } from "@misofm/engine/browser"; import { BUNDLED_ENGINE_ASSETS } from "@misofm/engine/assets"; // @ts-expect-error arbitrary-model canonical serialization is intentionally not public import { canonicalSessionJson } from "@misofm/engine"; void [CATALOG, session, createOfflineEngine, loadBundledEngineAsset, createEngine, BUNDLED_ENGINE_ASSETS, canonicalSessionJson]; +declare const browser: BrowserEngine; +const host = browser.host; +void host.command({ commands: [] }); +void host.observe({ subscriptions: [] }); +void host.submitSource({ + sourceId: "s", generation: 1n, startFrame: 0n, sampleRateHz: 48_000, + planes: [new Float32Array()], frames: 0, endOfRegion: true, +}); +void host.seekSource({ sourceId: "s", generation: 1n, sourceFrame: 0n }); +void host.meters({ enabled: false, onFrame: null }); +void host.telemetry({ enabled: false, onFrame: null }); `, "utf8"); const program = ts.createProgram([consumer], { module: ts.ModuleKind.NodeNext,