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 new file mode 100644 index 0000000..d931ed5 --- /dev/null +++ b/.github/ISSUE_SPECS/101-delegate-session-pcm-prefill-proof-to-the-sdk-runway-helper.md @@ -0,0 +1,65 @@ +# Delegate session PCM prefill proof to the SDK runway helper + +Depends on misofm/engine#797 and a published SDK containing its +`waitForPcmRunway` contract for final package acceptance. Independent of adapter +misofm/engine-web-adapter#95, misofm/engine-web-adapter#102 and app misofm/app#210; never hold their delivery for this work. + +## Smallest outcome and facts + +Dense and sparse session opening/seek use the SDK's generic PCM runway proof, +leaving this adapter responsible for its producer and aggregate choreography. +Current source baseline is main +`f833303f146de7cbe1705fe88ae68a6d6e0d4e45`; refresh before implementation. +`src/feed.ts`, `src/scratch.ts` and `src/stems/ring.ts` already delegate to SDK +primitives. Only `src/session.ts::waitForRunway` duplicates the generic generation, +contiguity and ring-capacity proof. This is source-level ownership evidence. + +## Required change + +1. Replace that algorithm with SDK `waitForPcmRunway`. Opening supplies existing + source totals/rings, frame 0, generation 1 and the current two-second timeout; + seek supplies its acknowledged target/full generation and the same deadline. + Omit `minimumFrames` to retain today's full-capacity/EOF-clamped policy. +2. Keep source `pump.seekFrames`, feed attachment/`prepareSeek`, context-state + checks, suspension/resumption, serialized transport, producer/refill work and + terminal cleanup exactly at their current composition seam. The helper never + grants permission to skip these steps or resumes audio itself. +3. Remove now-unused per-prefill observer construction and direct protocol + checks. Keep the existing diagnostic/source-observation objects because they + have independent callers. Do not alter ring/read cursor behavior or prefetch. +4. Translate `PcmRunwayError` into existing `session.open`/`session.seek` failures, + preserving source ID, cause and timeout/mismatch distinction in details. + Preserve abort/close semantics and meaningful SDK usage errors. No second + fallback algorithm or copy retained after migration. +5. Preserve dense/sparse canonical BLAKE3/count checks, warm/cold worker limits, + 512 KiB reads, 128 KiB ingest bounds, 64 KiB zero checkpoints, source-specific + cleanup/locks, current diagnostics and all playback behavior. No codec, + storage framework, source-policy expansion or transport rewrite. + +## Focused proof and delivery + +Files: `src/session.ts`, an existing error translation seam only if needed, +`tests/session.test.ts`, README and package/provenance pins when adopting the +published SDK. Reuse existing opening, suspended/running seek and failure tests: +full current-generation prefill before success, wrong generation/contiguity, +EOF, timeout/cancellation, close during seek, and restore-running only after +successful proof. Assert consumer preparation precedes the SDK call and a +failure still completes aggregate cleanup. SDK owns detailed ring unit cases; +do not duplicate its matrix or add an engine factory just for testing. + +Root commits one focused-green implementation tranche. Run `npm run check`; +for a released candidate run existing packed-browser/publish-dry-run gates and +fresh-consumer dependency checks once. Package with another coherent adapter +release when available; do not delay misofm/engine-web-adapter#95/app plotting to manufacture batching. +Final published adoption needs exact SDK/adapter versions, merged source SHAs, +registry integrities and existing provenance/attestation checks. If it cannot +share misofm/engine-web-adapter#95's cut, use a stateless ordinary release issue for publication and +record that dependency explicitly; source availability is never registry proof. + +Luna XHIGH implements; a fresh Astra MEDIUM independently verifies and fixes +concrete in-scope bugs only; coordinator is a separate fresh Astra MEDIUM. +Maximum five coherent attempts with one verdict each; preserve evidence and +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. 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 new file mode 100644 index 0000000..46b8c22 --- /dev/null +++ b/.github/ISSUE_SPECS/102-apply-opfs-capability-requirements-only-to-the-selected-storage-path.md @@ -0,0 +1,122 @@ +# Apply OPFS capability requirements only to the selected storage path + +Independent of adapter misofm/engine-web-adapter#95/misofm/engine-web-adapter#101 and engine/app analysis work. +This issue does not implement IndexedDB, WAVPACK or another backend. + +## Outcome and source evidence + +A caller supplying an existing custom dense or sparse store can open a session +without unrelated OPFS/Web Locks browser APIs. Default OPFS sessions retain +their current early, typed capability refusals and Worker-side write probe. + +At adapter main `f833303f146de7cbe1705fe88ae68a6d6e0d4e45`, +`src/session.ts::openSessionCommon` unconditionally calls +`assertEngineWebCapabilities` before selecting a store. That checker in +`src/capabilities.ts` requires `navigator.storage.getDirectory`, +`FileSystemFileHandle` and Web Locks even when `options.store` supplies the +whole storage service. `OpfsStorageBackend.open()` in `src/stems/storage.ts` +already probes actual Worker-only write support before source resolution. +This establishes an unnecessary source-level gate, not a demonstrated production +incident. Refresh latest main and read its AGENTS before implementing. + +## Frozen correction + +1. Separate the existing runtime predicates (cross-origin isolation, SAB, + Worker, AudioContext/AudioWorklet and Wasm SIMD128) from OPFS persistence + predicates in `src/capabilities.ts`. Reuse existing errors/remedies. +2. Preserve the existing module-level `assertEngineWebCapabilities(scope?)` as the comprehensive + default-FLAC/OPFS preflight for compatibility. Use two small internal/exported + module helpers, `assertEngineRuntimeCapabilities(scope?)` and + `assertOpfsStorageCapabilities(scope?)`, to compose its existing behavior. + Export from the package only if a current public use requires it; no new + capability registry, backend descriptor or plug-in protocol. +3. Both open entry points snapshot whether a store was supplied before awaiting + work. `openSessionCommon` always runs runtime checks; run OPFS checks only + for the default store path, before creating it or invoking resolver/network + work. Sparse opening currently removes `store` from `commonOptions`, so pass + the selected-path fact explicitly; do not accidentally treat it as default. +4. A supplied store owns its persistence/locking requirements. Do not inspect + its class name, assume all custom stores use OPFS, or disable actual backend + errors. Injected OPFS still executes its own `open()`/Worker support checks. + Default OPFS retains window-visible file handles/getDirectory and Web Locks + checks, Worker write handshake and existing typed failures/remedies. +5. Preserve source validation/admission ordering, BLAKE3/count verification, + current locking and cleanup contracts, warm/cold paths and output behavior. + There is no promise that a custom store is safe on multiple tabs without its + own coordination. Do not modify locks, storage algorithms or source policy. + +## Small proof and completion + +Change `src/capabilities.ts`, the common-open selected-path argument and focused +`tests/session.test.ts` cases plus concise API docs. Existing memory/custom-store +fixtures must reach normal opening for dense and sparse paths with valid runtime +capabilities but no OPFS/file handles/Web Locks. Missing runtime requirements +must still refuse before store/resolver work. Default-store cases must still +refuse missing persistence APIs before source work and retain the existing +Worker write-support failure/cleanup case. A supplied store's own failure must +propagate. Reuse the existing default OPFS packed smoke; no new browser/backend +matrix or IndexedDB test framework. + +Run focused tests and `npm run check`, then current required CI. One coherent +checkpoint should suffice; root commits focused-green exact paths before more +implementation. Luna XHIGH implements, a fresh Astra MEDIUM verifies and may +fix concrete in-scope bugs only, and a separate fresh Astra MEDIUM coordinates. +At most five coherent attempts/one verdict each; after five failures stop and +rebrief. Root records the source/evidence and closes local/GitHub state after +independent PASS, merged delivery and required CI; verify remote CLOSED. +If package publication is deferred, explicitly record the ordinary release +successor and do not claim registry availability. It may share a ready adapter +release cut, but misofm/engine-web-adapter#95 and app misofm/app#210 do not wait for this independent correction. + +Decision record: scope-only; no implementation or fresh runtime result yet. + + +## Implementation checkpoint + +Luna XHIGH implemented the frozen selected-path correction in isolated +`codex/backend-capabilities`: runtime and OPFS preflights compose the existing +comprehensive checker; dense and sparse opens snapshot whether a store was +supplied and skip persistence predicates only for that selected custom path. +Default-store refusal order and actual backend failures remain intact. +Implementer reports focused session 45/45, full `npm run check` 367/367 with +package policy, and whitespace checks PASS. Root checkpoints the four intended +source/test/README paths; no release pins changed. Independent fresh Astra +MEDIUM verification and required merged CI remain pending. This is attempt 1; +no published capability or final acceptance is claimed yet. + + +## Independent attempt 1 review — 2026-09-14 + +Fresh Astra MEDIUM adversarial review of implementation +`6616d01b7560d35975ad0b9733f97e7f76cfe708`: **PASS for the frozen correction**. +No production or test fixes were needed. Both entry points retain their supplied +store and selected-path fact before awaiting work; runtime predicates always +run, default persistence predicates precede scratch/source work, and supplied +stores retain their actual backend checks and error propagation. The helpers +remain module exports, without a new package API or backend protocol. + +Independent local evidence, Node 22.23.2/npm 10.9.8: + +- Focused session tests: 45/45 PASS. +- `npm run check`: 367/367 PASS, including reproducible SHA256 Wasm, + source/decoder/package policies and type checks. +- Fourteen review probes: all six runtime refusals precede scratch/store work + for both entry points; mutating `options.store` during deferred scratch work + preserves the original supplied store and its exact typed backend refusal. +- Existing packed default browser fixture: PASS on Chromium 151.0.7922.34, + including cold/warm ingestion, Worker/AudioWorklet assets and no console or + request failures. +- Existing packed OPFS gate: Chromium PASS (65,536 cold bytes, warm reuse, + sparse verification and cleanup). Linux Playwright WebKit 26.5 is an + environment blocker: its fixture throws `ReferenceError` for the absent + `FileSystemFileHandle`. A separate blank localhost page, secure and + cross-origin isolated, confirms both `FileSystemFileHandle` and + `navigator.storage.getDirectory` are undefined without loading the adapter. + This is **not a passing WebKit gate**; required macOS OPFS CI remains pending. +- Implementation whitespace check: PASS. + +Raw logs and the repeatable review probe are preserved outside this worktree in +`/tmp/miso-796-audit/adapter102-{focused,check,adversarial,packed-browser,packed-opfs,webkit-environment}.log` +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. 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 new file mode 100644 index 0000000..07efd55 --- /dev/null +++ b/.github/ISSUE_SPECS/95-expose-the-same-sdk-engine-and-delegate-browser-session-measurements-and-control.md @@ -0,0 +1,138 @@ +# Expose the same SDK engine and delegate browser-session measurements and control + +Supersedes the former three-analysis-delegate plan in this issue. Depends on +misofm/engine#796 for the frozen SDK API and misofm/engine#794 for final published +SDK provenance. Downstream: misofm/app#210. PCM readiness and backend capability +successors are separate and are not prerequisites for this issue. + +## Outcome and baseline + +An adapter session supplies FLAC/sparse storage and PCM while its consumer uses +one SDK engine directly for controls, observations, meters, response and spectrum. +Expose the existing playback engine; a getter alone is insufficient if adapter +`HostFeed`, canonical measurement types or a second console remain. + +Current main: `f833303f146de7cbe1705fe88ae68a6d6e0d4e45`, adapter 0.5.4, +SDK 0.2.4 and codec 0.1.1. Engine accepted main is +`69c268f240bf30b2a43b43dd521120cd89dcc0b9`; misofm/engine#793 is CLOSED and its SDK 0.2.5 +metadata is merged but the registry still reports 0.2.4 on 2026-09-13. +These are inspection facts; recheck before implementation and release. + +## Frozen implementation contract + +1. Export `SessionEngine = Omit, "close">` + and add `readonly engine: SessionEngine` to `EngineWebSession`. Return the + identical existing engine object, retaining receivers. This is a borrowed + TypeScript view, not runtime isolation. The composition session owns close: + consumers call `session.close()` and must not close its host/context/engine + separately or start work after requesting aggregate close. Accessing the + getter during closing/closed rejects with existing `session.closed`. +2. Obtain `await engine.console()` in `attachSessionControl(engine)`, preserving + its managed-observation hook. An existing narrow `session.console` guard may + delegate `edit`/`submit`; it must not call `createBrowserConsole(host)` or + construct another console. Preserve exact SDK command reports and typed + semantic failures. Ordinary aliases refuse once aggregate closing begins. +3. Delete adapter `HostFeed`. SDK `subscribeMeters`/`subscribeTelemetry` own + leases, listener multiplexing, errors and engine disposal. Keep `session.meters` + and `session.telemetry` as deprecated compatibility paths to those methods. + Telemetry is an SDK-type alias and returns the SDK update unchanged. +4. The sole temporary meter compatibility projection may add legacy + `peak = Math.max(peakLeft, peakRight)` to track/master records. Define its + types as SDK types extended with that field, never a second canonical model. + Spread all canonical update/record fields so generation, validity, loss, + spans and future fields survive; preserve master `gainReductionDb: null`. + Correct old null-to-zero types/tests/docs deliberately. This stateless + per-callback projection adds no subscription owner, timer, cache or Worker. + New consumers use `session.engine.subscribeMeters` and app-owned display math. +5. Both dense and sparse opens accept SDK-typed `spectrum`, + `spectrumCollection`, `observationSubscriptionLimits`, + `responseSubscriptionLimits` and `spectrumSubscriptionLimits` and forward + them to that one `createEngine`. Keep singular/collection exclusivity and + exact prepared target/mask/budget semantics. Snapshot mutable options before + the first opening await, using public SDK copying helpers where available + or only the small declared structure. Preserve scratch-compiled module reuse; the accepted SDK owns preparation + validation and its boot contract. + No adapter-specific target policy or broad `CreateEngineOptions` spread. +6. Do not add `session.subscribeTrackResponse`, `session.queryTrackResponse` + or `session.subscribeSpectrum` mirrors. Use the same SDK engine for all + those operations; their failures are optional analysis failures, outside the + source seek/transport queue. Document native SDK errors on direct engine + calls and legacy adapter errors only at existing convenience boundaries. + `console: false` retains adapter console/meter/telemetry refusals; the exposed + engine retains its own SDK policy semantics, without a new adapter facade. +7. Keep aggregate cleanup order, retries and late acquisition handling. + SDK `engine.close()` closes SDK-owned leases/subscriptions/Workers; adapter + closes producer, feed, output and source/store lifetime through its existing + cleanup. No second engine, context, Worker pool or disposer registry. + +## Preserve launch source behavior + +Do not alter FLAC/sparse resolution, BLAKE3 identities/cache namespace, canonical +count/digest verification, integer PCM conversion, OPFS, ring/feed/scratch +protocol, seek/prefill algorithm, prefetch or Effect control pipelines. Preserve +the current preparation-owned warm pool: at most three workers, further bounded +by concurrency/funded 2 MiB slots; one current 512 KiB payload per worker, +interval-local reads, 128 KiB ingest bound, 64 KiB zero checkpoints, consumed +buffer disposal, warm MessageChannel yields and main-realm fallback. Keep cold +capacity, Blob/index identity, cancellation/physical cleanup before locks and +readiness, diagnostics and packaged verification assets. No new source format, +IndexedDB backend, selector, storage framework or performance experiment. + +## Locations and checkpoints + +`src/session-types.ts`, `src/session.ts`, `src/console.ts`, public exports, +`tests/console.test.ts`, `tests/session.test.ts`, README, existing packed consumer. +Refresh/integrate latest main in the isolated worktree; do not touch unrelated +primary changes. Source implementation can use an exact accepted SDK tarball +before registry publication, with temporary installation state outside commits. +Final dependency pins/package acceptance require the registry SDK from misofm/engine#794. + +1. Same engine/control/measurement ownership and option forwarding; focused + types/tests green, then root commits exact paths. +2. Upgrade the exact SDK dependency and adapter release identities using existing + scripts/workflow; packed qualification, fresh review and merged delivery. + +## Focused gates and publication + +- Both open paths expose object-identical `engine`/host/context; repeated console + acquisition uses the SDK's existing owner hook. A managed observation/manual + console conflict retains SDK behavior. Multiple direct and compatibility + measurement subscriptions share one host lease; all canonical fields and null + survive legacy projection. `console: false`, late acquisition and close retain + existing contracts; no callbacks survive completed aggregate close. +- Mutate input preparation/limits across an opening await and prove snapshotted + values reach the existing engine. Preserve ordinary no-analysis opening. +- Extend `scripts/browser-packed.mjs` and its strict consumer, using one existing + packed Chromium scenario: actual sparse session/pump/Worklet/response Worker, + real SDK meters, one managed response and spectrum, atomic target switch + while context/pump remain running, refused target preserving the old stream, + close and no asset failures. Reuse existing fixture and cold/warm assertions. + This tests delegation; the app owns full numerical plotting acceptance. +- Run `npm run check`, `npm run test:browser` and `npm run publish:dry-run` on + the final candidate. The browser runner copies locked dependencies; also + install the tarball normally in a fresh directory and compile/import its + public surface to prove registry dependency resolution. No new harness/matrix. +- Choose an unused adapter version at release time. Update existing package/ + lock, `src/provenance.ts`, `scripts/check-package.mjs`, README and workflow + exact-version/SDK guards from actual registry bytes. Preserve legal/codec + assets and trusted release checks. Use existing `npm-publish.yml` + `publish|verify` with exact merged main SHA, OIDC and existing attestation + checks; an ambiguous publish is recovered with verify, never republishing. + +## Workflow and completion + +Luna XHIGH implements; a fresh Astra MEDIUM verifies and fixes concrete bugs +within this brief only; the fresh Astra MEDIUM coordinator is distinct. Maximum +five coherent attempts, one verdict each; stop/rebrief after the fifth failure. +Checkpoint focused-green tranches, run required CI, preserve exact source/package +evidence and synchronize local/GitHub state. Completion requires independent +PASS, merged source, published package and exact registry SDK dependency, +archive integrity and provenance/attestation proof, then verified issue closure. +A source PASS or tarball alone does not establish published app dependencies. + +## Decision record + +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. diff --git a/README.md b/README.md index 02f02cc..5bec005 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,12 @@ the only decoder path. The server must expose exact `Content-Range` and `Content return status 206, avoid `Content-Encoding`, and keep total size and any visible ETag stable across attempts. +Supplying an existing `store` selects caller-owned persistence for either the +dense or sparse session entry point. The adapter still checks the shared Engine +runtime requirements, while the store owns its own persistence and locking +requirements. Omitting `store` selects the default OPFS path and runs the typed +OPFS and Web Locks preflight before scratch boot or source resolution begins. + ### Safari floor The **decoder** is universal: Chromium, macOS Safari, and mobile Safari run the diff --git a/src/capabilities.ts b/src/capabilities.ts index 2152f72..3586671 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -25,10 +25,25 @@ export interface WebCapabilityScope { /** Synchronous, allocation-small gates that run before resolver/network work. */ export function assertEngineWebCapabilities(scope: WebCapabilityScope = globalThis): void { + assertEngineRuntimeCapabilities(scope); + assertOpfsStorageCapabilities(scope); +} + +/** + * Check the browser primitives required by every Engine Web session. Storage + * requirements are deliberately kept out of this helper so caller-supplied + * stores can own their persistence capabilities. + */ +export function assertEngineRuntimeCapabilities(scope: WebCapabilityScope = globalThis): void { requireCapability(scope.crossOriginIsolated === true, "capability.cross_origin_isolation", "Cross-origin isolation is required"); requireCapability(typeof scope.SharedArrayBuffer === "function", "capability.shared_array_buffer", "SharedArrayBuffer is required"); requireCapability(typeof scope.Worker === "function", "capability.module_worker", "Module Worker support is required"); requireCapability(typeof scope.AudioContext === "function" && typeof scope.AudioWorkletNode === "function", "capability.audio_worklet", "AudioWorklet is required"); + requireCapability(scope.WebAssembly?.validate(SIMD128_PROBE) === true, "capability.simd128", "WebAssembly SIMD128 is required"); +} + +/** Check the browser primitives used by the default OPFS storage path. */ +export function assertOpfsStorageCapabilities(scope: WebCapabilityScope = globalThis): void { requireCapability( typeof scope.navigator?.storage?.getDirectory === "function", "capability.opfs", @@ -48,7 +63,6 @@ export function assertEngineWebCapabilities(scope: WebCapabilityScope = globalTh { missing: "FileSystemFileHandle", remedy: OPFS_WRITE_REMEDY }, ); requireCapability(typeof scope.navigator?.locks?.request === "function", "capability.web_locks", "Web Locks are required"); - requireCapability(scope.WebAssembly?.validate(SIMD128_PROBE) === true, "capability.simd128", "WebAssembly SIMD128 is required"); } function requireCapability( diff --git a/src/session.ts b/src/session.ts index 3ab2adb..5318f43 100644 --- a/src/session.ts +++ b/src/session.ts @@ -7,7 +7,7 @@ import { createEngine, createDefaultHost, scratchBootOptions, MSB1_CONTROL, Msb1 import type { BrowserEngine, CreateEngineOptions } from "@misofm/engine/browser"; import { ADAPTER_ASSETS } from "./assets.js"; -import { assertEngineWebCapabilities } from "./capabilities.js"; +import { assertEngineRuntimeCapabilities, assertOpfsStorageCapabilities } from "./capabilities.js"; import { attachSessionControl } from "./console.js"; import type { SessionControl } from "./console.js"; import { EngineWebAdapterError } from "./errors.js"; @@ -65,6 +65,7 @@ function sparseSessionCancellationError(cause: unknown): SparseSessionCancellati export async function openEngineWebSession(options: EngineWebSessionOptions): Promise { const { flac, resolver, store, createPump, ingestDiagnostics, assets, onProgress } = options; + const storeSupplied = store !== undefined; bindIngestDiagnostics(ingestDiagnostics); const hasFlac = flac !== undefined; const hasResolver = resolver !== undefined; @@ -75,13 +76,14 @@ export async function openEngineWebSession(options: EngineWebSessionOptions): Pr { hasFlac, hasResolver }, ); } - return openSessionCommon(options, (input) => prepareDenseSources({ + return openSessionCommon(options, storeSupplied, (input) => prepareDenseSources({ ...input, flac, resolver: resolver as StemResolver, store, createPump, ingestDiagnostics, assets, onProgress, })); } export async function openSparseEngineWebSession(options: SparseEngineWebSessionOptions): Promise { const { store, resolver, maximumMetadataBytes, createPump, assets, onProgress, ...commonOptions } = options; + const storeSupplied = store !== undefined; if (onProgress !== undefined && typeof onProgress !== "function") throw new TypeError("Sparse session onProgress must be a function"); const progress = sparseProgressReporter(onProgress); // This entry point has one source contract. Refuse dense/FLAC-shaped input @@ -95,7 +97,7 @@ export async function openSparseEngineWebSession(options: SparseEngineWebSession ); } try { - return await openSessionCommon({ ...commonOptions, ...(assets === undefined ? {} : { assets }), onProgress: progress.emit }, (input) => prepareSparseSources({ + return await openSessionCommon({ ...commonOptions, ...(assets === undefined ? {} : { assets }), onProgress: progress.emit }, storeSupplied, (input) => prepareSparseSources({ ...input, store, resolver, maximumMetadataBytes, createPump, assets, onProgress: progress.emit, })); } finally { @@ -137,8 +139,9 @@ interface SparseSourcePreparationInput extends SourcePreparationInput { readonly onProgress: SparseEngineWebSessionOptions["onProgress"]; } -async function openSessionCommon(options: SessionOpenOptions, prepareSources: PrepareSources): Promise { - assertEngineWebCapabilities(options.capabilityScope); +async function openSessionCommon(options: SessionOpenOptions, storeSupplied: boolean, prepareSources: PrepareSources): Promise { + assertEngineRuntimeCapabilities(options.capabilityScope); + if (!storeSupplied) assertOpfsStorageCapabilities(options.capabilityScope); const abort = new AbortController(); const detachAbort = forwardAbort(options.signal, abort); const cleanup: Array<() => void | Promise> = []; diff --git a/tests/session.test.ts b/tests/session.test.ts index d3fca9a..8142482 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -13,7 +13,7 @@ import { createBLAKE3 } from "hash-wasm"; import type { BrowserEngine } from "@misofm/engine/browser"; import { EngineWebAdapterError, openEngineWebSession, openSparseEngineWebSession } from "../src/index.js"; -import { assertEngineWebCapabilities } from "../src/capabilities.js"; +import { assertEngineRuntimeCapabilities, assertEngineWebCapabilities, assertOpfsStorageCapabilities } from "../src/capabilities.js"; import { MSB1_CONTROL } from "../src/stems/ring.js"; import type { EngineAudioContext, EnginePump, EngineWebSessionCommonOptions, EngineWebSessionOptions } from "../src/session-types.js"; import type { FlacWorkerRequest, FlacWorkerResponse } from "../src/stems/flac-worker-protocol.js"; @@ -87,6 +87,60 @@ test("OPFS refusals name what is missing and carry a remedy", () => { } }); +test("runtime and OPFS capability checks compose independently", () => { + const withoutStorage = capabilitiesWithoutOpfs(); + assert.doesNotThrow(() => assertEngineRuntimeCapabilities(withoutStorage)); + assert.throws( + () => assertOpfsStorageCapabilities(withoutStorage), + (error: unknown) => error instanceof EngineWebAdapterError + && error.code === "capability.opfs" + && error.details.missing === "navigator.storage.getDirectory", + ); + + const withoutRuntime = { ...capabilities(), crossOriginIsolated: false }; + assert.throws( + () => assertEngineRuntimeCapabilities(withoutRuntime), + (error: unknown) => error instanceof EngineWebAdapterError && error.code === "capability.cross_origin_isolation", + ); +}); + +test("default dense and sparse stores retain OPFS preflight before source work", async () => { + const base = baseOptions(); + let denseScratch = 0; + let denseResolver = 0; + const denseResolverPath: StemResolver = { + async resolve() { denseResolver += 1; throw new Error("default resolver must not run"); }, + }; + await assert.rejects( + openEngineWebSession({ + ...base, resolver: denseResolverPath, capabilityScope: capabilitiesWithoutOpfs(), + scratchBoot: async () => { denseScratch += 1; throw new Error("scratch must not run"); }, + }), + (error: unknown) => error instanceof EngineWebAdapterError + && error.code === "capability.opfs" + && error.details.missing === "navigator.storage.getDirectory", + ); + assert.equal(denseScratch, 0); + assert.equal(denseResolver, 0); + + let sparseScratch = 0; + let sparseResolver = 0; + await assert.rejects( + openSparseEngineWebSession({ + document: base.document, sources: base.sources!, leaseId: base.leaseId!, + resolver: async () => { sparseResolver += 1; throw new Error("default resolver must not run"); }, + capabilityScope: capabilitiesWithoutOpfs(), + scratchBoot: async () => { sparseScratch += 1; throw new Error("scratch must not run"); }, + // The omitted store selects the default OPFS path. + }), + (error: unknown) => error instanceof EngineWebAdapterError + && error.code === "capability.opfs" + && error.details.missing === "navigator.storage.getDirectory", + ); + assert.equal(sparseScratch, 0); + assert.equal(sparseResolver, 0); +}); + test("default module Worker handshake fails before store or resolver work", async () => { let storeOpened = false; let resolverCalled = false; @@ -234,7 +288,8 @@ test("session composes in order and serializes lifecycle with reverse cleanup", { id: "source-z", spec: { channels: 1, bitDepth: 16, frames: 4, content: IDENTITY_Z } }, { id: "source", spec: { channels: 1, bitDepth: 16, frames: 4, content: IDENTITY } }, ]), - capabilityScope: capabilities(), + // This supplied store owns persistence, so unrelated OPFS APIs are not required. + capabilityScope: capabilitiesWithoutOpfs(), createContext: () => context, createHost: async ({ context: engineContext, preparedModule }) => { assert.equal(preparedModule, scratchWorker!.module, "the locally received module survives verified ingestion"); @@ -351,7 +406,8 @@ test("sparse session prepares complete authoritative sources through the shared } as unknown as BrowserEngine["host"]; const session = await openSparseEngineWebSession({ document: documentFor(sources), sources, leaseId: "sparse-lease", console: false, - capabilityScope: capabilities(), store, + // This supplied store owns persistence, so unrelated OPFS APIs are not required. + capabilityScope: capabilitiesWithoutOpfs(), store, scratchBoot: async () => ({ sampleRateHz: 48_000, quantumFrames: 4, sourceRingFrames: 16, backend: "simd128", tracks: [], sources: compiled.map((source) => ({ id: source.id, channels: source.spec.channels, frames: BigInt(source.spec.frames) })) }), createContext: () => context, @@ -1753,6 +1809,10 @@ function capabilities(): NonNullable }; } +function capabilitiesWithoutOpfs(): NonNullable { + return { ...capabilities(), navigator: {}, FileSystemFileHandle: undefined }; +} + function fakeContext(events: string[], hangResume = false): EngineAudioContext & { modules: string[] } { const modules: string[] = []; let state = "suspended";