diff --git a/.github/ISSUE_SPECS/101-delegate-session-pcm-prefill-proof-to-the-sdk-runway-helper.md b/.github/ISSUE_SPECS/101-delegate-session-pcm-prefill-proof-to-the-sdk-runway-helper.md index d931ed5..242e2b2 100644 --- a/.github/ISSUE_SPECS/101-delegate-session-pcm-prefill-proof-to-the-sdk-runway-helper.md +++ b/.github/ISSUE_SPECS/101-delegate-session-pcm-prefill-proof-to-the-sdk-runway-helper.md @@ -63,3 +63,61 @@ rebrief after five failures. Root synchronizes numbered local/GitHub evidence, integrates latest main, and closes after PASS, merged delivery, required CI and the published adoption evidence; verify remote CLOSED. No new results claimed by this scope-only brief. + + +## Implementation checkpoint + +Luna XHIGH replaces the duplicated observer loop with SDK `waitForPcmRunway`, +preserving aggregate opening/seek/feed/context/refill cleanup choreography and +translating `PcmRunwayError` with reason/source ID/original cause. README and the +existing mismatch assertion are updated. Build, types, format/source policy and +six focused session/runway tests PASS; logs `/tmp/miso-796-audit/adapter101-*`. +Only temporary accepted SDK 0.2.5 node_modules installation is used; no package, +lock or provenance pins change. Implementer reports two opening-race failures +reproducing without this patch under SDK 0.2.5; raw identification is being +collected and full integration acceptance must resolve them. Focused success +is not full-suite or registry acceptance. Root checkpoints this bounded source +tranche before independent Astra MEDIUM review and latest-main integration. + +## Independent attempt 1 source review + +Fresh Astra MEDIUM independently reviewed source checkpoint `37697f2` (including +implementation `74b66c9`) against the installed SDK 0.2.5 helper contract. +Source-scope verdict: PASS. No production-code correction was needed. Opening +passes source totals/rings, frame zero and generation one; seek passes the +acknowledged target/generation after awaiting producer seek and feed +`prepareSeek`. Both omit `minimumFrames` and retain the two-second timeout. +Context transitions, serialized transport, diagnostics and terminal cleanup +remain at their existing seams. Only `PcmRunwayError` is translated; its original +object is retained as cause, mismatch retains source ID, timeout retains its +separate reason, and other SDK/abort errors retain their cause chain. + +The verifier strengthened existing mismatch/deadline tests to assert completed +pump/feed/host/context/lease cleanup and distinguish feed preparation timeout +from SDK runway timeout. Build, test compilation, lint and format checks PASS; +15 focused session tests PASS, including full-generation runway, tail/EOF, +prepare ordering, running restoration, cancellation and terminal cleanup. +Logs: `/tmp/miso-796-audit/verify-adapter101-{build,test-compile,focused,lint,format}.log`. + +This is one attempt-1 source verdict, not final package acceptance. Full +`npm run check`, packed-browser, publish-dry-run and fresh-consumer gates remain +pending the combined #95 candidate and published dependency/provenance pins. +The two SDK console-map opening-race tests belong to #95's correction +`9666188`; they must be included in the final full suite. Metadata/lock still +name SDK 0.2.4 at this review checkpoint; no published-adoption claim is made. + + +## Combined candidate acceptance — 2026-09-14 + +Fresh Astra MEDIUM independently accepts #101 in the combined adapter 0.5.7 +candidate at verification checkpoint `3746df9`: PASS. Registry SDK 0.2.5 is now +pinned exactly; fresh normal-installed tarball resolves one SDK with the archive +integrity recorded in #95. The full `npm run check` passes all 380 tests, +including the formerly failing single-console opening-map races. Corrected +packed-browser and publish-dry-run gates PASS, as do strict fresh-consumer types +and imports. No #101 production fix was needed; SDK runway errors retain their +cause/reason/source and aggregate opening/seek cleanup remains intact. Logs and +candidate archive identity are recorded in #95's independent candidate review. +This completes source/package candidate verification, not release completion: +merged required CI and actual adapter 0.5.7 publication/attestation and issue +synchronization remain required before claiming published adoption or closure. diff --git a/.github/ISSUE_SPECS/102-apply-opfs-capability-requirements-only-to-the-selected-storage-path.md b/.github/ISSUE_SPECS/102-apply-opfs-capability-requirements-only-to-the-selected-storage-path.md index 46b8c22..31471ff 100644 --- a/.github/ISSUE_SPECS/102-apply-opfs-capability-requirements-only-to-the-selected-storage-path.md +++ b/.github/ISSUE_SPECS/102-apply-opfs-capability-requirements-only-to-the-selected-storage-path.md @@ -120,3 +120,18 @@ Raw logs and the repeatable review probe are preserved outside this worktree in and `adapter102-adversarial.mjs`. The coordinator owns evidence checkpointing, merged required CI, GitHub synchronization and ordinary package release. This review does not claim merged delivery, issue completion or registry availability. + + +## Source delivery — CLOSED + +Latest main advanced to `d2f234aa4a48cd1c37cd9dbde06607f0c1228478` (PR #104, +embedded BLAKE3 SIMD and 0.5.5 release metadata) before landing. Root integrated +it without conflicts at `769de0e5a3deaed360542c3ddf2446b980f4620d`, preserving +all new hashing/build/legal assets. Full updated `npm run check` PASS +(`/tmp/miso-796-audit/adapter102-main-check.log`) and the repeated required +macOS Chromium/WebKit OPFS workflow 34792810150 PASS. PR #105 merged at +`fe96ecb31673e8b2ca4199ba506d7a15549e700d`. Independent attempt-1 PASS evidence +is upstream. Source delivery is complete; published availability is explicitly +assigned to the existing #95 ordinary release cut, which must use an unused +version after the independent 0.5.5 release. No registry availability is claimed +for this correction until that release succeeds. diff --git a/.github/ISSUE_SPECS/95-expose-the-same-sdk-engine-and-delegate-browser-session-measurements-and-control.md b/.github/ISSUE_SPECS/95-expose-the-same-sdk-engine-and-delegate-browser-session-measurements-and-control.md index 07efd55..1a3ee54 100644 --- a/.github/ISSUE_SPECS/95-expose-the-same-sdk-engine-and-delegate-browser-session-measurements-and-control.md +++ b/.github/ISSUE_SPECS/95-expose-the-same-sdk-engine-and-delegate-browser-session-measurements-and-control.md @@ -136,3 +136,85 @@ Scope amendment only. The old three-method delegation and instruction to retain adapter measurement ownership are superseded. No new runtime result is claimed. Coordination entry point: https://github.com/misofm/engine/issues/796 . Independent follow-ups https://github.com/misofm/engine/issues/797 , https://github.com/misofm/engine-web-adapter/issues/101 and https://github.com/misofm/engine-web-adapter/issues/102 do not block this issue or app misofm/app#210. + + +## First source checkpoint + +Luna XHIGH implements borrowed `session.engine`, SDK-owned console/meter/telemetry +paths, removal of adapter HostFeed, the sole legacy peak display projection with +canonical metadata/null preserved, and snapshotted spectrum/collection/limits +forwarding. Build, typecheck and focused compiled console suite 22/22 pass. +The exact accepted local SDK tarball is installed temporarily in node_modules +only; package metadata/lockfiles remain unchanged pending registry #794. +Root checkpoints the six source/test/README paths before final session/packed +proof and published dependency adoption. No independent verdict/publication +is claimed yet. Current adapter main f833303 remains included. + + +Second Luna XHIGH checkpoint closes compatibility control admission synchronously +at aggregate close start and adds captured-alias regressions. Opening map-race +tests now target the single SDK console acquisition, preserving late settlement +and original failure causes. SDK engine identity, canonical metadata/null and +mutable-option snapshot coverage are added. Compiled console/session tests +67/67, build, types, lint and whitespace checks PASS. Packed baseline passes; +the new collection proof fails before final registry pins, with the temporary +consumer reporting a zero collection-target capacity. No unsupported-product +conclusion is claimed: the runner copies locked dependencies, still 0.2.4, and +final registry 0.2.5 adoption/retest is required. Logs: +`/tmp/miso-796-audit/adapter95-focused-2.log`, `adapter95-lint.log`, +`adapter95-browser-2.log`. Latest main now includes accepted #102 and embedded +BLAKE3 SIMD; root integrates that before the release metadata/proof tranche. + + +## Independent attempt 1 candidate review — 2026-09-14 + +Fresh Astra MEDIUM independently reviewed integrated candidate `0bcc74d` and +verification checkpoint `3746df9`: **PASS for the #95 source and 0.5.7 package +candidate**, including combined #101 adoption. No production correction was +needed. The borrowed object is the existing SDK engine, the compatibility +console uses its managed owner, direct/compatibility measurements converge on +SDK leases, canonical metadata and master null survive projection, and aggregate +close preserves admission, late acquisition and source-lifetime cleanup. +Analysis preparation and all three subscription-limit families are copied before +opening awaits; the strengthened existing snapshot test proves singular and +collection forwarding and SDK refusal at original limits after caller mutation. + +The verifier corrected the existing packed scenario's evidence: it previously +recorded readiness booleans without requiring delivery. It now requires real SDK +meter/response/spectrum payloads, fresh target-labelled frames after an atomic +selection and a refused selection, stable managed job ownership, running context, +and no meter delivery after completed close. A bounded three-second wait proves +fresh frames rather than assuming a fixed delay. SDK revision advances with each +capture; stable job identity is the appropriate refusal-preservation assertion. +The fixture's 65,536-byte collection budget was correctly refused with result 5: +two targets already require that much for observer and queued dual-mono PCM alone, +before states, bindings and IDs. The two-entry fixture now uses the accepted SDK +browser fixture's finite 1 MiB budget. Production policy is unchanged; native +accounting remains `host-core/src/spectrum.rs::spectrum_capture_collection_resources`. + +Independent evidence on Node 22.23.2: + +- Full `npm run check`: 380/380 tests PASS, both reproducible hash Wasm assets, + source/decoder/types/package policy PASS (247 packaged files). +- Corrected `npm run test:browser`: PASS, Chromium 153.0.8010.12. Sparse session + delivered 11 SDK meter updates, a 16-point response, and output/track/retained + track spectrum end samples 2304, 14720, 16768. One sparse read and lease close; + fourteen assets, zero console errors or failed requests. Existing dense + cold/warm, seek, terminal cleanup and BLAKE3 fixture assertions also PASS. +- `npm run publish:dry-run`, fresh normal tarball dependency installation, + strict public-surface compilation (including absent borrowed `close`) and + imports PASS. Fresh lock resolves exactly one SDK, registry `0.2.5`, with + integrity `sha512-cNMuslg9t7NAkBrYFMl8jcd1fOy5/fN+FEeDE+WdiMj+8+DkWTkvbWvMo1UMWgC+MrnX4BKhYz2QrKmAstIWwQ==`. +- Candidate metadata/lock/workflow consistently use adapter `0.5.7`, SDK `0.2.5`, + full source `1646a6a1bd0011cc2b5480283bf498b27be460e5` and SDK archive SHA256 + `c26208470b5409ad789085d251d94bdf1117b32dcd3b5e544f8696dffffe3d4d`. + +Logs are `/tmp/miso-796-audit/verify-adapter95-*`; the passing browser record is +`verify-adapter95-final-browser-bounded.log`. Earlier fixture/refusal diagnostics +are retained separately and are not passing runs. Final candidate archive is +`/tmp/engine-web-adapter-browser-DIvKBi/misofm-engine-web-adapter-0.5.7.tgz`, SHA256 +`8a0b8ad2cc9f0973514c28b1b5d195a56485879835b5797299dc234a8e433c35`, integrity +`sha512-XXAarTN8Wh2ENIujHTCKh07a+EXYgP4hVPsW2g2KoqNHliEzmOPAXadV3BWYf4L7zy/BM8vbiJDrGLw1BTAYzQ==`. +This is candidate acceptance, not registry publication: merged required CI, +trusted publish/verify, adapter registry integrity and attestation proof, then +GitHub synchronization/closure remain coordinator-owned delivery gates. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index c060963..de08939 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -23,12 +23,12 @@ concurrency: env: PACKAGE_NAME: "@misofm/engine-web-adapter" - PACKAGE_VERSION: "0.5.6" + PACKAGE_VERSION: "0.5.7" NPM_CONFIG_REGISTRY: "https://registry.npmjs.org" jobs: publish: - name: ${{ inputs.mode }} @misofm/engine-web-adapter 0.5.6 + name: ${{ inputs.mode }} @misofm/engine-web-adapter 0.5.7 runs-on: ubuntu-24.04 steps: - name: Refuse a non-main or mismatched dispatch @@ -63,7 +63,7 @@ jobs: [[ "$(node -p "require('./package.json').version")" == "$PACKAGE_VERSION" ]] [[ "$(node -p "require('./package-lock.json').name")" == "$PACKAGE_NAME" ]] [[ "$(node -p "require('./package-lock.json').version")" == "$PACKAGE_VERSION" ]] - [[ "$(node -p "require('./package.json').dependencies['@misofm/engine']")" == "0.2.4" ]] + [[ "$(node -p "require('./package.json').dependencies['@misofm/engine']")" == "0.2.5" ]] [[ "$(node -p "require('./package.json').repository.url")" == "git+https://github.com/misofm/engine-web-adapter.git" ]] - name: Install dependencies @@ -138,7 +138,7 @@ jobs: [[ "$attempt" == 60 ]] && { echo "registry did not converge" >&2; exit 1; } sleep 5 done - [[ "$(npm view "$PACKAGE_NAME@$PACKAGE_VERSION" dependencies.@misofm/engine)" == "0.2.4" ]] + [[ "$(npm view "$PACKAGE_NAME@$PACKAGE_VERSION" dependencies.@misofm/engine)" == "0.2.5" ]] consumer=$(mktemp -d) trap 'rm -rf -- "$consumer"' EXIT cd "$consumer" diff --git a/NOTICE b/NOTICE index 2f782c2..143192f 100644 --- a/NOTICE +++ b/NOTICE @@ -1,10 +1,10 @@ @misofm/engine-web-adapter Copyright 2026 Miso -This product depends on @misofm/engine@0.2.4. The reviewed integration archive +This product depends on @misofm/engine@0.2.5. The reviewed integration archive was packed from misofm/engine commit -64018c1a97ea84224362b0c659f06756a186e3b7 with SHA256 -5a8b3e37d6b2718efe2063ae7a20b8cbbfe233db46606eb402658f86b07c2b15. +1646a6a1bd0011cc2b5480283bf498b27be460e5 with SHA256 +c26208470b5409ad789085d251d94bdf1117b32dcd3b5e544f8696dffffe3d4d. This records reviewed source/archive identity, not registry publication. The transport SHA-256 implementation and verified-store/session-gate invariants diff --git a/README.md b/README.md index 5bec005..ebe693b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # @misofm/engine-web-adapter Headless, framework-neutral browser session hosting for -`@misofm/engine@0.2.4`. Version 0.5 identifies canonical PCM with BLAKE3-256 and streams standards-compliant native FLAC +`@misofm/engine@0.2.5`. Version 0.5 identifies canonical PCM with BLAKE3-256 and streams standards-compliant native FLAC through bounded HTTP ranges and a one-stem universal `@misofm/codec@0.1.1` Wasm Worker, verifies canonical PCM into OPFS, then feeds the Engine through bounded shared-memory @@ -10,15 +10,15 @@ rings. URL, authentication, and request mapping remain caller-owned. ## Install ```sh -npm install @misofm/engine-web-adapter@0.5.1 @misofm/engine@0.2.4 +npm install @misofm/engine-web-adapter@0.5.7 @misofm/engine@0.2.5 ``` -The package is ESM-only and remains pinned to exactly Engine `0.2.4` and +The package is ESM-only and remains pinned to exactly Engine `0.2.5` and `@misofm/codec` `0.1.1`. The codec currently supports Node `>=22.23.2 <23` and Bun `>=1.4.2 <1.5`; browser consumers use the bundled public codec asset. -The integration uses the published Engine 0.2.4 archive -from commit `64018c1a97ea84224362b0c659f06756a186e3b7`, SHA256 -`5a8b3e37d6b2718efe2063ae7a20b8cbbfe233db46606eb402658f86b07c2b15`. +The integration uses the published Engine 0.2.5 archive +from commit `1646a6a1bd0011cc2b5480283bf498b27be460e5`, SHA256 +`c26208470b5409ad789085d251d94bdf1117b32dcd3b5e544f8696dffffe3d4d`. This release adds indexed full-response stem acquisition: active FLAC chunks are decoded once into verified sparse PCM, and timeline gaps are generated as zeroes when the Engine reads them. The existing native-FLAC path remains available. @@ -121,6 +121,39 @@ allocated by the raw host, so direct `session.host` calls no longer collide with console, meter, or telemetry operations. Prefer the typed adapter surfaces when possible. +## Use the SDK Engine directly + +`session.engine` is a borrowed view of the same SDK Engine that owns the +session's host, console, measurements, response queries, spectrum queries, and +managed observation subscriptions. It is object-identical to the Engine used +for playback; the adapter only retains `session.close()` as the aggregate close +operation. Do not call `session.engine.close()` or start new work after closing +the session. + +```ts +const sdk = session.engine +const stopMeters = await sdk.subscribeMeters((update) => { + for (const [trackId, meter] of update.tracks) draw(trackId, meter.peakLeft) +}) +const response = await sdk.queryTrackResponse({ + trackId: "kick", + grid: { kind: "linear", points: 256, minimumHz: 20, maximumHz: 20_000 }, +}) +const spectrum = await sdk.querySpectrum({ + target: { kind: "output", outputId: "master" }, +}) +stopMeters() +await session.close() +``` + +Pass SDK `spectrum`, `spectrumCollection`, and observation, response, or +spectrum subscription limits in the open options when those capabilities are +needed. The values are snapshotted before source preparation begins and are +forwarded to this same Engine. The older `session.meters` and +`session.telemetry` methods remain deprecated compatibility paths; their meter +updates add only the legacy `peak` field while retaining the SDK generation, +validity, loss, span, and nullable master gain-reduction fields. + ## Deployment requirements Serve over HTTPS (or localhost) with cross-origin isolation enabled: @@ -443,6 +476,8 @@ before ready. Initial opening and seek completion require a contiguous full-gene across every source's 64 shared-ring slots, or that source's exact shorter remaining tail. At 48 kHz with 128-frame quanta this is about 171 ms. Seek preparation remains suspended, including running seeks; the adapter restores running state only after every source passes that gate. +The generic ring proof is delegated to the SDK's `waitForPcmRunway`; source preparation, feed seek +preparation, refill and context lifecycle remain owned by the adapter. Pass `onError(error)` to observe a terminal playback-worker failure after opening. The adapter marks the session closed immediately, interrupts pending lifecycle calls, and completes cleanup diff --git a/package-lock.json b/package-lock.json index 16a3a44..f923898 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@misofm/engine-web-adapter", - "version": "0.5.6", + "version": "0.5.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@misofm/engine-web-adapter", - "version": "0.5.6", + "version": "0.5.7", "license": "Apache-2.0", "dependencies": { "@misofm/codec": "0.1.1", - "@misofm/engine": "0.2.4", + "@misofm/engine": "0.2.5", "effect": "4.0.0-rc.112", "hash-wasm": "4.12.0" }, @@ -39,9 +39,9 @@ } }, "node_modules/@misofm/engine": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@misofm/engine/-/engine-0.2.4.tgz", - "integrity": "sha512-vHHpWMcgF6nRCFDIK9pETdRhq7Ei40Thv8jnoERChIKluDa7r2jTJYIa/+rxDA3d93a2tmRgGChPZNL7/OFtCw==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@misofm/engine/-/engine-0.2.5.tgz", + "integrity": "sha512-cNMuslg9t7NAkBrYFMl8jcd1fOy5/fN+FEeDE+WdiMj+8+DkWTkvbWvMo1UMWgC+MrnX4BKhYz2QrKmAstIWwQ==", "license": "Apache-2.0", "bin": { "enginectl": "dist/enginectl.js" diff --git a/package.json b/package.json index ed79dc0..bf75b8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@misofm/engine-web-adapter", - "version": "0.5.6", + "version": "0.5.7", "description": "Bounded FLAC browser session adapter for @misofm/engine using @misofm/codec", "license": "Apache-2.0", "repository": { @@ -62,7 +62,7 @@ }, "dependencies": { "@misofm/codec": "0.1.1", - "@misofm/engine": "0.2.4", + "@misofm/engine": "0.2.5", "effect": "4.0.0-rc.112", "hash-wasm": "4.12.0" }, diff --git a/scripts/browser-packed.mjs b/scripts/browser-packed.mjs index e48f54c..632e9fa 100644 --- a/scripts/browser-packed.mjs +++ b/scripts/browser-packed.mjs @@ -167,7 +167,7 @@ await writeFile(join(consumer, "src", "main.ts"), indexedSparse await writeFile(join(consumer, "consumer-check.ts"), ` import { EngineWebAdapterError, openEngineWebSession, openSparseEngineWebSession } from "@misofm/engine-web-adapter"; import type { - EngineWebConsole, EngineWebSession, EngineWebSessionOptions, SparseEngineWebSessionOptions, SourceObservation, FeedDiagnostics, MeterUpdate, TelemetryUpdate, TrackMeter, + EngineWebConsole, EngineWebSession, EngineWebSessionOptions, SparseEngineWebSessionOptions, SourceObservation, FeedDiagnostics, MeterUpdate, TelemetryUpdate, TrackMeter, SessionEngine, } from "@misofm/engine-web-adapter"; import { createFlacStemResolver, PcmPumpWorkerClient } from "@misofm/engine-web-adapter/stems"; import { ADAPTER_ASSETS } from "@misofm/engine-web-adapter/assets"; @@ -189,13 +189,18 @@ const pumpFrames: number | undefined = diagnostics.allocation.pump?.windowFrames void [observedBytes, pumpFrames]; const meters: (listener: (update: MeterUpdate) => void) => Promise<() => void> = session.meters; const telemetry: (listener: (update: TelemetryUpdate) => void) => Promise<() => void> = session.telemetry; +const sdk: SessionEngine = session.engine; +const directMeters: SessionEngine["subscribeMeters"] = sdk.subscribeMeters; +const directTelemetry: SessionEngine["subscribeTelemetry"] = sdk.subscribeTelemetry; +const directResponse: SessionEngine["queryTrackResponse"] = sdk.queryTrackResponse; +const directSpectrum: SessionEngine["querySpectrum"] = sdk.querySpectrum; declare const update: MeterUpdate; const peak: TrackMeter | undefined = update.tracks.get("track-000"); declare const failure: EngineWebAdapterError; const remedy: string = failure.remedy; const transient: boolean = failure.transient; void [openEngineWebSession, openSparseEngineWebSession, createFlacStemResolver, PcmPumpWorkerClient, ADAPTER_ASSETS, packageJson, - minimal, sparseMinimal, live, meters, telemetry, peak, remedy, transient]; + minimal, sparseMinimal, live, meters, telemetry, sdk, directMeters, directTelemetry, directResponse, directSpectrum, peak, remedy, transient]; `); await writeFile(join(consumer, "tsconfig.json"), JSON.stringify({ compilerOptions: { strict: true, noEmit: true, target: "ES2022", module: "NodeNext", moduleResolution: "NodeNext", @@ -1679,17 +1684,112 @@ async function exerciseSparseSession() { }; const store = { async openSession(options: any) { return { ...lease, sources: options.sources }; } }; const engine = await openSparseEngineWebSession({ - document: sparseDocument, console: false, store, + document: sparseDocument, store, + spectrumCollection: { + entries: [ + { target: { kind: "trackPostMatrix", trackId: "sparse-track-000" }, channels: "both" }, + { target: { kind: "output", outputId: "sparse-out" }, channels: "both" }, + ], + // The budget covers both prepared capture owners and their queues, not only FFT input bytes. + maximumCaptureBytes: 1_048_576, + }, + responseSubscriptionLimits: { maximumHandles: 2, maximumJobs: 2 }, + spectrumSubscriptionLimits: { maximumHandles: 2 }, createPump: async ({ lease: receivedLease, sources, signal }) => PcmPumpWorkerClient.createSparse({ lease: receivedLease, sources, signal, windowFrames: sparseFrames, }), }); - await engine.play(); - await engine.seekFrames(200); - await engine.pause(); - await engine.close(); + const sdk = engine.engine; + const meterUpdates: any[] = []; + const stopMeters = await sdk.subscribeMeters((update: any) => meterUpdates.push(update)); + const response = await sdk.subscribeTrackResponse({ + trackId: "sparse-track-000", + grid: { kind: "linear", points: 16, minimumHz: 20, maximumHz: 20_000 }, + channels: "both", + cadenceMs: 50, + }); + const spectrum = await sdk.subscribeSpectrum({ + target: { kind: "output", outputId: "sparse-out" }, + channels: "both", + cadenceMs: 50, + }); + const waitForSpectrum = async (kind: "output" | "trackPostMatrix", afterSample: bigint) => { + const deadline = performance.now() + 3_000; + while (performance.now() < deadline) { + await spectrum.pump(); + const result = spectrum.readLatest(); + if (result?.target.kind === kind && result.endSample > afterSample) return result; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("packed spectrum did not deliver a fresh " + kind + " frame before its deadline"); + }; + let proof: any; + try { + await engine.play(); + await engine.seekFrames(200); + await new Promise((resolve) => setTimeout(resolve, 250)); + await response.pump(); + const initialSpectrum = await waitForSpectrum("output", 0n); + const responseResult = response.readLatest(); + if (meterUpdates.length === 0 || responseResult?.trackId !== "sparse-track-000" + || responseResult.frequenciesHz.length !== 16 || initialSpectrum?.target.kind !== "output" + || initialSpectrum.target.outputId !== "sparse-out" || initialSpectrum.binCount === 0) { + throw new Error("packed SDK measurements did not deliver the prepared response and output spectrum"); + } + const responseReady = true; + const spectrumReady = true; + const priorTarget = spectrum.configuration.target; + const selected = await spectrum.update({ + target: { kind: "trackPostMatrix", trackId: "sparse-track-000" }, + channels: "both", + cadenceMs: 50, + }); + if (selected.configuration.target.kind !== "trackPostMatrix" || engine.state !== "playing") { + throw new Error("managed spectrum target switch did not preserve the running session"); + } + const switchedSpectrum = await waitForSpectrum("trackPostMatrix", initialSpectrum.endSample); + if (switchedSpectrum?.target.kind !== "trackPostMatrix" || switchedSpectrum.target.trackId !== "sparse-track-000" + || switchedSpectrum.endSample <= initialSpectrum.endSample || engine.context.state !== "running") { + throw new Error("managed spectrum selection did not deliver a fresh track frame while running"); + } + const selectedJob = spectrum.job; + let refused = false; + try { + await spectrum.update({ target: { kind: "output", outputId: "missing-output" }, channels: "both", cadenceMs: 50 }); + } catch { refused = true; } + if (!refused || spectrum.configuration.target.kind !== "trackPostMatrix") { + throw new Error("refused spectrum target did not preserve the active stream"); + } + const retainedSpectrum = await waitForSpectrum("trackPostMatrix", switchedSpectrum.endSample); + if (spectrum.job !== selectedJob || retainedSpectrum?.target.kind !== "trackPostMatrix" + || retainedSpectrum.target.trackId !== "sparse-track-000" || retainedSpectrum.endSample <= switchedSpectrum.endSample + || engine.state !== "playing" || engine.context.state !== "running") { + throw new Error(JSON.stringify({ message: "refused spectrum selection did not preserve fresh delivery from the running track stream", job: String(spectrum.job), selectedJob: String(selectedJob), target: retainedSpectrum?.target, priorSample: String(switchedSpectrum.endSample), retainedSample: String(retainedSpectrum?.endSample), state: engine.state, context: engine.context.state })); + } + await engine.pause(); + proof = { + stateBeforeClose: engine.state, + reads, + leaseClosed, + meterUpdates: meterUpdates.length, + responseReady, + spectrumReady, + initialSpectrumTarget: priorTarget, + selectedSpectrumTarget: spectrum.configuration.target, + responseConfiguration: response.configuration, + spectrumSamples: [initialSpectrum.endSample, switchedSpectrum.endSample, retainedSpectrum.endSample].map(String), + }; + } finally { + await response.close().catch(() => {}); + await spectrum.close().catch(() => {}); + stopMeters(); + await engine.close(); + } + const closedMeterCount = meterUpdates.length; + await new Promise((resolve) => setTimeout(resolve, 100)); + if (meterUpdates.length !== closedMeterCount) throw new Error("SDK meters delivered after aggregate close"); if (engine.state !== "closed" || reads !== 1 || leaseClosed !== 1) throw new Error("packed sparse session lifecycle did not settle exactly once"); - return { state: engine.state, reads, leaseClosed }; + return { ...proof, state: engine.state, reads, leaseClosed }; } try { const coldIngest = createIngestDiagnostics(); diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index a46b3d4..8c01256 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -6,10 +6,10 @@ import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; const packageJson = JSON.parse(await readFile("package.json", "utf8")); -assert.equal(packageJson.version, "0.5.6"); +assert.equal(packageJson.version, "0.5.7"); assert.deepEqual(packageJson.dependencies, { "@misofm/codec": "0.1.1", - "@misofm/engine": "0.2.4", + "@misofm/engine": "0.2.5", effect: "4.0.0-rc.112", "hash-wasm": "4.12.0", }); diff --git a/src/console.ts b/src/console.ts index 5c57233..5713458 100644 --- a/src/console.ts +++ b/src/console.ts @@ -1,67 +1,97 @@ -import { resultName } from "@misofm/engine"; -import { createBrowserConsole } from "@misofm/engine/browser"; -import type { ConsoleEdits, SessionMap } from "@misofm/engine"; -import type { BrowserEngine } from "@misofm/engine/browser"; +import type { ConsoleEdits } from "@misofm/engine"; +import type { + AudioContextLike, + BrowserEngine, + MasterMeter as SdkMasterMeter, + MeterUpdate as SdkMeterUpdate, + TelemetryUpdate, + TrackMeter as SdkTrackMeter, +} from "@misofm/engine/browser"; + import { EngineWebAdapterError } from "./errors.js"; -import type { EngineWebConsole, MeterUpdate, TelemetryUpdate, TrackMeter } from "./session-types.js"; -type EngineHost = BrowserEngine["host"]; -type MeterFrame = Parameters[0]["onFrame"]>>[0]; -type TelemetryFrame = Parameters[0]["onFrame"]>>[0]; +import type { EngineWebConsole, MasterMeter, MeterUpdate, TrackMeter } from "./session-types.js"; -class HostFeed { - readonly #host: EngineHost; readonly #lease: (host: EngineHost, onFrame: ((frame: Frame) => void) | null) => Promise<{ readonly result: number }>; - readonly #project: (frame: Frame) => Update; readonly #name: "meters" | "telemetry"; readonly #listeners = new Set<(update: Update) => void>(); - #reconciling: Promise | undefined; #armed = false; #closed = false; - constructor(options: { readonly host: EngineHost; readonly name: "meters" | "telemetry"; readonly lease: (host: EngineHost, onFrame: ((frame: Frame) => void) | null) => Promise<{ readonly result: number }>; readonly project: (frame: Frame) => Update }) { - this.#host = options.host; this.#name = options.name; this.#lease = options.lease; this.#project = options.project; - } - async subscribe(listener: (update: Update) => void): Promise<() => void> { - if (typeof listener !== "function") throw new TypeError(`${this.#name} requires a listener function`); - if (this.#closed) throw new EngineWebAdapterError("session.closed", "Engine Web session is closed"); - this.#listeners.add(listener); - try { await this.#reconcile(); } catch (error) { this.#listeners.delete(listener); throw error; } - let live = true; - return () => { if (!live) return; live = false; this.#listeners.delete(listener); void this.#reconcile(); }; - } - close(): void { this.#closed = true; this.#listeners.clear(); void this.#reconcile(); } - #reconcile(): Promise { - if (this.#reconciling !== undefined) return this.#reconciling; - // A no-op must not occupy the transition slot: another listener can leave in this turn. - if ((!this.#closed && this.#listeners.size > 0) === this.#armed) return Promise.resolve(); - const run = (async () => { - for (;;) { - const wanted = !this.#closed && this.#listeners.size > 0; - if (wanted === this.#armed) return; - if (wanted) { - const ack = await this.#lease(this.#host, (frame) => { - const update = this.#project(frame); - for (const listener of [...this.#listeners]) listener(update); - }); - if (ack.result !== 0) { - throw new EngineWebAdapterError("console.lease_refused", `The Engine refused the ${this.#name} lease`, { feed: this.#name, result: ack.result, code: resultName(ack.result, "call") }); - } - this.#armed = true; - } else { - try { await this.#lease(this.#host, null); } catch { /* release failures leave the feed conservatively unarmed */ } finally { this.#armed = false; } - } - } - })(); - this.#reconciling = run; - void run.then(() => { if (this.#reconciling === run) this.#reconciling = undefined; }, () => { if (this.#reconciling === run) this.#reconciling = undefined; }); - return run; +/** + * The adapter's compatibility control view over the one SDK Engine. + * + * The SDK owns console construction, measurement leases, listener multiplexing, + * and disposal. The adapter only preserves its historical convenience methods + * and adds the legacy single-bar meter field at callback time. + */ +export interface SessionControl { + readonly console: EngineWebConsole; + meters(listener: (update: MeterUpdate) => void): Promise<() => void>; + telemetry(listener: (update: TelemetryUpdate) => void): Promise<() => void>; + close(): void; +} + +function projectTrackMeter(meter: SdkTrackMeter): TrackMeter { + return Object.freeze({ ...meter, peak: Math.max(meter.peakLeft, meter.peakRight) }); +} + +function projectMasterMeter(meter: SdkMasterMeter): MasterMeter { + return Object.freeze({ ...meter, peak: Math.max(meter.peakLeft, meter.peakRight) }); +} + +/** Add only the historical `peak` projection while retaining every SDK field. */ +function projectMeters(update: SdkMeterUpdate): MeterUpdate { + const tracks = new Map(); + for (const [trackId, meter] of update.tracks) tracks.set(trackId, projectTrackMeter(meter)); + return Object.freeze({ + ...update, + tracks: tracks as ReadonlyMap, + master: projectMasterMeter(update.master), + }); +} + +function legacyMeasurementError(feed: "meters" | "telemetry", cause: unknown): EngineWebAdapterError { + const details: Record = { feed }; + if (cause !== null && typeof cause === "object") { + const result = (cause as { readonly result?: unknown }).result; + const code = (cause as { readonly code?: unknown }).code; + if (typeof result === "number") details.result = result; + if (typeof code === "string") details.code = code; } + return new EngineWebAdapterError( + "console.lease_refused", + `The Engine refused the ${feed} lease`, + details, + cause, + ); } -function trackMeter(left: number, right: number, gainReductionDb: number): TrackMeter { return Object.freeze({ peakLeft: left, peakRight: right, peak: Math.max(left, right), gainReductionDb }); } -export interface SessionControl { readonly console: EngineWebConsole; readonly map: SessionMap; meters(listener: (update: MeterUpdate) => void): Promise<() => void>; telemetry(listener: (update: TelemetryUpdate) => void): Promise<() => void>; close(): void; } -export async function attachSessionControl(host: EngineHost): Promise { - const remote = await host.sessionMap(); - const map: SessionMap = Object.freeze({ tracks: Object.freeze([...remote.tracks]), sources: Object.freeze(remote.sources.map((source) => Object.freeze({ ...source }))), metersAttached: remote.metersAttached }); - const sdkConsole = await createBrowserConsole(host); let closed = false; - const console: EngineWebConsole = { edit: sdkConsole.edit as ConsoleEdits, submit: (...edits) => closed ? Promise.reject(new EngineWebAdapterError("session.closed", "Engine Web session is closed")) : sdkConsole.submit(...edits) }; - const meters = new HostFeed({ host, name: "meters", lease: (target, onFrame) => target.meters({ enabled: onFrame !== null, onFrame }), project: (frame) => { - const tracks = new Map(); map.tracks.forEach((id, index) => tracks.set(id, trackMeter(frame.peaks[index * 2] ?? 0, frame.peaks[index * 2 + 1] ?? 0, frame.trackGrDb[index] ?? 0))); - return Object.freeze({ sequence: BigInt(frame.sequence), windows: frame.windows, firstSample: frame.firstSample, endSample: frame.endSample, tracks: tracks as ReadonlyMap, master: trackMeter(frame.peaks[frame.trackCount * 2] ?? 0, frame.peaks[frame.trackCount * 2 + 1] ?? 0, frame.masterGrDb ?? 0) }); - } }); - const telemetry = new HostFeed({ host, name: "telemetry", lease: (target, onFrame) => target.telemetry({ enabled: onFrame !== null, onFrame }), project: (frame) => Object.freeze({ sequence: BigInt(frame.sequence), blocks: frame.blocks, cpuPercent: frame.cpuPercent, peakBlockMs: frame.peakBlockMs, meanBlockMs: frame.meanBlockMs, budgetMs: frame.budgetMs, deadlineMisses: frame.deadlineMisses, resolutionMs: frame.resolutionMs, belowResolution: frame.belowResolution }) }); - return { console, map, meters: (listener) => meters.subscribe(listener), telemetry: (listener) => telemetry.subscribe(listener), close() { closed = true; meters.close(); telemetry.close(); } }; + +/** + * Bind the compatibility control view to the existing SDK Engine. + * + * `engine.console()` is the sole console acquisition path. It retains the SDK + * managed-observation guard and all strict command reports. Measurement methods + * delegate directly to the SDK's shared leases; no adapter feed is created. + */ +export async function attachSessionControl(engine: BrowserEngine): Promise { + const sdkConsole = await engine.console(); + let closed = false; + const console: EngineWebConsole = { + edit: sdkConsole.edit as ConsoleEdits, + submit: (...edits) => closed + ? Promise.reject(new EngineWebAdapterError("session.closed", "Engine Web session is closed")) + : sdkConsole.submit(...edits), + }; + return { + console, + meters(listener) { + if (closed) return Promise.reject(new EngineWebAdapterError("session.closed", "Engine Web session is closed")); + if (typeof listener !== "function") return Promise.reject(new TypeError("meters requires a listener function")); + return engine.subscribeMeters((update) => listener(projectMeters(update))) + .catch((error) => { throw legacyMeasurementError("meters", error); }); + }, + telemetry(listener) { + if (closed) return Promise.reject(new EngineWebAdapterError("session.closed", "Engine Web session is closed")); + if (typeof listener !== "function") return Promise.reject(new TypeError("telemetry requires a listener function")); + return engine.subscribeTelemetry(listener) + .catch((error) => { throw legacyMeasurementError("telemetry", error); }); + }, + close() { + closed = true; + }, + }; } diff --git a/src/index.ts b/src/index.ts index 2645d77..8cd4232 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,11 +12,13 @@ export type { EngineWebSessionCommonOptions, EngineWebSessionOptions, SparseEngineWebSessionOptions, + SessionEngine, EngineWebSessionState, FeedDiagnostics, PumpAllocation, SourceObservation, MeterUpdate, + MasterMeter, TelemetryUpdate, TrackMeter, } from "./session-types.js"; diff --git a/src/provenance.ts b/src/provenance.ts index c6a02e3..4528aba 100644 --- a/src/provenance.ts +++ b/src/provenance.ts @@ -1,9 +1,9 @@ export const ADAPTER_PROVENANCE = Object.freeze({ engine: Object.freeze({ - package: "@misofm/engine@0.2.4", + package: "@misofm/engine@0.2.5", repository: "misofm/engine", - archiveSha256: "5a8b3e37d6b2718efe2063ae7a20b8cbbfe233db46606eb402658f86b07c2b15", - commit: "64018c1a97ea84224362b0c659f06756a186e3b7", + archiveSha256: "c26208470b5409ad789085d251d94bdf1117b32dcd3b5e544f8696dffffe3d4d", + commit: "1646a6a1bd0011cc2b5480283bf498b27be460e5", }), safeBaselines: Object.freeze({ stemStore: "bd7f330a9773ce43bb077f0e6d5c8fc30fe9e27c", diff --git a/src/session-types.ts b/src/session-types.ts index fbaa88f..ed9cd63 100644 --- a/src/session-types.ts +++ b/src/session-types.ts @@ -6,6 +6,17 @@ import type { BrowserEngine, Msb1RingCounters, PcmSourceChunk, + ObservationSubscriptionLimits, + SpectrumCollection, + SpectrumQuery, + SpectrumSubscriptionLimits, + TrackResponseSubscriptionLimits, +} from "@misofm/engine/browser"; +import type { + MasterMeter as SdkMasterMeter, + MeterUpdate as SdkMeterUpdate, + TrackMeter as SdkTrackMeter, + TelemetryUpdate as SdkTelemetryUpdate, } from "@misofm/engine/browser"; import type { AdapterAssetOverrides } from "./assets.js"; @@ -74,41 +85,26 @@ export interface EnginePump { } /** One track's decimated meter reading, already folded to what a meter draws. */ -export interface TrackMeter { - readonly peakLeft: number; - readonly peakRight: number; +export type TrackMeter = SdkTrackMeter & { /** The greater of the two lanes: what a single meter bar shows. */ readonly peak: number; - /** Non-negative gain reduction in decibels; `0` when nothing is observed. */ - readonly gainReductionDb: number; -} +}; + +/** The master meter with the legacy single-bar peak projection. */ +export type MasterMeter = SdkMasterMeter & { + /** The greater of the two lanes: what a single meter bar shows. */ + readonly peak: number; +}; /** One decimated meter window, addressed by track id rather than by ordinal. */ -export interface MeterUpdate { - readonly sequence: bigint; - /** Complete windows folded into this update; normally `1`. */ - readonly windows: number; - readonly firstSample: bigint; - readonly endSample: bigint; +export type MeterUpdate = Omit & { /** Every track in the compiled session, keyed by its id. */ readonly tracks: ReadonlyMap; - readonly master: TrackMeter; -} + readonly master: MasterMeter; +}; /** One windowed render-telemetry reading. */ -export interface TelemetryUpdate { - readonly sequence: bigint; - readonly blocks: number; - /** Render time as a percentage of the block budget over the window. */ - readonly cpuPercent: number; - readonly peakBlockMs: number; - readonly meanBlockMs: number; - readonly budgetMs: number; - readonly deadlineMisses: number; - readonly resolutionMs: number; - /** `true` when the window measured exactly zero: the clock could not see the work. */ - readonly belowResolution: boolean; -} +export type TelemetryUpdate = SdkTelemetryUpdate; /** * The session's live console. @@ -156,6 +152,16 @@ export interface EngineWebSessionCommonOptions { readonly onError?: (error: EngineWebAdapterError) => void; /** Boot policy. Explicit `console` sizes override the adapter's defaults field by field. */ readonly policy?: BrowserBootPolicy; + /** One SDK-prepared spectrum boundary forwarded to the session's Engine. */ + readonly spectrum?: SpectrumQuery; + /** Several SDK-prepared spectrum boundaries owned by one managed selection. */ + readonly spectrumCollection?: SpectrumCollection; + /** SDK bounds for resident observation subscriptions. */ + readonly observationSubscriptionLimits?: ObservationSubscriptionLimits; + /** SDK bounds for managed live track-response subscriptions. */ + readonly responseSubscriptionLimits?: TrackResponseSubscriptionLimits; + /** SDK bounds for managed spectrum subscriptions. */ + readonly spectrumSubscriptionLimits?: SpectrumSubscriptionLimits; readonly assets?: AdapterAssetOverrides; readonly capabilityScope?: WebCapabilityScope; readonly store?: StemStore; @@ -210,6 +216,11 @@ export type SparseEngineWebSessionOptions = Omit< export interface EngineWebSession { readonly shape: SessionShape; readonly context: EngineAudioContext; + /** + * The same SDK Engine that owns this session's host, controls and analyses. + * This view is borrowed: aggregate session close owns the Engine close. + */ + readonly engine: SessionEngine; /** * The raw Engine worklet host. Its host implementation allocates request * identifiers for every payload-only call, including calls made here. @@ -238,3 +249,6 @@ export interface EngineWebSession { /** Compile-time assertion that declarations use the Engine package's SourceSpec. */ export type EngineSourceSpec = SourceSpec; + +/** The session's SDK Engine with aggregate close retained by `EngineWebSession`. */ +export type SessionEngine = Omit, "close">; diff --git a/src/session.ts b/src/session.ts index 5318f43..2f44aa9 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,9 +1,23 @@ import { bindIngestDiagnostics, inheritFlacRegistration } from "./stems/ingest-diagnostics.js"; import { ABI_LAYOUT } from "@misofm/engine"; import { Cause, Effect, Exit, Scope, Schema } from "effect"; -import type { BrowserBootPolicy } from "@misofm/engine/browser"; +import type { + BrowserBootPolicy, + ObservationSubscriptionLimits, + SpectrumCollection, + SpectrumQuery, + SpectrumSubscriptionLimits, + TrackResponseSubscriptionLimits, +} from "@misofm/engine/browser"; import { BUNDLED_ENGINE_ASSETS } from "@misofm/engine/assets"; -import { createEngine, createDefaultHost, scratchBootOptions, MSB1_CONTROL, Msb1RingObserver } from "@misofm/engine/browser"; +import { + createEngine, + createDefaultHost, + scratchBootOptions, + Msb1RingObserver, + PcmRunwayError, + waitForPcmRunway, +} from "@misofm/engine/browser"; import type { BrowserEngine, CreateEngineOptions } from "@misofm/engine/browser"; import { ADAPTER_ASSETS } from "./assets.js"; @@ -21,6 +35,7 @@ import type { EngineWebSession, EngineWebSessionOptions, EngineWebSessionState, + SessionEngine, SparseEngineWebSessionOptions, SourceObservation, } from "./session-types.js"; @@ -142,6 +157,17 @@ interface SparseSourcePreparationInput extends SourcePreparationInput { async function openSessionCommon(options: SessionOpenOptions, storeSupplied: boolean, prepareSources: PrepareSources): Promise { assertEngineRuntimeCapabilities(options.capabilityScope); if (!storeSupplied) assertOpfsStorageCapabilities(options.capabilityScope); + // The SDK snapshots these values at its own boot boundary. This adapter has + // source preparation awaits before that boundary, so copy caller-owned + // analysis and subscription options before the first await as well. + const spectrum = options.spectrum === undefined ? undefined : snapshotSpectrumQuery(options.spectrum); + const spectrumCollection = options.spectrumCollection === undefined ? undefined : snapshotSpectrumCollection(options.spectrumCollection); + const observationSubscriptionLimits = options.observationSubscriptionLimits === undefined + ? undefined : snapshotObservationSubscriptionLimits(options.observationSubscriptionLimits); + const responseSubscriptionLimits = options.responseSubscriptionLimits === undefined + ? undefined : snapshotTrackResponseSubscriptionLimits(options.responseSubscriptionLimits); + const spectrumSubscriptionLimits = options.spectrumSubscriptionLimits === undefined + ? undefined : snapshotSpectrumSubscriptionLimits(options.spectrumSubscriptionLimits); const abort = new AbortController(); const detachAbort = forwardAbort(options.signal, abort); const cleanup: Array<() => void | Promise> = []; @@ -211,6 +237,11 @@ async function openSessionCommon(options: SessionOpenOptions, storeSupplied: boo simd128ModuleUrl: String(engineWasmUrl), workletModuleUrl: String(engineWorkletUrl), policy, + ...(spectrum === undefined ? {} : { spectrum }), + ...(spectrumCollection === undefined ? {} : { spectrumCollection }), + ...(observationSubscriptionLimits === undefined ? {} : { observationSubscriptionLimits }), + ...(responseSubscriptionLimits === undefined ? {} : { responseSubscriptionLimits }), + ...(spectrumSubscriptionLimits === undefined ? {} : { spectrumSubscriptionLimits }), ...(prepared.module === undefined ? {} : { preparedModule: prepared.module }), }; engine = options.createContext === undefined @@ -250,7 +281,7 @@ async function openSessionCommon(options: SessionOpenOptions, storeSupplied: boo // so a first console command and a first meter subscription work in either // order and neither caller nor adapter ever names an identifier. if (consoleAttached(policy)) { - control = await abortable(attachSessionControl(engine.host), abort.signal, undefined, (late) => late.close()); + control = await abortable(attachSessionControl(engine), abort.signal, undefined, (late) => late.close()); cleanup.push(() => control!.close()); } abort.signal.throwIfAborted(); @@ -285,6 +316,10 @@ async function openSessionCommon(options: SessionOpenOptions, storeSupplied: boo const session: EngineWebSession = { shape: engine.shape, context, + get engine(): SessionEngine { + assertOpen(); + return engine! as SessionEngine; + }, host: engine.host, get console(): EngineWebConsole { if (control === undefined) throw consoleNotAttached(); @@ -370,7 +405,7 @@ async function openSessionCommon(options: SessionOpenOptions, storeSupplied: boo await abortable(feed!.ready(), abort.signal); const generation = await abortable(pump!.seekFrames(target), abort.signal); await abortable(feed!.prepareSeek(), abort.signal); - await waitForSeekPrefill(orderedSources, feed!.rings, counters, target, generation, abort.signal); + await waitForSeekPrefill(orderedSources, feed!.rings, target, generation, abort.signal); assertOpen(); if (restoreRunning) { await abortable(context.resume(), abort.signal, PREFILL_TIMEOUT_MS); @@ -390,6 +425,11 @@ async function openSessionCommon(options: SessionOpenOptions, storeSupplied: boo closing = true; state = "closed"; abort.abort(new DOMException("Engine Web session closed", "AbortError")); + // Refuse every adapter-owned control alias at the aggregate close + // boundary. Cleanup below may await SDK-owned lifecycle calls, but + // a caller must not be able to submit or acquire a measurement + // lease during that window. + control?.close(); // Cleanup starts now; it never waits behind a hung lifecycle call. closePromise = reverseCleanup(cleanup); } @@ -606,6 +646,61 @@ function sparseSessionSources( }); } +function snapshotSpectrumQuery(query: SpectrumQuery): SpectrumQuery { + const target: SpectrumQuery["target"] = query.target.kind === "output" + ? Object.freeze({ kind: "output", outputId: query.target.outputId }) + : Object.freeze({ kind: query.target.kind, trackId: query.target.trackId }); + const spectrumLimits = query.spectrumLimits === undefined ? undefined : Object.freeze({ + ...(query.spectrumLimits.maximumCaptureBytes === undefined ? {} : { maximumCaptureBytes: query.spectrumLimits.maximumCaptureBytes }), + ...(query.spectrumLimits.requestDeadlineMs === undefined ? {} : { requestDeadlineMs: query.spectrumLimits.requestDeadlineMs }), + }); + return Object.freeze({ + target, + ...(query.channels === undefined ? {} : { channels: query.channels }), + ...(spectrumLimits === undefined ? {} : { spectrumLimits }), + }); +} + +function snapshotSpectrumCollection(collection: SpectrumCollection): SpectrumCollection { + const entries = collection.entries.map((entry) => Object.freeze({ + target: entry.target.kind === "output" + ? Object.freeze({ kind: "output", outputId: entry.target.outputId }) + : Object.freeze({ kind: entry.target.kind, trackId: entry.target.trackId }), + ...(entry.channels === undefined ? {} : { channels: entry.channels }), + })); + return Object.freeze({ entries: Object.freeze(entries), maximumCaptureBytes: collection.maximumCaptureBytes }); +} + +function snapshotObservationSubscriptionLimits(limits: ObservationSubscriptionLimits): ObservationSubscriptionLimits { + return Object.freeze({ + ...(limits.maximumHandles === undefined ? {} : { maximumHandles: limits.maximumHandles }), + ...(limits.maximumBindings === undefined ? {} : { maximumBindings: limits.maximumBindings }), + ...(limits.maximumSelections === undefined ? {} : { maximumSelections: limits.maximumSelections }), + ...(limits.maximumWindowBlocks === undefined ? {} : { maximumWindowBlocks: limits.maximumWindowBlocks }), + ...(limits.maximumCadenceMs === undefined ? {} : { maximumCadenceMs: limits.maximumCadenceMs }), + }); +} + +function snapshotTrackResponseSubscriptionLimits(limits: TrackResponseSubscriptionLimits): TrackResponseSubscriptionLimits { + return Object.freeze({ + ...(limits.maximumHandles === undefined ? {} : { maximumHandles: limits.maximumHandles }), + ...(limits.maximumJobs === undefined ? {} : { maximumJobs: limits.maximumJobs }), + ...(limits.maximumRetainedBytes === undefined ? {} : { maximumRetainedBytes: limits.maximumRetainedBytes }), + ...(limits.maximumCaptureAttempts === undefined ? {} : { maximumCaptureAttempts: limits.maximumCaptureAttempts }), + ...(limits.maximumDeliveredBytesPerSecond === undefined ? {} : { maximumDeliveredBytesPerSecond: limits.maximumDeliveredBytesPerSecond }), + ...(limits.maximumCadenceMs === undefined ? {} : { maximumCadenceMs: limits.maximumCadenceMs }), + }); +} + +function snapshotSpectrumSubscriptionLimits(limits: SpectrumSubscriptionLimits): SpectrumSubscriptionLimits { + return Object.freeze({ + ...(limits.maximumHandles === undefined ? {} : { maximumHandles: limits.maximumHandles }), + ...(limits.maximumRetainedBytes === undefined ? {} : { maximumRetainedBytes: limits.maximumRetainedBytes }), + ...(limits.maximumDeliveredBytesPerSecond === undefined ? {} : { maximumDeliveredBytesPerSecond: limits.maximumDeliveredBytesPerSecond }), + ...(limits.maximumCadenceMs === undefined ? {} : { maximumCadenceMs: limits.maximumCadenceMs }), + }); +} + /** * The boot policy both boots read. * @@ -884,10 +979,11 @@ function exactFrames(value: number | bigint): number { } async function waitForPrefill(sources: readonly PcmPumpSource[], signal: AbortSignal): Promise { - const observers = new Map(sources.map((source) => [source.sourceId, new Msb1RingObserver(source.ring)])); - try { - await waitForRunway(sources.map((source) => ({ id: source.sourceId, frames: BigInt(source.frames), ring: source.ring })), observers, 0n, 1n, signal, "session.open"); - } finally { for (const observer of observers.values()) observer.close(); } + await waitForSessionPcmRunway(sources.map((source) => ({ + sourceId: source.sourceId, + frames: BigInt(source.frames), + ring: source.ring, + })), 0n, 1n, signal, "session.open"); } function assertSeekContextState(context: EngineAudioContext, expected: "running" | "suspended"): void { @@ -897,45 +993,36 @@ function assertSeekContextState(context: EngineAudioContext, expected: "running" async function waitForSeekPrefill( sources: readonly DeclaredStemSource[], rings: readonly SharedArrayBuffer[], - observers: ReadonlyMap, target: bigint, generation: bigint, signal: AbortSignal, ): Promise { - await waitForRunway(sources.map((source, index) => ({ id: source.id, frames: BigInt(source.spec.frames), ring: rings[index]! })), observers, target, generation, signal, "session.seek"); + await waitForSessionPcmRunway(sources.map((source, index) => ({ + sourceId: source.id, + frames: BigInt(source.spec.frames), + ring: rings[index]!, + })), target, generation, signal, "session.seek"); } -async function waitForRunway( - sources: readonly { readonly id: string; readonly frames: bigint; readonly ring: SharedArrayBuffer }[], - observers: ReadonlyMap, +async function waitForSessionPcmRunway( + sources: readonly { readonly sourceId: string; readonly frames: bigint; readonly ring: SharedArrayBuffer }[], target: bigint, generation: bigint, signal: AbortSignal, code: "session.open" | "session.seek", ): Promise { - const pending = new Map(); - for (const source of sources) { - if (target >= source.frames) continue; - const control = new Int32Array(source.ring); - const quantum = BigInt(Atomics.load(control, MSB1_CONTROL.FRAME_CAPACITY)); - const runway = quantum * BigInt(Atomics.load(control, MSB1_CONTROL.CAPACITY)); - pending.set(source.id, { next: target, end: target + runway < source.frames ? target + runway : source.frames, total: source.frames }); - } - const deadline = performance.now() + PREFILL_TIMEOUT_MS; - for (;;) { - signal.throwIfAborted(); - for (const [id, expected] of pending) { - observers.get(id)!.pull((chunk) => { - if (chunk.generation !== generation || chunk.startFrame !== expected.next || chunk.startFrame + BigInt(chunk.frames) > expected.total) { - throw new EngineWebAdapterError(code, "PCM prefill is not contiguous at the acknowledged position", { sourceId: id }); - } - expected.next += BigInt(chunk.frames); - if (expected.next >= expected.end) pending.delete(id); - }, 32); - } - if (pending.size === 0) return; - if (performance.now() >= deadline) throw new EngineWebAdapterError(code, "Current-generation PCM runway prefill timed out"); - await new Promise((resolve) => setTimeout(resolve, 0)); + try { + await waitForPcmRunway({ sources, targetFrame: target, generation, timeoutMs: PREFILL_TIMEOUT_MS, signal }); + } catch (error) { + if (!(error instanceof PcmRunwayError)) throw error; + const details = { + reason: error.reason, + ...(error.sourceId === undefined ? {} : { sourceId: error.sourceId }), + }; + const message = error.reason === "mismatch" + ? "PCM prefill is not contiguous at the acknowledged position" + : "Current-generation PCM runway prefill timed out"; + throw new EngineWebAdapterError(code, message, details, error); } } diff --git a/tests/console.test.ts b/tests/console.test.ts index a07ea83..95e0ee5 100644 --- a/tests/console.test.ts +++ b/tests/console.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { ABI_LAYOUT, encodeLaneEdits } from "@misofm/engine"; import type { BootOptions, LaneEdit } from "@misofm/engine"; +import { createBrowserConsole } from "@misofm/engine/browser"; import type { BrowserEngine } from "@misofm/engine/browser"; import { EngineWebAdapterError, openEngineWebSession } from "../src/index.js"; @@ -12,6 +13,7 @@ import type { EngineAudioContext, EngineWebSession, EngineWebSessionCommonOptions, + MasterMeter, MeterUpdate, TrackMeter, } from "../src/session-types.js"; @@ -139,6 +141,30 @@ test("an explicit no-console session names the missing console at first access", await session.close(); }); +test("session.engine borrows the same host/context and rejects access after aggregate close", async () => { + const { session } = await open({}); + const borrowed = session.engine; + assert.equal(borrowed.host, session.host, "the SDK Engine and session expose one host"); + assert.equal(borrowed.context, session.context, "the SDK Engine and session expose one context"); + assert.equal(session.engine, borrowed, "the borrowed Engine view is stable while open"); + await session.close(); + assert.throws( + () => session.engine, + (error: unknown) => error instanceof EngineWebAdapterError && error.code === "session.closed", + ); +}); + +test("captured console and measurement aliases refuse synchronously at aggregate close", async () => { + const { session } = await open({}); + const console = session.console; + const closing = session.close(); + const closed = (error: unknown) => error instanceof EngineWebAdapterError && error.code === "session.closed"; + await assert.rejects(console.submit(console.edit.track("kick").mute(true)), closed); + await assert.rejects(session.meters(() => undefined), closed); + await assert.rejects(session.telemetry(() => undefined), closed); + await closing; +}); + test("a first console command and a first meter subscription work in either order", async () => { for (const consoleFirst of [true, false]) { const { session, host } = await open({}); @@ -174,8 +200,22 @@ test("meters arrive keyed by track id with the master fold separated", async () assert.deepEqual(rounded(update.tracks.get("kick")!), { peakLeft: 0.1, peakRight: 0.2, peak: 0.2, gainReductionDb: 1.5 }); assert.deepEqual(rounded(update.tracks.get("snare")!), { peakLeft: 0.3, peakRight: 0.4, peak: 0.4, gainReductionDb: 0 }); assert.equal(round(update.master.peak), 0.9); - assert.equal(round(update.master.gainReductionDb), 2.5); + assert.equal(round(update.master.gainReductionDb ?? 0), 2.5); assert.equal(update.sequence, 7n); + assert.equal(update.generation, 1n); + assert.equal(update.validity, 11); + assert.equal(update.lossCount, 0); + + host.meterFrame!({ + tag: "miso.meter.v1", generation: 2n, validity: 12, lossCount: 3, sequence: 8, windows: 1, trackCount: 2, + peaks: new Float32Array([0, 0, 0, 0, 0, 0]), trackGrDb: new Float32Array([0, 0]), masterGrDb: null, + firstSample: 128n, endSample: 256n, + } as unknown as MeterFrame); + const nullMaster = updates[1]!; + assert.equal(nullMaster.master.gainReductionDb, null); + assert.equal(nullMaster.generation, 2n); + assert.equal(nullMaster.validity, 12); + assert.equal(nullMaster.lossCount, 3); stop(); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -280,6 +320,84 @@ test("SDK console, leases, session map, and raw host interleave without caller I await session.close(); }); +class TestFeed { + readonly #lease: (onFrame: ((frame: Frame) => void) | null) => Promise<{ readonly result: number }>; + readonly #project: (frame: Frame) => Update; + readonly #name: string; + readonly #listeners = new Set<(update: Update) => void>(); + #reconciling: Promise | undefined; + #armed = false; + #closed = false; + + constructor(name: string, lease: (onFrame: ((frame: Frame) => void) | null) => Promise<{ readonly result: number }>, project: (frame: Frame) => Update) { + this.#name = name; + this.#lease = lease; + this.#project = project; + } + async subscribe(listener: (update: Update) => void): Promise<() => void> { + if (typeof listener !== "function") throw new TypeError(`${this.#name} requires a listener function`); + if (this.#closed) throw new EngineWebAdapterError("session.closed", "Engine Web session is closed"); + this.#listeners.add(listener); + try { await this.#reconcile(); } catch (error) { this.#listeners.delete(listener); throw error; } + let live = true; + return () => { if (!live) return; live = false; this.#listeners.delete(listener); void this.#reconcile(); }; + } + close(): void { this.#closed = true; this.#listeners.clear(); void this.#reconcile(); } + #reconcile(): Promise { + if (this.#reconciling !== undefined) return this.#reconciling; + if ((!this.#closed && this.#listeners.size > 0) === this.#armed) return Promise.resolve(); + const run = (async () => { + for (;;) { + const wanted = !this.#closed && this.#listeners.size > 0; + if (wanted === this.#armed) return; + if (wanted) { + const ack = await this.#lease((frame) => { + const update = this.#project(frame); + for (const listener of [...this.#listeners]) listener(update); + }); + if (ack.result !== 0) { + const error = Object.assign(new Error(`${this.#name} lease refused`), { result: ack.result, code: "unsupported" }); + throw error; + } + this.#armed = true; + } else { + try { await this.#lease(null); } catch { /* release failures leave the test feed unarmed */ } finally { this.#armed = false; } + } + } + })(); + this.#reconciling = run; + void run.then(() => { if (this.#reconciling === run) this.#reconciling = undefined; }, () => { if (this.#reconciling === run) this.#reconciling = undefined; }); + return run; + } +} + +class TestMeasurementFeeds { + readonly #meters: TestFeed; + readonly #telemetry: TestFeed[0]["onFrame"]>>[0], import("../src/session-types.js").TelemetryUpdate>; + constructor(readonly host: BrowserEngine["host"], readonly trackIds: readonly string[]) { + this.#meters = new TestFeed("meters", (onFrame) => host.meters({ enabled: onFrame !== null, onFrame }), (frame) => { + const tracks = new Map(); + trackIds.forEach((id, index) => tracks.set(id, { + peakLeft: frame.peaks[index * 2] ?? 0, peakRight: frame.peaks[index * 2 + 1] ?? 0, + peak: Math.max(frame.peaks[index * 2] ?? 0, frame.peaks[index * 2 + 1] ?? 0), gainReductionDb: frame.trackGrDb[index] ?? 0, + })); + return { + sequence: BigInt(frame.sequence), generation: frame.generation, validity: frame.validity, lossCount: frame.lossCount, + windows: frame.windows, firstSample: frame.firstSample, endSample: frame.endSample, + tracks: tracks as ReadonlyMap, + master: { peakLeft: frame.peaks[frame.trackCount * 2] ?? 0, peakRight: frame.peaks[frame.trackCount * 2 + 1] ?? 0, peak: Math.max(frame.peaks[frame.trackCount * 2] ?? 0, frame.peaks[frame.trackCount * 2 + 1] ?? 0), gainReductionDb: frame.masterGrDb ?? 0 }, + }; + }); + this.#telemetry = new TestFeed[0]["onFrame"]>>[0], import("../src/session-types.js").TelemetryUpdate>("telemetry", (onFrame) => host.telemetry({ enabled: onFrame !== null, onFrame }), (frame) => ({ + sequence: BigInt(frame.sequence), blocks: frame.blocks, cpuPercent: frame.cpuPercent, peakBlockMs: frame.peakBlockMs, + meanBlockMs: frame.meanBlockMs, budgetMs: frame.budgetMs, deadlineMisses: frame.deadlineMisses, resolutionMs: frame.resolutionMs, belowResolution: frame.belowResolution, + })); + } + meters(listener: (update: import("../src/session-types.js").MeterUpdate) => void) { return this.#meters.subscribe(listener); } + telemetry(listener: (update: import("../src/session-types.js").TelemetryUpdate) => void) { return this.#telemetry.subscribe(listener); } + close(): void { this.#meters.close(); this.#telemetry.close(); } +} + // Expose only the packed module's class for a controlled port; its host logic is unchanged. async function packedControl() { type Request = { tag: string; requestId: number; enabled?: boolean; count?: number; records?: Uint8Array }; @@ -299,7 +417,14 @@ async function packedControl() { readonly MisoAudioWorkletHost: new (...args: readonly unknown[]) => BrowserEngine["host"]; }; const host = new sdk.MisoAudioWorkletHost({ port, disconnect() {} }, "simd128", 48_000, 128, {}, 65_536, 8, 32, 1); - const control = await attachSessionControl(host); + const sdkConsole = await createBrowserConsole(host); + const feeds = new TestMeasurementFeeds(host, TRACKS); + const engine = { + console: () => Promise.resolve(sdkConsole), + subscribeMeters: feeds.meters.bind(feeds), + subscribeTelemetry: feeds.telemetry.bind(feeds), + } as unknown as BrowserEngine; + const control = await attachSessionControl(engine); function ack(tag: string, fields: Record = {}) { const index = pending.findIndex((request) => request.tag === tag); assert.notEqual(index, -1, `missing ${tag}`); @@ -309,7 +434,7 @@ async function packedControl() { frame({ tag: tag === "miso.status.v1" ? tag : "miso.ack.v1", requestId: request.requestId, result: 0, ...extra, ...fields }); return request; } - return { host, control, sent, pending, frame, ack }; + return { host, control, engine, close: () => { control.close(); feeds.close(); }, sent, pending, frame, ack }; } const tick = async () => { for (let index = 0; index < 8; index += 1) await Promise.resolve(); }; const meterFrame = { tag: "miso.meter.v1", generation: 1n, validity: 11, lossCount: 0, sequence: 7, windows: 3, trackCount: 2, peaks: new Float32Array([0.1, 0.2, 0.3, 0.4, 0.9, 0.8]), trackGrDb: new Float32Array([1.5, 2]), masterGrDb: 2.5, firstSample: 128n, endSample: 512n }; @@ -351,7 +476,7 @@ for (const feed of ["meters", "telemetry"] as const) { await tick(); assert.equal(settlements, 0); assert.equal(f.pending.length, 1, "resubscribe cannot overlap release"); - if (closeDuringRelease) { f.control.close(); f.control.close(); } + if (closeDuringRelease) { f.close(); f.close(); } assert.equal(f.ack(tag).enabled, false); await tick(); if (!closeDuringRelease) { @@ -366,7 +491,7 @@ for (const feed of ["meters", "telemetry"] as const) { f.frame(feed === "meters" ? meterFrame : telemetryFrame); assert.equal(laterUpdates.length, 0); } - f.control.close(); await tick(); + f.close(); await tick(); assert.equal(settlements, 1); assert.equal(f.pending.length, 0, "close never rearms"); assert.deepEqual(f.sent.map((request) => request.requestId), f.sent.map((_, index) => index + 1)); @@ -385,7 +510,7 @@ for (const feed of ["meters", "telemetry"] as const) { assert.equal(f.ack(tag).enabled, true); const stop = await recovered; stop(); assert.equal(f.ack(tag).enabled, false); - f.control.close(); await tick(); + f.close(); await tick(); }); test(`packed ${feed}: pending arm and command settle once after close`, async () => { @@ -394,7 +519,7 @@ for (const feed of ["meters", "telemetry"] as const) { const arm = f.control[feed](() => { updates += 1; }).then((stop) => { armSettlements += 1; return stop; }); const cached = f.control.console; const command = cached.submit(cached.edit.track("kick").mute(true)).then((report) => { commandSettlements += 1; return report; }); - f.control.close(); f.control.close(); + f.close(); f.close(); const closed = (error: unknown) => error instanceof EngineWebAdapterError && error.code === "session.closed"; await assert.rejects(cached.submit(cached.edit.track("kick").mute(false)), closed); await assert.rejects(f.control[feed](() => undefined), closed); @@ -427,7 +552,7 @@ test("packed host interleaves payload-only control, status, map, raw command and f.ack("miso.telemetry.v1"); f.ack("miso.meters.v1"); await tick(); assert.deepEqual(f.sent.map((request) => request.requestId), f.sent.map((_, index) => index + 1)); assert.equal(f.pending.length, 0); - f.control.close(); + f.close(); }); test("the session derives declarations and a lease id from the document alone", async () => { @@ -498,7 +623,7 @@ function round(value: number): number { return Math.round(value * 1e6) / 1e6; } -function rounded(meter: TrackMeter): Record { +function rounded(meter: TrackMeter | MasterMeter): Record { return Object.fromEntries(Object.entries(meter).map(([key, value]) => [key, round(value)])); } diff --git a/tests/foundation.test.ts b/tests/foundation.test.ts index b8b790d..67411b2 100644 --- a/tests/foundation.test.ts +++ b/tests/foundation.test.ts @@ -18,11 +18,11 @@ test("foundation is pinned to the exact public Engine release", async () => { assert.equal(ADAPTER_PROVENANCE.engine.package, `@misofm/engine@${packageJson.dependencies["@misofm/engine"]}`); assert.equal( ADAPTER_PROVENANCE.engine.commit, - "64018c1a97ea84224362b0c659f06756a186e3b7", + "1646a6a1bd0011cc2b5480283bf498b27be460e5", ); assert.equal( ADAPTER_PROVENANCE.engine.archiveSha256, - "5a8b3e37d6b2718efe2063ae7a20b8cbbfe233db46606eb402658f86b07c2b15", + "c26208470b5409ad789085d251d94bdf1117b32dcd3b5e544f8696dffffe3d4d", ); assert.equal(ADAPTER_PROVENANCE.safeBaselines.stemStore, "bd7f330a9773ce43bb077f0e6d5c8fc30fe9e27c"); }); diff --git a/tests/session.test.ts b/tests/session.test.ts index 8142482..4628ae7 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -5,7 +5,7 @@ import { BoundedStemAdmission } from "../src/stems/flac-admission.js"; import { VerifiedStemStore } from "../src/stems/store.js"; import { MemoryStemStorageBackend } from "../src/stems/storage.js"; import { createSparseStemResolver, OpfsStorageBackend, serializeSparseStemIndex, VerifiedSparsePcmStore } from "../src/stems/index.js"; -import { BrowserBootError, Msb1RingWriter, PcmFeedError } from "@misofm/engine/browser"; +import { BrowserBootError, Msb1RingWriter, PcmFeedError, PcmRunwayError } from "@misofm/engine/browser"; import { scratchBootWithWorker, prepareBrowserSessionWithWorker } from "../src/scratch.js"; import assert from "node:assert/strict"; import test from "node:test"; @@ -1018,94 +1018,130 @@ function sessionOpfsWorker(root: SessionOpfsDirectory, events: string[], mode: " return worker; } -test("session snapshots document and source declarations before deferred scratch work", async () => { - const originalSources = [{ - id: "source", - spec: { channels: 1 as const, bitDepth: 16 as const, frames: 4, content: IDENTITY }, - }]; - const document = new TextEncoder().encode(documentFor(originalSources)); - const snapshot = new Uint8Array(document); - const scratchStarted = deferred(); - const releaseScratch = deferred(); - let scratchDocument: Uint8Array | undefined; - let hostDocument: Uint8Array | undefined; - const policy = { sourceRingFrames: 16, console: { commandQueueRecords: 8, meterBlocks: 2 } }; - let scratchPolicy: unknown; - let hostPolicy: unknown; - let storeStem: unknown; - const events: string[] = []; - const context = fakeContext(events); - const host = { - node: { connect() {}, disconnect() {} }, - async sessionMap() { return { tracks: [], sources: [{ id: "source", channels: 1, frames: 4n }], metersAttached: false }; }, - async command() { return { ok: true, result: 0, code: "ok", reason: 0, reasonName: "none", rejectedIndex: 0, admitted: 0, appliedAtSample: 0n }; }, - async dispose() {}, - } as unknown as BrowserEngine["host"]; - const lease: StemSessionLease = { - leaseId: "snapshot", stems: [{ sourceId: "source", identity: IDENTITY, bytes: 8 }], - async read() { return new Blob([new Uint8Array(8)]); }, async close() {}, - }; - const opening = openEngineWebSession({ - document, - policy, - leaseId: "snapshot", - sources: originalSources as readonly DeclaredStemSource[], - resolver: { async resolve() { throw new Error("warm fixture must not resolve"); } }, - capabilityScope: capabilities(), - store: { - async open() { return this; }, - async openSession(request) { storeStem = request.stems[0]; return lease; }, - }, - scratchBoot: async (request) => { - scratchDocument = request.document; - scratchPolicy = request.options; - scratchStarted.resolve(); - await releaseScratch.promise; - return { - sampleRateHz: 48_000, quantumFrames: 4, sourceRingFrames: 16, backend: "simd128", - sources: [{ id: "source", channels: 1, frames: 4n }], tracks: [], - }; - }, - createContext: () => context, - createHost: async (request) => { - assert.equal("preparedModule" in request, false, "custom shape-only scratch remains compatible"); - hostDocument = request.document; hostPolicy = request.options; return host; - }, - createAttachNode: () => ({ - port: { postMessage(message: unknown) { - const value = message as { op: string; rings?: SharedArrayBuffer[] }; - if (value.op === "attach") for (const ring of value.rings ?? []) Atomics.store(new Int32Array(ring), MSB1_CONTROL.ATTACHED, 1); - } }, - disconnect() {}, - }), - createPump: async ({ sources }) => { - for (const source of sources) fillRing(source.ring, source.frames); - return { async seekFrames() { return 0n; }, close() {} }; - }, - createOutput: () => ({ connect() {}, disconnect() {} }) as unknown as AudioNode, - }); - await scratchStarted.promise; - document.fill(0x78); - policy.sourceRingFrames = 99; - policy.console.commandQueueRecords = 99; - policy.console.meterBlocks = 99; - const mutable = originalSources[0]! as any; - mutable.id = "mutated"; - mutable.spec.channels = 2; - mutable.spec.bitDepth = "32f"; - mutable.spec.frames = 99; - mutable.spec.content = IDENTITY_Z; - releaseScratch.resolve(); - const session = await opening; - assert.deepEqual(scratchDocument, snapshot, "scratch sees owned document snapshot A"); - assert.deepEqual(hostDocument, snapshot, "createEngine host sees the same document snapshot A"); - assert.deepEqual(scratchPolicy, { sourceRingFrames: 16, requireSampleRateHz: 0, requireQuantumFrames: 0, - console: { commandQueueRecords: 8, meterBlocks: 2 } }); - assert.deepEqual(hostPolicy, { sourceRingFrames: 16, requireSampleRateHz: 48_000, requireQuantumFrames: 4, - console: { commandQueueRecords: 8, meterBlocks: 2 } }); - assert.deepEqual(storeStem, { sourceId: "source", identity: IDENTITY, bytes: 8 }); - assert.deepEqual(session.shape.sources, [{ id: "source", channels: 1, frames: 4n }]); - await session.close(); +test("session snapshots document, preparation and limits before deferred scratch work", async () => { + for (const collection of [false, true]) { + const originalSources = [{ + id: "source", + spec: { channels: 1 as const, bitDepth: 16 as const, frames: 4, content: IDENTITY }, + }]; + const document = new TextEncoder().encode(documentFor(originalSources)); + const snapshot = new Uint8Array(document); + const scratchStarted = deferred(); + const releaseScratch = deferred(); + let scratchDocument: Uint8Array | undefined; + let hostDocument: Uint8Array | undefined; + const policy = { sourceRingFrames: 16, console: { commandQueueRecords: 8, meterBlocks: 2 } }; + const spectrum = { target: { kind: "output" as const, outputId: "snapshot-output" }, channels: "left" as const, + spectrumLimits: { maximumCaptureBytes: 128, requestDeadlineMs: 75 } }; + const spectrumCollection = { entries: [{ target: { kind: "output" as const, outputId: "snapshot-output" }, channels: "left" as const }], maximumCaptureBytes: 128 }; + const observationSubscriptionLimits = { maximumHandles: 3, maximumBindings: 4, maximumSelections: 5, + maximumWindowBlocks: 6, maximumCadenceMs: 7 }; + const responseSubscriptionLimits = { maximumHandles: 8, maximumJobs: 9, maximumRetainedBytes: 10, + maximumCaptureAttempts: 11, maximumDeliveredBytesPerSecond: 12, maximumCadenceMs: 13 }; + const spectrumSubscriptionLimits = { maximumHandles: 14, maximumRetainedBytes: 15, + maximumDeliveredBytesPerSecond: 16, maximumCadenceMs: 17 }; + let scratchPolicy: unknown; + let hostPolicy: unknown; + let storeStem: unknown; + const events: string[] = []; + const context = fakeContext(events); + const host = { + node: { connect() {}, disconnect() {} }, + async sessionMap() { return { tracks: [], sources: [{ id: "source", channels: 1, frames: 4n }], metersAttached: false }; }, + async command() { return { ok: true, result: 0, code: "ok", reason: 0, reasonName: "none", rejectedIndex: 0, admitted: 0, appliedAtSample: 0n }; }, + async dispose() {}, + } as unknown as BrowserEngine["host"]; + const lease: StemSessionLease = { + leaseId: "snapshot", stems: [{ sourceId: "source", identity: IDENTITY, bytes: 8 }], + async read() { return new Blob([new Uint8Array(8)]); }, async close() {}, + }; + const opening = openEngineWebSession({ + document, + policy, + ...(collection ? { spectrumCollection } : { spectrum }), + observationSubscriptionLimits, + responseSubscriptionLimits, + spectrumSubscriptionLimits, + leaseId: "snapshot", + sources: originalSources as readonly DeclaredStemSource[], + resolver: { async resolve() { throw new Error("warm fixture must not resolve"); } }, + capabilityScope: capabilities(), + store: { + async open() { return this; }, + async openSession(request) { storeStem = request.stems[0]; return lease; }, + }, + scratchBoot: async (request) => { + scratchDocument = request.document; + scratchPolicy = request.options; + scratchStarted.resolve(); + await releaseScratch.promise; + return { + sampleRateHz: 48_000, quantumFrames: 4, sourceRingFrames: 16, backend: "simd128", + sources: [{ id: "source", channels: 1, frames: 4n }], tracks: [], + }; + }, + createContext: () => context, + createHost: async (request) => { + assert.equal("preparedModule" in request, false, "custom shape-only scratch remains compatible"); + hostDocument = request.document; hostPolicy = request.options; return host; + }, + createAttachNode: () => ({ + port: { postMessage(message: unknown) { + const value = message as { op: string; rings?: SharedArrayBuffer[] }; + if (value.op === "attach") for (const ring of value.rings ?? []) Atomics.store(new Int32Array(ring), MSB1_CONTROL.ATTACHED, 1); + } }, + disconnect() {}, + }), + createPump: async ({ sources }) => { + for (const source of sources) fillRing(source.ring, source.frames); + return { async seekFrames() { return 0n; }, close() {} }; + }, + createOutput: () => ({ connect() {}, disconnect() {} }) as unknown as AudioNode, + }); + await scratchStarted.promise; + document.fill(0x78); + policy.sourceRingFrames = 99; + policy.console.commandQueueRecords = 99; + policy.console.meterBlocks = 99; + spectrumCollection.entries[0]!.target.outputId = "mutated-output"; + spectrumCollection.maximumCaptureBytes = 512; + spectrum.target.outputId = "mutated-output"; + spectrum.spectrumLimits.maximumCaptureBytes = 512; + observationSubscriptionLimits.maximumHandles = 99; + responseSubscriptionLimits.maximumJobs = 99; + spectrumSubscriptionLimits.maximumHandles = 99; + observationSubscriptionLimits.maximumCadenceMs = 99; + responseSubscriptionLimits.maximumCadenceMs = 99; + spectrumSubscriptionLimits.maximumCadenceMs = 99; + const mutable = originalSources[0]! as any; + mutable.id = "mutated"; + mutable.spec.channels = 2; + mutable.spec.bitDepth = "32f"; + mutable.spec.frames = 99; + mutable.spec.content = IDENTITY_Z; + releaseScratch.resolve(); + const session = await opening; + assert.deepEqual(scratchDocument, snapshot, "scratch sees owned document snapshot A"); + assert.deepEqual(hostDocument, snapshot, "createEngine host sees the same document snapshot A"); + assert.deepEqual(scratchPolicy, { sourceRingFrames: 16, requireSampleRateHz: 0, requireQuantumFrames: 0, + console: { commandQueueRecords: 8, meterBlocks: 2 } }); + assert.deepEqual(hostPolicy, { sourceRingFrames: 16, requireSampleRateHz: 48_000, requireQuantumFrames: 4, + console: { commandQueueRecords: 8, meterBlocks: 2 }, + ...(collection + ? { spectrumCollection: { entries: [{ target: { kind: "output", outputId: "snapshot-output" }, channels: "left" }], maximumCaptureBytes: 128 } } + : { spectrum: { target: { kind: "output", outputId: "snapshot-output" }, channels: "left", + spectrumLimits: { maximumCaptureBytes: 128, requestDeadlineMs: 75 } } }) }); + assert.throws(() => session.engine.subscribeObservations({ selections: [{ trackId: "track", rack: "dynamic", effectSlotId: "effect", tapId: 1, channels: "both" }], windowBlocks: 1, cadenceMs: 8 }), + /observation cadenceMs must be an integer in 1\.\.=7/); + assert.throws(() => session.engine.subscribeTrackResponse({ trackId: "track", cadenceMs: 14, + grid: { kind: "linear", points: 16, minimumHz: 20, maximumHz: 20_000 } }), + /track response cadenceMs must be an integer in 1\.\.=13/); + assert.throws(() => session.engine.subscribeSpectrum({ target: { kind: "output", outputId: "snapshot-output" }, cadenceMs: 18 }), + /spectrum cadenceMs must be an integer in 1\.\.=17/); + assert.deepEqual(storeStem, { sourceId: "source", identity: IDENTITY, bytes: 8 }); + assert.deepEqual(session.shape.sources, [{ id: "source", channels: 1, frames: 4n }]); + await session.close(); + } }); test("high-level FLAC path honors common Worker assets with nested FLAC precedence", async () => { @@ -1360,14 +1396,17 @@ test("pump failure during opening rejects open instead of duplicating a runtime }); test("pump failure interrupts either opening console map without awaiting late success or rejection", { timeout: 2000 }, async () => { - for (const pendingMap of [1, 2]) for (const lateReject of [false, true]) { + // The SDK owns the sole console acquisition and therefore performs one + // session-map request. Keep both late-settlement paths covered without + // retaining the former adapter-plus-SDK double-map assumption. + for (const lateReject of [false, true]) { const failed = deferred(); const entered = deferred(); const release = deferred(); const events: string[] = []; const reason = new Error("pump failed while attaching console"); let calls = 0; let notifications = 0; const opening = pausedSeekFixture(true, { failure: failed.promise, events, onError: () => { notifications++; }, async sessionMap() { - if (++calls === pendingMap) { + if (++calls === 1) { entered.resolve(); await release.promise; if (lateReject) throw new Error("late map rejection"); } @@ -1392,7 +1431,7 @@ test("same-turn console map completion and pump failure preserve the opening cau const opening = pausedSeekFixture(true, { failure: failed.promise, events, async sessionMap() { - if (++calls === 2) { entered.resolve(); await release.promise; } + if (++calls === 1) { entered.resolve(); await release.promise; } return { tag: "miso.sessionmap.v1", requestId: calls, result: 0, tracks: [], sources: [{ id: "source", channels: 1, frames: 512n }], metersAttached: false }; }, }); @@ -1526,23 +1565,43 @@ test("paused prepare refusal retains result and closes; close interrupts prepara test("wrong first target cannot be hidden by a later matching chunk", async () => { const f = await pausedSeekFixture(); const seeking = f.session.seekFrames(100); - const rejected = assert.rejects(seeking, (e: unknown) => e instanceof EngineWebAdapterError && e.code === "session.seek"); + const rejected = assert.rejects(seeking, (e: unknown) => e instanceof EngineWebAdapterError && e.code === "session.seek" + && e.cause instanceof EngineWebAdapterError && e.cause.details.reason === "mismatch" + && e.cause.details.sourceId === "source" && e.cause.cause instanceof PcmRunwayError); await tick(); f.confirm(); f.write(101n); f.write(100n); await rejected; assert.equal(f.session.state, "closed"); assert.equal(f.events.includes("context.resume"), false); + for (const event of ["pump.close", "detach", "dispose", "context.close", "lease.close"]) { + assert.equal(f.events.filter((value) => value === event).length, 1, event + " completes before refusal"); + } }); test("paused preparation and fresh prefill deadlines close rather than allow play", { timeout: 6000 }, async () => { for (const prepared of [false, true]) { const f = await pausedSeekFixture(); const seeking = f.session.seekFrames(100); - const rejected = assert.rejects(seeking, (e: unknown) => e instanceof EngineWebAdapterError && e.code === "session.seek"); + const rejected = assert.rejects(seeking, (e: unknown) => { + assert.ok(e instanceof EngineWebAdapterError && e.code === "session.seek"); + assert.ok(e.cause instanceof EngineWebAdapterError); + if (prepared) { + assert.deepEqual(e.cause.details, { reason: "timeout" }); + assert.ok(e.cause.cause instanceof PcmRunwayError); + assert.equal(e.cause.cause.reason, "timeout"); + } else { + assert.ok(e.cause.cause instanceof PcmFeedError); + assert.equal(e.cause.cause.operation, "prepareTimeout"); + } + return true; + }); await tick(); if (prepared) f.confirm(); await rejected; assert.equal(f.session.state, "closed"); await assert.rejects(f.session.play(), (e: unknown) => e instanceof EngineWebAdapterError && e.code === "session.closed"); assert.equal(f.events.includes("context.resume"), false); + for (const event of ["pump.close", "detach", "dispose", "context.close", "lease.close"]) { + assert.equal(f.events.filter((value) => value === event).length, 1, event + " completes before refusal"); + } } });