diff --git a/.github/ISSUE_SPECS/1323-reset-latency-floors-at-a-host-declared-discontinuity.md b/.github/ISSUE_SPECS/1323-reset-latency-floors-at-a-host-declared-discontinuity.md index 05ed0e263..4cce5606e 100644 --- a/.github/ISSUE_SPECS/1323-reset-latency-floors-at-a-host-declared-discontinuity.md +++ b/.github/ISSUE_SPECS/1323-reset-latency-floors-at-a-host-declared-discontinuity.md @@ -417,3 +417,14 @@ adoption, `EXACT`, with `SUPERSEDED` for those A folded in. **Measured.** `audit capi`: allocations 0, deallocations 0, syscalls 0, pcm_digest `4f52089e4a8cf83c` (unchanged; `tools/audit` not touched). C ABI gate 2: latency 486 -> 0 at 48 kHz. No resource ceiling was re-pinned. + +## Amendment (root ruling B16-R9, batch 16b review, 2026-10-16) + +B15-R7's order ("#1643 calls it before its seek") is retired. A plain session seek (#1642, +`SessionState::session_seek`) is a declared discontinuity: it runs this slice's declaration in the +same call, through the same core path (`declare_discontinuity_serviced`), after every check and +before any push; a refused seek declares nothing, and a refused declaration pushes nothing and +returns its own error. An anchored session seek declares nothing (it keeps continuity: D2 publishes +`Next`, before the anchor). No host and no SDK calls `declare_discontinuity` before a session seek. +D1's duty is now: a host calls it at a stop, and before a seek of every source made with +per-source seeks. B15-R7's browser export stays as it is. diff --git a/.github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md b/.github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md index 5b0496572..4dbbc0c41 100644 --- a/.github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md +++ b/.github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md @@ -358,3 +358,245 @@ the existing `impl SessionState`. none of them does: no seek is split across blocks. Run at both bank widths the native build covers. The render thread's allocation and free counts stay 0 (`bench_support::alloc` thread counters after warming). + +## Decision record: tranche 1, core (batch 16) + +Built on `454d65688` (worktree `codex/controls-batch-16b`). Tranche 2 (D4-D6: C exports, bit, +header, docs, audit, capi gates 1-7) follows. + +**What shipped:** + +- **B16-R2, the publish fence** (`crates/source/src/lib.rs`). `PublishedGeneration`, one + `Arc` per session: `new(initial)` (control thread), `publish(g)` (`fetch_max`, + `Release`: the word is `max(word, g)` and never falls), `generation()`, `retained_bytes()`, and + a crate-private block-entry load (`Acquire`). Equality is identity (`Arc::ptr_eq`). + `SeekProducer` holds `Option`; its `push` is the old `try_seek` without a + publication, and `try_seek` is `push` then publish. `PcmSourceProducer`, `HostChunkProvider` and + `TimelineProducer` gain `try_seek_unpublished` and `share_published_generation`; their + `try_seek` publishes after its one push. The shared observe step's pop is + `SeekState::take_published(queue, published)`: `peek(0)` (#1320), and pop only a command whose + generation is at most the loaded value; otherwise the command keeps its slot. The PCM consumer + and the timeline call it at every observe. The driver holds `Option`, + loads it once at `begin_block` entry, before the timeline and the sources, and passes the value + to both (and to `prepare_source_seek`). `prepare_session_graph_source_set` seals a session's + set with the word; `prepare_graph_source_set` (standalone, no word, no fence) is unchanged. + Superseded by 'Fold (batch 16b review), part A' below. +- **No acked PCM is dropped by the fence.** The PCM consumer's acquire now peeks each block before + it pops it: a block of an unobserved generation gets the one re-observe, and if its seek is + still unobserved (fenced) the acquire stops and leaves the block queued behind its command. + Before, the acquire popped the block first and, had the re-observe found nothing, would have + discarded it. For an unfenced consumer the outcome is the old one in every case the old + reasoning allowed. +- **One word per session** (`crates/host-core/src/prepare.rs`). `PlanStateInventory` holds it: a + first plan allocates it, a successor takes `base.inventory`'s, `at_discontinuity` keeps it and + `across_withdrawn` clones it, so every plan of a session, its fresh rings (a source a candidate + adds or redeclares), its carried consumers, its timeline and a donation's two sides share one + object. `test_only_floors_only` (a fresh reference) gets a word of its own. Every fresh producer + and the fresh timeline producer share it before their first seek; the driver gets it through + `prepare_session_graph_source_set`; `SourceControlSet` holds it (`ControlSourceBuilder::finish`). + `adopt_persisting` debug-asserts both sets hold the same word. +- **D1** (`crates/host-core/src/source.rs`). `SourceControlSet::seek_session(generation, + timeline_sample, anchor_sample)`: zero is `GenerationZero`; a generation not above the word is + `Seek(GenerationNotStrictlyIncreasing)` (`source.generation.stale`); then `check_seek` on the + timeline and every source in ID order (vacant is `Vacated`), each source at + `min(timeline_sample, region_end)` (then `max(region_start)`, a no-op while host-core sets 0); + then every `try_seek_unpublished` (timeline first), then one `publish(g)`. A failed push after + the checks is `Chunk(HostChunkError::InternalInvariant)` with a `debug_assert`; the pushed + commands are published so none stays held. `published_generation()` reads the word. +- **SessionState::session_seek** (`crates/control-plane/src/control.rs`, inside the existing + `impl SessionState`): the service step, then `seek_session` on `newest_providers_mut().sources`. + +**Departures:** + +- **The word is charged in the inventory, not the source rows.** `PlanStateInventory::retained_bytes` + gains `PublishedGeneration::retained_bytes()` (24 bytes on x86-64), because the inventory is + the owner that holds the word across plans and the capi drop oracle frees it there + (`capi_retained_bytes_charge_every_byte_the_compile_retains` passes unchanged). No source row, + pin or ceiling moved. Each kept inventory charges it once, an overcharge in the safe direction. +- **Four test literals** of `SourceGraphSourceSetDriver` in `crates/source/src/lib.rs` and one of + `PlanStateInventory` in `prepare.rs` gain the new field (`published: None`, a fresh word): + field additions only, no assertion changed. +- **`crates/host-core/tests/timeline.rs`**: `seek_both` (gates 1 and 3 of #1641) now issues one + `seek_session` instead of a source seek plus `test_only_timeline_producer().try_seek`. The other + `test_only_timeline_producer` uses stay: they seek the timeline alone. +- **A per-source seek pushed during a block** with a generation above the word now applies at the + next block, not in the block the push raced (B16-R2's rule for every command). Only a + concurrent host sees it; no existing test does. + +**Gates (tranche 1):** + +- D1 unit tests, `crates/host-core/src/source.rs` `source::tests`: + `both_forms_move_the_timeline_and_every_source_clamped_to_its_region`, + `a_refusal_pushes_nothing_and_publishes_nothing`, `a_vacant_entry_refuses_the_whole_seek`. PASS. +- Fence unit tests, `crates/source/src/lib.rs` `tests::published`: + `an_unpublished_seek_and_its_pcm_wait_in_their_slots_until_published` (red with the fence + removed), `a_seek_on_its_own_publishes_its_generation_and_the_word_never_falls`. PASS. +- **Gate 1b**, `crates/host-core/tests/session_seek.rs` + `concurrent_session_seeks_reach_the_timeline_and_every_source_in_one_block`: a session thread + pushes 10,000 session seeks (about half anchored, some late; generation `k + 2`; each pushed at a + random instant up to 40 µs after a block starts) while a render thread renders; every timeline + step is in the block both sources' seek reports name, at the seek's frames (the short source + clamped), at Simd8 and Simd4; render-thread allocations, reallocations and frees 0 over every + measured block. About 12 s for both widths (debug). With the fence removed (`take_published` + ignoring the value) it fails in each of two runs, at seeks 297 and 1,368 (`short` one block + ahead of the timeline and `long`). +- Commands: `cargo test --locked -p source`, `-p host-core --features host-core/test-support`, + `-p control-plane`, `-p capi`: PASS. `./target/release/audit capi`: `pcm_digest` + `4f52089e4a8cf83c` (unchanged), 0 violations. `realtime_policy`: ok, 153 regions (unchanged). + Clippy `-p source -p host-core -p control-plane --all-targets --all-features -D warnings`, + `cargo check --workspace --all-targets --all-features`, `cargo fmt --all`: clean. + +## Decision record: tranche 2, the C ABI (batch 16) + +Built on `51ec4fd67` (tranche 1). + +**What shipped:** + +- **D4.** `miso_engine_v1_session_seek(session, generation, timeline_sample)` and + `miso_engine_v1_session_seek_at(session, generation, timeline_sample, anchor_sample)` in + `crates/capi/src/ffi.rs`: one shared body (`session_seek_entry`) with the handle check and the + `SourceFailure::report` mapping around `SessionState::session_seek`. `FEATURE_SESSION_SEEK = 1 << + 10` (1024); the mask is 2047. `masks_and_result_codes_are_frozen` gains the bit in its `features` + array (the mask is the OR of every bit) and pins it; `version_and_capabilities_are_exact` checks + it is reported. No C export of the published generation (not approved). +- **Header.** The two prototypes after `miso_engine_v1_source_seek_report`; the constant and + mask; D5's paragraph, "Session seek (issue 1642)", right after "Anchored seek.", which also + says a host may pick the next generation as one above the largest it has used; the two symbols + in the thread table and in "Control service" (that paragraph rewrapped, no word changed but the + two names). +- **Smoke programs.** `abi_smoke.c` pins the bit and the mask, adds it to + `reports_every_defined_feature`, checks it against the queried mask and calls both symbols + with a null session for `INVALID_ARGUMENT`; `header_smoke.cpp` pins the bit inside the mask and + both signatures. `scripts/check-capi-abi.sh`: the frozen list 19 -> 21 (the self-test is + generic and needs no change). +- **Docs.** `docs/C_ABI_V1_QUALIFICATION.md`: the D5 paragraph (with the fence and the frozen count + 21), and the two calls in the service paragraph's list of session calls. +- **D6.** `tools/audit/src/capi.rs`: before call 5 (`SESSION_SEEK_CALL`, right after call 4's + render applied the anchored seek), outside every render scope, `declare_discontinuity`, then + `session_seek(session, 3, 8 * QUANTUM_FRAMES)`, then one generation-3 quantum of + `fixture-source` from that frame, each asserted `RESULT_OK`. + +**Departures:** + +- **One `engine/realtime` edit (accepted on its one attempt).** + `RealtimePlanOwner::active_plan_mut` in `crates/engine/src/realtime/plan_exchange.rs`, inside + the existing `impl RealtimePlanOwner` (no new impl block), so a capi test can read the active + plan's timeline (`crate::ffi::test_plan_timeline_at`, over `graph::test_only_timeline_at` + through control-plane's test-support `test_owner_mut`). No other reader of the active plan + existed; gate 1 names #1641's reader. It is a borrow for the plan's exclusive owner only. +- **The D5 paragraph sits after "Anchored seek." in the header** as the batch-16 check says; in + the qualification doc it follows the seek-report paragraph (#1316), not the #1275 one, so the + frozen counts there stay in order (15, 17, 19, 21). +- **Test helper.** `crate::ffi::test_session_seek` (the runtime test module forbids `unsafe`). + +**Gates** (`crates/capi/src/runtime/tests.rs`, `runtime::tests::session_seek`): + +1. `both_forms_move_the_timeline_and_every_source_at_one_block`: `session_seek(2, 6_000)` lands + long at 6,000, short at 4,000 and the timeline at 6,000 in one block; `session_seek_at(3, 1_000, + A)` holds all three two blocks and lands them at `A`. +2. `a_stale_generation_changes_nothing`: `session_seek(2, 0)` refuses `source.generation.stale` + and the next block's bits and timeline equal a twin's; a per-source seek to 5 makes a session + seek at 4 stale through the published generation alone (the timeline is at 2), and pushes + nothing. +3. `one_full_slot_refuses_every_consumer`: `BACKPRESSURE` with `source.seek.backpressure`; the + short source's slot and the timeline's stay empty. +4. `a_seek_before_the_swap_reaches_the_carried_and_the_added_source_and_the_timeline`. +5. `a_source_seek_leaves_the_timeline`. +6. `masks_and_result_codes_are_frozen`, `version_and_capabilities_are_exact`, + `check-capi-abi.sh` and its `--self-test`. +7. `audit capi`: 0 violations, `replacements == 1` and the carry assertion hold. `pcm_digest` + moved `4f52089e4a8cf83c` -> `bffc21e857b5e134`: call 5 now plays the generation-3 quantum where + it rendered underrun silence before (the anchored seek's one quantum ran out at call 4). +- **`cargo doc -D warnings` repairs (mechanical, outside the paths).** Four pre-existing + public-doc links to private items: three in `crates/source/src/lib.rs` (`TimelineConsumer`, + `begin_block_at` and `prime_ready`, from #1640/#1641) and one in + `crates/control-plane/src/compile.rs:734`, each turned into a code span. + +**Root ruling B16-R8 (2026-10-16).** `RealtimePlanOwner::active_plan_mut` is gated behind a new +`engine/test-support` feature (`#[cfg(feature = "test-support")]`), because its only caller is test +code. capi's dev-dependency on `engine` turns the feature on. The workspace debug-test and doctest +steps of `qualification.yml` name `engine/test-support`, so `scripts/check-test-support-ci.py` +passes (13 packages). The release `capi` build does not see the accessor. + +## Amendment (root ruling B16-R9, batch 16b review, 2026-10-16) + +- **D5 amended.** Its last two sentences ("A seek of every source is a declared discontinuity: the + host calls `miso_engine_v1_declare_discontinuity` (#1323 D1) first.") are replaced by: "A plain + session seek is a declared discontinuity; the engine declares it in the same call, and a refused + seek declares nothing. An anchored session seek keeps continuity." A seek of every source made + with per-source seeks is still the host's own call, so `miso_engine_v1_declare_discontinuity` + keeps its duty text for that case and for a stop. +- **D1 amended (the core).** `SessionState::session_seek` without an anchor runs, in order: the + service step; `SourceControlSet::check_session_seek` on the newest providers' set (every rule of + `seek_session` except the pushes and the publication); the declaration, through the same core + path as `declare_discontinuity` (all or nothing); then `seek_session` on the newest providers. A + refused check declares nothing and pushes nothing; a refused declaration pushes nothing and + returns its own error. With an anchor nothing is declared: it is the loop and exact-start form + (#1643), and #1323 D2 publishes the successor `Next`, at the next block, before the anchor, so the + effects would go to rest and PDC drop while the old pass still plays. +- **D6 amended.** `audit capi` makes no separate declaration: the session seek declares itself. +- **Gates added.** A refused plain session seek publishes nothing; an accepted one publishes exactly + one discontinuity successor and its seek applies; an anchored one publishes no successor; each on + the C ABI and alike on the browser (the parity row), over #1285's floors (a latent insert removed, + so a declaration has a successor to publish). +- **B15-R7's order is retired** (no host and no SDK calls the declaration before a session seek; + recorded in #1323's and #1643's specs too). + +## Fold (batch 16b review), part B / B16-R9 + +**What shipped.** + +- **Core.** `crates/control-plane/src/control.rs`, inside the existing `impl SessionState` (no new + impl block): `session_seek` returns `Result<(), SessionSeekFailure>`, a new enum with `Seek(SourceFailure)` + and `Declaration(CommandError)`, exported from the crate root. `declare_discontinuity` is now its + service step plus `declare_discontinuity_serviced`, the one core path both calls run. The plain + form checks, declares, then calls `seek_session` on the newest providers; a refusal of that push + maps to `SourceFailure::Control(Chunk(InternalInvariant))` behind a `debug_assert`. The comment + says why the set is the one checked: the discontinuity successor carries every source of the + committed model, and its commit moves each producer from the set it supersedes (the checked + donation, then `adopt_persisting` for the persisting producers and the timeline), with no push in + between. +- **C ABI.** `session_seek_entry` maps `Seek` by `SourceFailure::report` and `Declaration` by the new + `declaration_refused`, the mapping `miso_engine_v1_declare_discontinuity` already used (now shared), + so the result codes stay consistent. Header: the session seek paragraph states B16-R9 and gains + the full refusal list (MINOR a: `source.seek.anchor_unaligned` for `_at`, `source.ring.vacated`, + `INTERNAL` with `capi.source.epoch` or `source.internal`, and the declaration's refusals for the + plain form); the declared-discontinuity paragraph's duty is now "at a stop, and before a seek of + every source made with per-source seeks". `docs/C_ABI_V1_QUALIFICATION.md`: the same text, a new + B16-R9 paragraph, the #1323 duty line, and MINOR b ("at or below which"). +- **Browser.** `ReadyOwnership::session_seek` runs the plain form through `run_control` (made generic + over its result and error, with the refusal for a full table queue passed in), so a plan the + declaration publishes keeps its tables and counts as a blocking rebuild, as + `declare_discontinuity`'s does; the anchored form calls the core directly. A refused + declaration reports through `refuse_control`, as the browser's declaration does. The exports pass + the spectrum promotion analysis, as `declare_discontinuity`'s export does. +- **Audit (D6).** `tools/audit/src/capi.rs` no longer calls `miso_engine_v1_declare_discontinuity`. + `pcm_digest` `bffc21e857b5e134`, unchanged: the audit session has no raised floor and no + source-read offset, so the declaration publishes nothing both before and after (the separate call + and the seek's own declaration are both no-ops there); 0 violations, `replacements == 1` holds. +- **MAJOR 1 (host part).** `runtime::tests::session_seek::a_removed_sources_generation_keeps_a_session_seek_stale`: + a per-source seek of the short source to 10, a transaction that removes it, the swap, then a + session seek at 5 is `source.generation.stale` and pushes nothing; 11 is accepted. The false + comments in `a_stale_generation_changes_nothing` (capi and host-web: the long source's own + generation refuses that case too) are corrected. + +**Gates (each red with its rule removed).** + +1. A refused plain seek publishes nothing: `capi` `runtime::tests::session_seek_declares::a_refused_plain_session_seek_publishes_nothing` + (red when the declaration runs before the check). +2. An accepted plain seek publishes one successor and applies: `...::an_accepted_plain_session_seek_publishes_one_successor_and_applies` + (after the swap and while the removal's candidate waits; red with the declaration removed). +3. An anchored seek publishes none: `...::an_anchored_session_seek_publishes_no_successor` (red when + the anchored form declares). +4. Both hosts alike: `control_parity::a_plain_session_seek_declares_and_an_anchored_one_does_not_alike_on_both_hosts` + (48 and 96 kHz; red under each of the three mutations). +5. The headless SDK: `sdk/test/capability-evals.mjs` (see #1643's record). + +The floors come from #1285's path: the limited session (a muted track behind a true-peak limiter, +removed by a transaction) on the C ABI, and t3's limiter removed on the parity session. + +### Fold (batch 16b review), part A + +The unfenced mode is gone (finding M2). The session's published generation is now a required argument of `PcmSourceRing::prepare_host_region`, `SourceTimeline::prepare`/`prepare_vacant` and `prepare_session_graph_source_set`; `prepare_graph_source_set` and the `u64::MAX` sentinel are removed. Every producer publishes to its word. Every consumer and timeline holds the word and loads it at each block entry; the driver loads it once for the whole set. `PcmSourceRing::prepare(config)` stays: it gives the ring a word of its own, so the ring is fenced as a session of one. A set refuses a timeline or occupied consumer of another word (`SourceGraphSourceSetError::SessionWord`), and a carry or swap between sets of different words is refused. The only push without a publish is the source-crate `SessionSeek` (`PublishedGeneration::session_seek`), which owns the zero and stale rules and checks that each producer is of its word (`SessionSeekError::ForeignSession`). It publishes once, on `publish()` or drop, and only if something was pushed. host-core's `seek_session` uses it, and the new `check_session_seek` applies the same rules with nothing pushed or published; part B calls it before it declares the discontinuity. For M1, host-core `source::tests::a_generation_not_above_the_word_is_stale_when_every_producer_takes_it` (word at 10, seek at 5 is stale while every producer would take it) is red with the stale rule disabled, when every other host-core source test stays green. `published::a_session_seek_opens_above_its_word_and_pushes_only_its_own_producers` and `published::a_set_takes_only_consumers_of_its_session` are red under their mutations. Render stays allocation-, lock- and syscall-free: 153 policy regions; capi `pcm_digest` `bffc21e857b5e134`. + +Fold 3 (batch 16b fold verification): `session_seek_declares::a_refused_declaration_pushes_nothing_and_leaves_the_generation_free` gates the refused-declaration rule; PcmSourceRing::prepare(config) stays ungated because no host path reaches it (only crates/source integration tests: prime.rs, randomized.rs, seek_schedule_model.rs), root ruling B16-R10's condition. diff --git a/.github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md b/.github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md index e57780b7f..2f898abdc 100644 --- a/.github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md +++ b/.github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md @@ -256,3 +256,136 @@ exports and the C13 list, the SDK-chosen generation (D2), and a parity row in and the timeline (#1641's host-core test reader) reads the same value on both hosts. Red if either host splits the seek across blocks, clamps differently or drops the lateness. It needs #1642's C export. + +## Decision record: #1643 (batch 16) + +Built on `5567f7487` (worktree `codex/controls-batch-16b`). + +**What shipped.** +- **D1.** `miso_engine_web_v1_session_seek(handle, generation, frame)` and + `miso_engine_web_v1_session_seek_at(handle, generation, frame, anchor)` in + `hosts/host-web/src/ffi.rs`, one shared body `AudioWorkletEngineHost::session_seek(generation, + frame, Option)` in `lib.rs`: it calls `SessionState::session_seek` (the C ABI's call, + after its service step) and maps a refusal through `source_failure_result`. host-web holds no seek + logic and never calls `prepare_source_seek`. A host that is not ready refuses with + `RESULT_REFUSED_LIFECYCLE` and changes nothing; both exports run the service step, so the page + calls them from the message handler only (neither is `render_locked`). +- **D2.** `OfflineEngine.seek(frame, options?) -> { generation } & EngineCallResult` + (`sdk/src/headless/engine.ts`) and `WasmBoundary.declareDiscontinuity()` / `sessionSeek()` + (`sdk/src/core/boundary.ts`). `sessionSeek` calls `miso_engine_web_v1_declare_discontinuity` + first (B15-R7), stops at its refusal, then calls the session seek export. The generation is + `options.generation`, else one above the largest this object has passed (starts at 1). The + counter (`#highestGeneration`) is the only state added; `seek`, `seekSource` and `submitSource` + raise it on an `ok` result only, so a refusal (backpressure, stale) never moves it. +- **D3.** Mirrors: `check-web-audioworklet.sh` `expected_exports`, `abi_layout.rs` `EXPORTS` (108 -> + 110), `check-abi-layout-v1.py` `EXPORTS` and the fixture, `sdk/assets/miso-engine-v1-abi-layout.json` + and `sdk/src/generated/abi.ts` (regenerated with `node codegen/assets.mjs` and `node + codegen/generate.mjs`). The ABI self-test gains two mutations (each new export dropped): 36 + caught, from 34. The test-worklet mock needed no edit. + +**Departures.** +- **Test-support plumbing outside the listed paths** (mechanical): `hosts/host-web/Cargo.toml` + `test-support` now also names `control-plane/test-support` and `engine/test-support`, so a native + host-web test reaches the active plan's timeline through `PlanState::test_owner_mut` and + `RealtimePlanOwner::active_plan_mut` (B16-R8) and `host_core::test_only_timeline_at`: + `AudioWorkletEngineHost::timeline_at_for_test` and `native_staging::timeline_at`, both + `cfg(feature = "test-support")`. The workspace test steps already name both features + (`check-test-support-ci.py` passes, 13 packages). No file under `crates/engine/src/realtime/` + changed. +- **Gate 6: the C ABI's timeline is not read.** `capi::Plan` and `Session` expose no accessor + outside the crate (`test_plan_timeline_at` is `cfg(test)` and crate-private), and a new public + capi test hook is not mechanical. The parity row therefore compares the browser's and the C + ABI's seek reports of all three sources field by field (at the call, after every block through + the applying one and after), the output bits, and reads the browser's timeline: it moves in the + applying block, runs on one quantum per block, and in the late seek lands on the frame the long + sources' reports (equal on both hosts) name. The C ABI's timeline is pinned by capi's own gate + (`runtime::tests::session_seek`). The row is `cfg(feature = "test-support")`. +- **Gate 1/6: the short source is not fed at its clamped frame.** A source clamped to its end has + no frame to submit there (a chunk from the end is `RESULT_INVALID_ARGUMENT`), so the long source + is fed and the short source underruns; its report still names the clamped frame. +- **Gate 1's full-slot case** holds the slot with an anchored `source_seek_at` (which, unlike the + plain `source_seek`, does not prepare the consumer and so does not drain the slot). +- **A refused session seek still declares the discontinuity first** (the SDK holds no + generation state beyond its counter, so it cannot know the core would refuse). The declaration + resets latency floors and publishes nothing else; it is the C ABI host's order. +- **Gate 4** (no rendered bit moves) is the unchanged `browser` job digests; this slice adds no + render code and no worklet call. Not run here (CI job). + +**Gates -> tests.** +- Gate 1 (`hosts/host-web/src/tests.rs`, `tests::session_seek`): + `both_forms_move_the_timeline_and_every_source_at_one_block` (plain seek at 2/6,000: long at + 6,000, short at 4,000, timeline 6,000 in one block; anchored seek held two blocks then landing + at the anchor), `a_stale_generation_changes_nothing` (next block bits and timeline equal a run + without the call; a per-source seek to 5 makes a session seek at 4 stale), + `one_full_slot_refuses_every_consumer` (`RESULT_BACKPRESSURE`; the short source's slot and the + timeline stay untouched). All call the `extern "C"` exports. +- Gate 2 (`sdk/test/capability-evals.mjs`): "session seek declares the discontinuity first and moves + the sources in the next block" (export call order `declare_discontinuity`, `session_seek`; output + equals a `seekSource` twin and is not silent; default generations 2, 3, 10, 13, 14; a stale + explicit generation is `invalidArgument`; usage errors reach no export). +- Gate 3: `check-web-audioworklet.sh` (compares the wasm's exports with `expected_exports`), + `check-abi-layout-v1.py` and `--self-test` (36 mutations), `check-sdk-generated.sh`. +- Gate 3a (`tests::session_seek`): + `the_session_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing` and + `a_seek_before_the_swap_reaches_the_carried_and_the_added_source_and_the_timeline` (the + transaction goes through `native_staging::control_command`, the `control_command` export; the + seek before the swap reaches the carried source, the added source and the timeline in the swap + block, active epoch 0 -> 1). +- Gate 6 (`hosts/host-web/tests/control_parity.rs`): + `the_session_seek_is_alike_on_both_hosts_for_plain_and_late_seeks` (48 and 96 kHz). + +**Commands run (all pass).** `cargo test --locked -p host-web --features host-web/test-support` +(lib 145 passed, control_parity 20 passed, every other binary green); `cargo clippy --locked -p +host-web -p parameter-metadata --all-targets --all-features -- -D warnings`; `cargo fmt --all -- +--check`; `RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps -p host-web --all-features`; +`build-web-audioworklet.sh --named-twin`; `check-web-audioworklet.sh`; `test-web-audioworklet.sh`; +`check-sdk-generated.sh`, `check-sdk-types.sh`, `check-sdk-headless.sh` (565 SDK tests pass); +`check-abi-layout-v1.py` (document and `--self-test`); `check-session-map-shape.py` (and +`--self-test`); `check-cross-targets.sh` (PASS, no new `memset_pattern16` call); +`check-workspace-policy.sh`; `check-test-support-ci.py`; `cargo run -p realtime-policy` (153 +regions, unchanged). The workspace-wide clippy runs at the batch gates. + +**Artifact.** `miso-engine-v1-audio-worklet.simd128.wasm`: 4,207,840 bytes, sha256 +`2fc4d8aaa1b9fb49f18c71cc8f2e0fc8fd1f096b96324e86bc6312e64bf92a74` (named twin 4,830,433 bytes, +`f6cb1e2c5d2a5d1ea88327d6331c2473ff7eeaa5d5f75eff550a8b19a2521b01`). The committed pin is not +re-pinned. + +**Successors.** (1) A capi test hook that reads the active plan's timeline would let the parity row +compare both timelines directly. (2) #1644 (browser SDK `seek` and the PCM feed's ring rule) is the +next caller of the exports. + +## Amendment (root ruling B16-R9, batch 16b review, 2026-10-16) + +- **B15-R7's order is retired.** The headless `seek` (D2) and `WasmBoundary.sessionSeek` no longer + call `miso_engine_web_v1_declare_discontinuity`: a plain session seek is a declared discontinuity, + and the engine declares it in the same call, after every check, so a refused seek declares + nothing. An anchored session seek keeps continuity and declares nothing (the loop and exact-start + form; README A1.2). `WasmBoundary.declareDiscontinuity()` stays for a stop. +- **Gate 2 amended.** It asserts the SDK does not call the declaration before a session seek (the + call log is `["session_seek"]`), and a new eval holds the B16-R9 rules on the headless path. + +## Fold (batch 16b review), part B / B16-R9 + +- **SDK.** `sdk/src/core/boundary.ts` `sessionSeek` and `sdk/src/headless/engine.ts` `seek`: no + declaration call; the doc comments say the engine declares in the same call. The browser export + (`miso_engine_web_v1_session_seek`) runs the plain form through the host's `run_control`, so a + plan the declaration publishes keeps its tables and counts as a blocking rebuild; the anchored + export declares nothing. +- **Evals (`sdk/test/capability-evals.mjs`).** `session seek calls the session seek export alone and + moves the sources in the next block` (gate 2, the call log is `["session_seek"]`; red with the + SDK's declaration restored) and `a plain session seek declares the discontinuity itself, and a + refused one declares nothing` (B16-R9 gate 4 on the parity session: t3's limiter removed through + the new `remove_t3_limiter` structural frame, so #1285 floors hold the latency; a stale plain seek + keeps the floors, an accepted one resets them, and no separate declaration is called; red with + the SDK's declaration restored, and red against an artifact built with the core's declaration + removed). The headless SDK has no anchored session seek, so rule 3 is held on the C ABI and the + parity row only. +- **Fixture.** `hosts/host-web/tests/structural_frames_fixture.rs` writes one more frame, + `remove_t3_limiter` (`RemoveTrackEffect` of t3's limiter), into + `hosts/host-web/tests/fixtures/structural-frames.json` (blessed; the byte gate passes). +- **Browser tests.** `tests::session_seek::the_session_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing` + now asserts no seek waits in a slot after the refusals (`native_staging::session_seek_slots_empty`, + test-support; MINOR f; red when the export pushes before its lifecycle refusal); its doc says the + failed host keeps its control state. The `one_full_slot_refuses_every_consumer` comment is + corrected (MINOR e: the slot frees at the next block's pop; it is full only because no block + renders between the calls). diff --git a/.github/ISSUE_SPECS/1644-seek-the-session-from-the-browser-sdk.md b/.github/ISSUE_SPECS/1644-seek-the-session-from-the-browser-sdk.md index a0d939103..bae2dd99d 100644 --- a/.github/ISSUE_SPECS/1644-seek-the-session-from-the-browser-sdk.md +++ b/.github/ISSUE_SPECS/1644-seek-the-session-from-the-browser-sdk.md @@ -238,3 +238,16 @@ Checked against `12d4a4f9d`. refused declaration calls no seek export. - **Dependencies** gain #1323 (the export and its mirrors). Authorized paths do not change: the call is in D1's handler. + +## Amendment (batch 16b review fold, root ruling B16-R9, 2026-10-16) + +- **This slice is the caller of `miso_engine_web_v1_session_seek` and + `miso_engine_web_v1_session_seek_at`** (#1643 D1; MINOR d): D1's handler calls the plain export + for a seek and the anchored export for a seek with an anchor (the loop and exact-start form). + No other SDK file calls the anchored export today. +- **"Discontinuity first (B15-R7 reach)" (batch 15a fold) is retired.** A plain session seek + declares the discontinuity in the same call, and a refused seek declares nothing; an anchored + session seek keeps continuity. D1's handler calls the session seek export alone, never + `miso_engine_web_v1_declare_discontinuity` before it. Gate 2's added rows become: the handler + calls no declaration export for a session seek message, once per message. +- **Dependencies.** #1323 is no longer one for this slice. diff --git a/.github/ISSUE_SPECS/1706-report-source-refusal-diagnostics-on-the-browser.md b/.github/ISSUE_SPECS/1706-report-source-refusal-diagnostics-on-the-browser.md new file mode 100644 index 000000000..760aa6293 --- /dev/null +++ b/.github/ISSUE_SPECS/1706-report-source-refusal-diagnostics-on-the-browser.md @@ -0,0 +1,38 @@ +# Report source refusal diagnostics on the browser as on the C ABI + +Filed by root from the batch 16 #1293 review (MINOR 5). It is the "source submit +diagnostics" one-host difference that decision 17 C13 lists +(`docs/rulings/one-control-abstraction-2026-10-13.md`, C13). + +## Product outcome + +A browser host that is refused a source call (submit, seek, anchored seek, session seek or seek +report) reads the same typed diagnostic as a C host: for example `source.id.unknown`, +`source.seek.anchor_unaligned`, `source.generation.stale`, `source.seek.backpressure`, +`source.report.busy` or `capi.source.epoch`'s browser twin. One core function owns the mapping from +a `SourceFailure` to its result kind and diagnostic; both hosts call it. + +## Context (anchors on `5567f7487`) + +- The C ABI maps `SourceFailure` to `(result code, diagnostic bytes)` in + `crates/capi/src/runtime/error.rs:121-143` and writes the diagnostic to + `miso_engine_v1_last_error`. +- The browser maps the same failure in `hosts/host-web/src/lib.rs:5338-5356` + (`source_result`, `source_failure_result`) to a result code only, and drops the core's + diagnostic. The result-code rule is written twice, once per host: logic in two adapters. +- The core's text is `SourceControlError::diagnostic` (`crates/host-core/src/source.rs`). + +## Decisions to make at filing + +- D1. One core mapping, in `crates/control-plane` beside `SourceFailure` (or in host-core): + `SourceFailure::report() -> (SourceResultKind, &'static str)` (kinds: ok-refusal invalid + argument, backpressure, internal). capi and host-web map the kind to their result codes and + write the text to their diagnostic buffers; neither holds the rule. +- D2. The browser writes the diagnostic where `AudioWorkletEngineHost::diagnostic` is read today, + for every source export, and the SDK surfaces it as it surfaces other refusals. +- D3. Parity row in `hosts/host-web/tests/control_parity.rs`: each refusal kind of each source call + gives the same result kind and the same diagnostic on both hosts. + +## Dependencies + +- None blocking. #1644 (browser SDK `seek`) benefits from it. diff --git a/.github/ISSUE_SPECS/1707-ask-for-a-control-queue-slot-only-when-a-declared-discontinuity-publishes.md b/.github/ISSUE_SPECS/1707-ask-for-a-control-queue-slot-only-when-a-declared-discontinuity-publishes.md new file mode 100644 index 000000000..e88e876ab --- /dev/null +++ b/.github/ISSUE_SPECS/1707-ask-for-a-control-queue-slot-only-when-a-declared-discontinuity-publishes.md @@ -0,0 +1,44 @@ +# Ask for a control queue slot only when a declared discontinuity publishes + +Filed by root from ruling B16-R10 (batch 16b, 2026-10-16). + +## Product outcome + +A declared discontinuity that publishes nothing never refuses with backpressure on either host. +That covers the standalone declaration (`miso_engine_v1_declare_discontinuity`, +`miso_engine_web_v1_declare_discontinuity`) and the plain session seek, which declares one in the +same call (B16-R9). Only a declaration that publishes a successor asks for room in a host's +control queue, and the core decides whether it publishes before any host asks for that room. + +## Context (anchors on `cebbf0df4`) + +- The core decides whether a discontinuity successor is needed with #1323 D2's two terms: a floor + above a node's natural arrival, or a source-read offset above 0 + (`PlanStateInventory::runs_behind_natural_timing`, `crates/host-core/src/prepare.rs:1022`). + When neither holds, `SessionState::declare_discontinuity` (`crates/control-plane/src/control.rs:2154`, + serviced body `:2161`) returns `Ok` and publishes nothing. +- The browser runs both calls through `AudioWorkletEngineHost::run_control` + (`hosts/host-web/src/lib.rs:1737`). It refuses with its `full` error when the pending-shapes table + queue is full (`:1745-1747`), before the core runs, so before the core knows whether anything + would publish. The declaration (`:1695`) and the plain session seek (`:1713`, `full` = + `SessionSeekFailure::Declaration(CommandError::Backpressure)`) both inherit that refusal. +- The C ABI has no such queue, so the same call returns `OK` there. That is a one-host difference + (decision 17 C1: browser and C ABI behave the same). + +## Decisions to make at filing + +- D1. The core exposes the decision before the publish: for example a `SessionState` query + "would a declaration publish now?" (the two #1323 D2 terms on the newest plan), or a + declaration API that takes the host's room as a parameter and refuses with backpressure only when + it would publish. Pick one; one core rule, both hosts. +- D2. `run_control` asks for a slot only when the core says the call publishes; a declaration or + plain session seek that publishes nothing returns `OK` on a full queue, as on the C ABI. +- D3. Parity row in `hosts/host-web/tests/control_parity.rs`: with the browser's table queue full, + (a) a standalone declaration and (b) a plain session seek that publish nothing return `OK` on + both hosts and the seek applies; (c) ones that would publish (floor raised on the #1285 path) + refuse with backpressure on the browser and nothing changes. Red if the browser asks for the slot + first again. + +## Dependencies + +- #1642 and #1643 (B16-R9's plain session seek). diff --git a/.github/workflows/qualification.yml b/.github/workflows/qualification.yml index 2c0dfe3a9..fe2649270 100644 --- a/.github/workflows/qualification.yml +++ b/.github/workflows/qualification.yml @@ -634,7 +634,7 @@ jobs: --exclude conformance --exclude audit --exclude bench --exclude console-workload \ --exclude wasm-gates --exclude wasm-gate-guest \ --exclude wasm-gate-corpus \ - --features builtins-compiler/test-support,graph/test-support,host-web/test-support,host-core/test-support,effect-compiler/test-support,protocol/test-support,control-plane/test-support,engine/realtime-audit + --features builtins-compiler/test-support,graph/test-support,host-web/test-support,host-core/test-support,effect-compiler/test-support,protocol/test-support,control-plane/test-support,engine/test-support,engine/realtime-audit # --all-targets runs no doctest, and most doctests here are compile_fail type-level # guarantees, each beside a plain twin (#1422). Same packages and features as the step above. - name: Workspace doctests (same packages and features as the debug tests) @@ -647,7 +647,7 @@ jobs: --exclude conformance --exclude audit --exclude bench --exclude console-workload \ --exclude wasm-gates --exclude wasm-gate-guest \ --exclude wasm-gate-corpus \ - --features builtins-compiler/test-support,graph/test-support,host-web/test-support,host-core/test-support,effect-compiler/test-support,protocol/test-support,control-plane/test-support,engine/realtime-audit + --features builtins-compiler/test-support,graph/test-support,host-web/test-support,host-core/test-support,effect-compiler/test-support,protocol/test-support,control-plane/test-support,engine/test-support,engine/realtime-audit test-debug-b: name: DSP crates debug tests diff --git a/crates/capi/Cargo.toml b/crates/capi/Cargo.toml index 1e9d2edc6..05c8239ed 100644 --- a/crates/capi/Cargo.toml +++ b/crates/capi/Cargo.toml @@ -28,6 +28,9 @@ builtins = { workspace = true, features = ["test-support"] } # #1309 D8: the control plane's observers and fault hooks, for capi's tests only. A dev-dependency # feature never reaches the release `capi` build (`scripts/check-capi-abi.sh` builds `-p capi`). control-plane = { workspace = true, features = ["test-support"] } +# B16-R8: `RealtimePlanOwner::active_plan_mut`, so the #1642 gates read the active plan's +# timeline. A dev-dependency feature never reaches the release `capi` build. +engine = { workspace = true, features = ["test-support"] } # #1639: the block's held jump lengths (`graph::test_only_block_jump_lengths`), for capi's tests only. graph = { workspace = true, features = ["test-support"] } # The tests write control values by row name (`control_core::{row_address, fader_edits, ...}`). diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index 0b762ba57..eeed91999 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -19,7 +19,8 @@ extern "C" { * miso_engine_v1_source_submit_planar_f32, miso_engine_v1_source_seek, * miso_engine_v1_source_seek_at, miso_engine_v1_source_seek_report, * miso_engine_v1_submit_command, miso_engine_v1_dequeue_event, - * miso_engine_v1_service, miso_engine_v1_declare_discontinuity, or + * miso_engine_v1_service, miso_engine_v1_declare_discontinuity, + * miso_engine_v1_session_seek, miso_engine_v1_session_seek_at, or * miso_engine_v1_last_error on the session; they are serialized with each other. * plan Split ownership. * miso_engine_v1_render_f32_planar: render thread only, never concurrently with @@ -95,8 +96,11 @@ extern "C" { * Declared discontinuity (issue 1323). miso_engine_v1_declare_discontinuity(session) says that * the next block the host renders need not continue the previous output, because the host has * stopped or is about to seek every source. The host's duty: call it at a stop, and before a seek - * of every source. If the newest plan (the waiting replacement, else the plan that renders) runs - * behind its natural timing because a floor above raised some node's arrival, the call prepares + * of every source made with per-source seeks. A plain session seek is a declared discontinuity: + * the engine declares it in the same call (see "Session seek"), so the host does not call this + * before it; an anchored session seek keeps continuity. If the newest plan (the waiting + * replacement, else the plan that renders) runs behind its natural timing because a floor above + * raised some node's arrival, the call prepares * the committed session again with no floors and publishes it for the next block: from that * block latency_samples is the session's natural latency. That plan carries only the sources * (each source's ring, generation and read position), so no frame is repeated or skipped; every @@ -179,20 +183,20 @@ extern "C" { * between edits. miso_engine_v1_service(session) runs one bounded step of it: it reclaims plans * render has retired, brings the session's view of the running plan up to render, refreshes the * session counters, and stages render telemetry. Every other session call (source submit, seek, - * seek_at, seek_report, submit_command, dequeue_event) that passes its argument checks runs the - * same step first; a call refused for its arguments returns before the step. So a host that makes - * those calls anyway need not also call service. The step never waits for render, never commits or - * acknowledges anything, and never takes or drops a pending edit; its cost does not grow with the - * time since the last call. It returns MISO_ENGINE_V1_OK; a null session as - * MISO_ENGINE_V1_INVALID_ARGUMENT and another handle as MISO_ENGINE_V1_WRONG_HANDLE; and, should - * the session's plan epochs fail to synchronize, MISO_ENGINE_V1_INTERNAL with the session - * diagnostic "capi.source.epoch". The host's duty: a pending edit progresses only while the host - * makes session calls, the same duty as draining events. A host that renders but never calls keeps - * retired plans allocated, and its next structural edit waits for that reclaim; a warm successor - * that misses its deadline falls back only at the host's next session call. The plan watermark - * never covers an edit before it is in effect. While edits are pending, call miso_engine_v1_service - * (or another session call) at least once per render-buffer period. A host checks - * MISO_ENGINE_V1_FEATURE_SERVICE before calling the symbol. + * seek_at, seek_report, session_seek, session_seek_at, submit_command, dequeue_event) that passes + * its argument checks runs the same step first; a call refused for its arguments returns before the + * step. So a host that makes those calls anyway need not also call service. The step never waits + * for render, never commits or acknowledges anything, and never takes or drops a pending edit; its + * cost does not grow with the time since the last call. It returns MISO_ENGINE_V1_OK; a null + * session as MISO_ENGINE_V1_INVALID_ARGUMENT and another handle as MISO_ENGINE_V1_WRONG_HANDLE; + * and, should the session's plan epochs fail to synchronize, MISO_ENGINE_V1_INTERNAL with the + * session diagnostic "capi.source.epoch". The host's duty: a pending edit progresses only while the + * host makes session calls, the same duty as draining events. A host that renders but never calls + * keeps retired plans allocated, and its next structural edit waits for that reclaim; a warm + * successor that misses its deadline falls back only at the host's next session call. The plan + * watermark never covers an edit before it is in effect. While edits are pending, call + * miso_engine_v1_service (or another session call) at least once per render-buffer period. A host + * checks MISO_ENGINE_V1_FEATURE_SERVICE before calling the symbol. * * Engine memory (issue 1602). The engine owns no control-thread memory outside the compile * limits beyond its own handle; miso_engine_v1_engine_destroy frees that handle. Everything a @@ -298,6 +302,35 @@ extern "C" { * F before the block at A renders. A host checks MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_AT before * calling it. * + * Session seek (issue 1642). The timeline is the session's playhead: it starts at 0 and advances + * one quantum per render, and stored automation follows it. miso_engine_v1_session_seek(session, + * g, T) moves it and every source of the newest committed session in one step, all or nothing: + * the timeline goes to T and each source to min(T, its frames); every one of them takes the seek + * in the same render block, or the call changes nothing and returns a refusal. The host then + * submits generation g of each source from its frame. g must be above every generation the host + * has used on the session, on any source or session seek; one above the largest it has used is + * always valid. The refusals, all MISO_ENGINE_V1_INVALID_ARGUMENT unless named: g = 0 + * ("source.generation.zero"), g not above every generation the session has used + * ("source.generation.stale"), T over the timeline limit ("source.seek.frame_out_of_range"), a + * source whose producer the newest session does not hold yet ("source.ring.vacated"), and + * MISO_ENGINE_V1_BACKPRESSURE with "source.seek.backpressure" while any source or the timeline + * still holds an unpopped seek; MISO_ENGINE_V1_INTERNAL with "capi.source.epoch" when the + * session's plan epochs cannot be synchronized, or with "source.internal" for an engine invariant. + * miso_engine_v1_session_seek_at(session, g, T, A) is the anchored form, with the anchor of + * miso_engine_v1_source_seek_at: every consumer changes at the block at A, or, reached late, at + * its frame plus the lateness; an A that is not a multiple of the quantum is + * MISO_ENGINE_V1_INVALID_ARGUMENT with "source.seek.anchor_unaligned". A per-source seek leaves + * the timeline where it is. A plain session seek is a declared discontinuity; the engine declares + * it in the same call, and a refused seek declares nothing: every check runs first, then the + * declaration (miso_engine_v1_declare_discontinuity's, all or nothing), then the seek. A refused + * declaration changes nothing and returns its own result and diagnostic, as + * miso_engine_v1_declare_discontinuity returns them (MISO_ENGINE_V1_COMPILE_REJECTED, + * MISO_ENGINE_V1_BACKPRESSURE or MISO_ENGINE_V1_INTERNAL). An anchored session seek keeps + * continuity: it declares nothing, so its effect tails and latency floors stay (it is the loop + * and exact-start form); a host that wants the floors reset declares at a stop. A seek of every + * source made with per-source seeks is still the host's own call, and the host declares before + * it. A host checks MISO_ENGINE_V1_FEATURE_SESSION_SEEK before calling either symbol. + * * Choosing the anchor. To start a stem that a transaction added, after the command returns OK: * pick A, a quantum multiple past the last rendered block by enough quanta to cover the time the * host needs to decode generation g from F and submit it (a few quanta); call @@ -425,7 +458,8 @@ extern "C" { #define MISO_ENGINE_V1_FEATURE_SERVICE UINT64_C(128) #define MISO_ENGINE_V1_FEATURE_DECLARE_DISCONTINUITY UINT64_C(256) #define MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_REPORT UINT64_C(512) -#define MISO_ENGINE_V1_FEATURE_MASK UINT64_C(1023) +#define MISO_ENGINE_V1_FEATURE_SESSION_SEEK UINT64_C(1024) +#define MISO_ENGINE_V1_FEATURE_MASK UINT64_C(2047) #define MISO_ENGINE_V1_OUTCOME_EXACT UINT64_C(1) #define MISO_ENGINE_V1_OUTCOME_TRANSITION_FALLBACK UINT64_C(2) @@ -641,6 +675,13 @@ uint32_t miso_engine_v1_source_seek_report(miso_engine_v1_session *session, const uint8_t *source_id, uint64_t source_id_bytes, miso_engine_v1_seek_report *out); +uint32_t miso_engine_v1_session_seek(miso_engine_v1_session *session, + uint64_t generation, + uint64_t timeline_sample); +uint32_t miso_engine_v1_session_seek_at(miso_engine_v1_session *session, + uint64_t generation, + uint64_t timeline_sample, + uint64_t anchor_sample); uint32_t miso_engine_v1_submit_command(miso_engine_v1_session *session, const uint8_t *request, uint64_t request_bytes, diff --git a/crates/capi/src/abi.rs b/crates/capi/src/abi.rs index 5010f26ea..f6c63743d 100644 --- a/crates/capi/src/abi.rs +++ b/crates/capi/src/abi.rs @@ -74,6 +74,9 @@ pub const FEATURE_DECLARE_DISCONTINUITY: u64 = 1 << 8; /// Source seek-report capability: `miso_engine_v1_source_seek_report` (#1316), an in-place V1 /// amendment. A host checks this bit before calling the symbol. pub const FEATURE_SOURCE_SEEK_REPORT: u64 = 1 << 9; +/// Session-seek capability: `miso_engine_v1_session_seek` and `miso_engine_v1_session_seek_at` +/// (#1642), an in-place V1 amendment. A host checks this bit before calling either symbol. +pub const FEATURE_SESSION_SEEK: u64 = 1 << 10; /// All ABI V1 feature capability bits. pub const FEATURE_MASK: u64 = FEATURE_IMMUTABLE_SESSION | FEATURE_HOST_PLANAR_SOURCE @@ -84,7 +87,8 @@ pub const FEATURE_MASK: u64 = FEATURE_IMMUTABLE_SESSION | FEATURE_PLAN_WATERMARK | FEATURE_SERVICE | FEATURE_DECLARE_DISCONTINUITY - | FEATURE_SOURCE_SEEK_REPORT; + | FEATURE_SOURCE_SEEK_REPORT + | FEATURE_SESSION_SEEK; /// Watermark outcome flag: the covered revisions completed exactly as committed. pub const OUTCOME_EXACT: u64 = 1; @@ -608,6 +612,7 @@ mod tests { FEATURE_SERVICE, FEATURE_DECLARE_DISCONTINUITY, FEATURE_SOURCE_SEEK_REPORT, + FEATURE_SESSION_SEEK, ]; assert!(features.iter().all(|feature| feature.count_ones() == 1)); assert_eq!( @@ -627,6 +632,9 @@ mod tests { ); assert_eq!(FEATURE_DECLARE_DISCONTINUITY, 1 << 8); assert_eq!(FEATURE_SOURCE_SEEK_REPORT, 1 << 9); + // #1642 D4: the next free bit, never a reused one. + assert_eq!(FEATURE_SESSION_SEEK, 1 << 10); + assert_eq!(FEATURE_MASK & FEATURE_SESSION_SEEK, FEATURE_SESSION_SEEK); assert_eq!( [ OUTCOME_EXACT, diff --git a/crates/capi/src/ffi.rs b/crates/capi/src/ffi.rs index d29a7dee0..1fd394f8e 100644 --- a/crates/capi/src/ffi.rs +++ b/crates/capi/src/ffi.rs @@ -18,7 +18,7 @@ use lane::fpenv::CanonicalFpEnv; use std::panic::{AssertUnwindSafe, catch_unwind}; use crate::runtime::{ - CommandError, EventError, EventLane, PlanQueries, PlanState, SourceFailure, + CommandError, EventError, EventLane, PlanQueries, PlanState, SessionSeekFailure, SourceFailure, SourceFailureReport, compile_children, limits_are_valid, plan_error, render_error_code, }; @@ -627,6 +627,109 @@ unsafe fn source_seek_entry( }) } +/// Seek the session's timeline and every source of the newest committed session to timeline +/// sample `timeline_sample`, under one new `generation`, all or nothing (#1642 D4). +/// +/// The timeline goes to `timeline_sample`; each source to `min(timeline_sample, its frames)`. +/// Every consumer takes the seek in the same render block, or none does. The host then submits +/// generation `generation` of each source from its frame. `generation` must be above every +/// generation the host has used on this session (one above the largest is always valid). +/// +/// A plain session seek is a declared discontinuity (root ruling B16-R9): the engine declares it in +/// the same call, after every check and before any push, through the core path of +/// [`miso_engine_v1_declare_discontinuity`]; a refused seek declares nothing. So a host never +/// declares before a session seek. The anchored form ([`miso_engine_v1_session_seek_at`]) keeps +/// continuity and declares nothing. +/// +/// Runs the service step first, as every session call does. Returns `RESULT_OK`; +/// `RESULT_INVALID_ARGUMENT` for a zero (`source.generation.zero`) or stale +/// (`source.generation.stale`) generation or a timeline sample over the limit +/// (`source.seek.frame_out_of_range`); `RESULT_BACKPRESSURE` with `source.seek.backpressure` when +/// any consumer still holds a seek (nothing changed; retry after a render); otherwise the source +/// seek's mapping (`RESULT_INVALID_ARGUMENT` with `source.ring.vacated`, `RESULT_INTERNAL` with +/// `capi.source.epoch` or `source.internal`); and a refused declaration's result and diagnostic, +/// as [`miso_engine_v1_declare_discontinuity`] returns them, with nothing pushed. The diagnostic is +/// in `miso_engine_v1_last_error`. +/// +/// # Safety +/// +/// `session` must be live. +/// +/// Thread: control. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miso_engine_v1_session_seek( + session: *mut Session, + generation: u64, + timeline_sample: u64, +) -> u32 { + // SAFETY: The caller's contract is this entry point's, forwarded unchanged. + unsafe { session_seek_entry(session, generation, timeline_sample, None) } +} + +/// [`miso_engine_v1_session_seek`] with every command anchored at `anchor_sample`, as +/// [`miso_engine_v1_source_seek_at`]'s anchor: the timeline and every source change at the block +/// that starts at `anchor_sample` (or, reached late, at the first block after it, each at its frame +/// plus the lateness). An anchor that is not a multiple of the quantum is +/// `RESULT_INVALID_ARGUMENT` with `source.seek.anchor_unaligned`. It declares no discontinuity +/// (root ruling B16-R9): it is the loop and exact-start form, which keeps continuity, so the effect +/// tails and the latency floors stay; a host that wants them reset declares at a stop. +/// +/// # Safety +/// +/// `session` must be live. +/// +/// Thread: control. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miso_engine_v1_session_seek_at( + session: *mut Session, + generation: u64, + timeline_sample: u64, + anchor_sample: u64, +) -> u32 { + // SAFETY: The caller's contract is this entry point's, forwarded unchanged. + unsafe { session_seek_entry(session, generation, timeline_sample, Some(anchor_sample)) } +} + +/// The shared body of the two session-seek entry points: the handle check and the result +/// mapping; the seek itself is `SessionState::session_seek`. +/// +/// # Safety +/// +/// `session` must be live. +unsafe fn session_seek_entry( + session: *mut Session, + generation: u64, + timeline_sample: u64, + anchor_sample: Option, +) -> u32 { + catch_result(|| { + // SAFETY: Nonnull live handle pointers are caller-provided under the handle contract. + let kind = unsafe { session_kind(session) }; + if kind != RESULT_OK { + return kind; + } + // SAFETY: `session` passed the live-kind check and the ABI serializes session calls. + let session = unsafe { &mut *session }; + match session + .state + .session_seek(generation, timeline_sample, anchor_sample) + { + Ok(()) => { + session.last_error.borrow_mut().clear(); + RESULT_OK + } + Err(SessionSeekFailure::Seek(error)) => { + let (code, diagnostic) = error.report(); + session.last_error.borrow_mut().set(diagnostic); + code + } + // B16-R9: the plain form's declaration refused; it reports as + // `miso_engine_v1_declare_discontinuity` does. + Err(SessionSeekFailure::Declaration(refused)) => declaration_refused(session, &refused), + } + }) +} + /// Copy where a source of the newest committed session landed, as render last published it /// (#1316): the generation in effect, the source-read sample (the render clock in this version) of /// the first block it played in, the source frame that block started at, and the source's @@ -752,7 +855,9 @@ pub unsafe extern "C" fn miso_engine_v1_service(session: *mut Session) -> u32 { /// Declare a discontinuity (#1323): the next block the host renders need not continue the /// previous output, because the host has stopped or is about to seek every source. A host calls it -/// at a stop, and before a seek of every source. +/// at a stop, and before a seek of every source made with per-source seeks; a plain +/// [`miso_engine_v1_session_seek`] declares it in the same call (root ruling B16-R9), so a host does +/// not call it before one. /// /// It forwards to the control plane's `SessionState::declare_discontinuity`, the function the /// browser's `miso_engine_web_v1_declare_discontinuity` runs. If the newest plan runs behind its @@ -785,19 +890,23 @@ pub unsafe extern "C" fn miso_engine_v1_declare_discontinuity(session: *mut Sess session.last_error.borrow_mut().clear(); RESULT_OK } - Err(refused) => { - session.last_error.borrow_mut().set(&refused.diagnostic()); - match refused { - CommandError::BufferTooSmall { .. } | CommandError::Internal => RESULT_INTERNAL, - CommandError::Invalid => RESULT_INVALID_ARGUMENT, - CommandError::Backpressure => RESULT_BACKPRESSURE, - CommandError::CompileRejected(_) => RESULT_COMPILE_REJECTED, - } - } + Err(refused) => declaration_refused(session, &refused), } }) } +/// The result of a refused declaration, its own or a plain session seek's (B16-R9): the core's +/// diagnostic into the session's last error, and the code a rebuild's refusal has. +fn declaration_refused(session: &Session, refused: &CommandError) -> u32 { + session.last_error.borrow_mut().set(&refused.diagnostic()); + match refused { + CommandError::BufferTooSmall { .. } | CommandError::Internal => RESULT_INTERNAL, + CommandError::Invalid => RESULT_INVALID_ARGUMENT, + CommandError::Backpressure => RESULT_BACKPRESSURE, + CommandError::CompileRejected(_) => RESULT_COMPILE_REJECTED, + } +} + /// Process one bounded Issue-005 capability command with exact-byte replay. /// /// # Safety @@ -1303,6 +1412,15 @@ pub(crate) fn test_session_state_summary(session: *mut Session) -> (u64, usize, state.test_state_summary() } +/// The newest plan epoch, the running plan's or a waiting replacement's (B16-R9 gates): it +/// advances exactly when the control plane publishes a plan. +#[cfg(test)] +pub(crate) fn test_newest_plan_epoch(session: *mut Session) -> u64 { + // SAFETY: Test callers retain the exclusively owned live session for this inspection. + let state = unsafe { &(*session).state }; + state.newest_plan_epoch() +} + #[cfg(test)] pub(crate) fn test_last_error(session: *mut Session) -> Vec { // SAFETY: Test callers retain the exclusively owned live session for this inspection. @@ -1433,6 +1551,33 @@ pub(crate) fn test_plan_carry_counts(plan: *mut Plan) -> (u64, u64) { unsafe { plan_carry_counts(plan) } } +#[cfg(test)] +pub(crate) fn test_session_seek( + session: *mut Session, + generation: u64, + timeline_sample: u64, + anchor_sample: Option, +) -> u32 { + // SAFETY: Test callers retain the live session for this call. + unsafe { + match anchor_sample { + None => miso_engine_v1_session_seek(session, generation, timeline_sample), + Some(anchor) => { + miso_engine_v1_session_seek_at(session, generation, timeline_sample, anchor) + } + } + } +} + +/// The active plan's timeline at `source_read_sample` (#1641's test reader, #1642 gates). +#[cfg(test)] +pub(crate) fn test_plan_timeline_at(plan: *mut Plan, source_read_sample: u64) -> i64 { + // SAFETY: Test callers retain the exclusively owned live plan, with no render in flight. + let state = unsafe { &mut (*plan).state }; + graph::test_only_timeline_at(state.test_owner_mut().active_plan_mut(), source_read_sample) + .expect("a graph plan with a timeline") +} + #[cfg(test)] pub(crate) fn test_set_structural_faults( session: *mut Session, @@ -1754,6 +1899,8 @@ mod tests { capabilities.feature_mask & crate::FEATURE_SOURCE_SEEK_REPORT, 0 ); + // #1642 gate 6. + assert_ne!(capabilities.feature_mask & crate::FEATURE_SESSION_SEEK, 0); assert_eq!(capabilities.reserved, [0; 4]); } diff --git a/crates/capi/src/runtime/mod.rs b/crates/capi/src/runtime/mod.rs index c099920c7..59ab83302 100644 --- a/crates/capi/src/runtime/mod.rs +++ b/crates/capi/src/runtime/mod.rs @@ -13,7 +13,7 @@ pub(crate) use control_plane::{ CommandError, CompileFailure, ControlAdapter, ControlLimits, EventError, EventLane, - PlanResources, ResourceFault, SourceFailure, SourceSubmission, + PlanResources, ResourceFault, SessionSeekFailure, SourceFailure, SourceSubmission, }; pub(crate) use core::mem::size_of; pub(crate) use effect_contract::TailSamples; diff --git a/crates/capi/src/runtime/tests.rs b/crates/capi/src/runtime/tests.rs index 1d8c05c49..47c15865d 100644 --- a/crates/capi/src/runtime/tests.rs +++ b/crates/capi/src/runtime/tests.rs @@ -5781,3 +5781,523 @@ fn the_c_abi_bounds_revisions_at_the_plan_gate_ceiling() { crate::ffi::test_session_destroy(session); } } + +/// #1642: the session seek through the C entry points. A two-source session at 48 kHz, quantum +/// 128: track `eq0` on `fixture-source` (10,000 frames) and track `t2` on `short-source` (4,000 +/// frames), stateless, played on generation 1. +mod session_seek { + use super::*; + + const LONG: &[u8] = b"fixture-source"; + const SHORT: &[u8] = b"short-source"; + const Q: u64 = 128; + + fn two_sources() -> session::SessionModel { + let mut model = stateless_session(48_000); + model.tracks.truncate(1); + model.routes.truncate(1); + model.sources[0].frames = 10_000; + let [ + protocol::SessionEdit::UpsertSource { mut source }, + protocol::SessionEdit::UpsertTrack { track }, + protocol::SessionEdit::UpsertRoute { route }, + ] = add_source_and_track(&model, "short-source", "t2") + else { + panic!("three upserts") + }; + source.frames = 4_000; + model.sources.push(source); + model.tracks.push(track); + model.routes.push(route); + model + } + + /// The C children of `model`, with `fixture-source` fed and rendered for four blocks. + struct Rig { + session: *mut crate::Session, + plan: *mut crate::Plan, + block: u64, + } + + impl Rig { + fn new(model: &session::SessionModel) -> Self { + let document = session::canonical_session_json(model).expect("canonical"); + let (session, plan) = boxed_c_children(&document); + let mut rig = Self { + session, + plan, + block: 0, + }; + submit_block_c(session, LONG, 1, 0, 0, 48_000); + for _ in 0..4 { + submit_block_c(session, LONG, 1, rig.block + 1, 0, 48_000); + rig.render(); + } + rig + } + + /// Renders the next block; returns its output. + fn render(&mut self) -> Vec { + let output = render_c(self.plan, self.block); + self.block += 1; + output + } + + /// The sample of the next block. + fn next(&self) -> u64 { + self.block * Q + } + + fn timeline(&self, sample: u64) -> i64 { + crate::ffi::test_plan_timeline_at(self.plan, sample) + } + + /// `(generation, first_sample, source_frame)` of `id`'s seek report. + fn landed(&self, id: &[u8]) -> (u64, u64, u64) { + let (generation, first, frame, _, _) = seek_report_c(self.session, id); + (generation, first, frame) + } + + fn seek(&self, generation: u64, timeline_sample: u64) -> u32 { + crate::ffi::test_session_seek(self.session, generation, timeline_sample, None) + } + + fn seek_at(&self, generation: u64, timeline_sample: u64, anchor: u64) -> u32 { + crate::ffi::test_session_seek(self.session, generation, timeline_sample, Some(anchor)) + } + + fn last_error(&self) -> Vec { + crate::ffi::test_last_error(self.session) + } + + /// One quantum of `LONG`'s generation `generation` from source frame `frame`. + fn feed_long(&self, generation: u64, frame: u64) { + let (left, right) = continuity_block(frame / Q, 0); + submit_c_to( + self.session, + LONG, + generation, + frame, + 48_000, + &left, + &right, + false, + ); + } + } + + impl Drop for Rig { + fn drop(&mut self) { + crate::ffi::test_plan_destroy(self.plan); + crate::ffi::test_session_destroy(self.session); + } + } + + /// Gate 1 up to its anchored seek: the rig after `session_seek(2, 6_000)` and the block that + /// applied it. + fn after_gate_one_plain() -> Rig { + let mut rig = Rig::new(&two_sources()); + assert_eq!(rig.seek(2, 6_000), crate::RESULT_OK); + rig.feed_long(2, 6_000); + let sample = rig.next(); + rig.render(); + assert_eq!(rig.landed(LONG), (2, sample, 6_000)); + assert_eq!(rig.landed(SHORT), (2, sample, 4_000), "clamped to its end"); + assert_eq!(rig.timeline(sample), 6_000); + rig + } + + /// Gate 1, both forms move everything at one block. + /// + /// Test value: red if the timeline and the sources apply the seek in different blocks, a + /// source is not clamped to its region, or the anchored form ignores its anchor. + #[test] + fn both_forms_move_the_timeline_and_every_source_at_one_block() { + let mut rig = after_gate_one_plain(); + let anchor = rig.next() + 2 * Q; + assert_eq!(rig.seek_at(3, 1_000, anchor), crate::RESULT_OK); + // The plain seek applied at the block before these, at timeline 6,000. + for block in 1..=2 { + let sample = rig.next(); + rig.render(); + assert_eq!(rig.landed(LONG).0, 2, "held before the anchor"); + assert_eq!(rig.landed(SHORT).0, 2, "held before the anchor"); + assert_eq!(rig.timeline(sample), (6_000 + block * Q) as i64); + } + rig.feed_long(3, 1_000); + let sample = rig.next(); + assert_eq!(sample, anchor); + rig.render(); + assert_eq!(rig.landed(LONG), (3, anchor, 1_000)); + assert_eq!(rig.landed(SHORT), (3, anchor, 1_000)); + assert_eq!(rig.timeline(anchor), 1_000); + } + + /// Gate 2, a stale generation changes nothing, under the source's rule and the session's + /// published generation alike. + /// + /// Test value: red if the generation check runs per consumer after earlier pushes, or if a + /// stale refusal pushes to any consumer (the short source and the timeline stay put). + #[test] + fn a_stale_generation_changes_nothing() { + let mut refused = after_gate_one_plain(); + let mut twin = after_gate_one_plain(); + assert_eq!(refused.seek(2, 0), crate::RESULT_INVALID_ARGUMENT); + assert_eq!(refused.last_error(), b"source.generation.stale"); + let sample = refused.next(); + refused.feed_long(2, 6_000 + Q); + twin.feed_long(2, 6_000 + Q); + let bits = |block: Vec| { + block + .iter() + .map(|value| value.to_bits()) + .collect::>() + }; + assert_eq!(bits(refused.render()), bits(twin.render())); + assert_eq!(refused.timeline(sample), twin.timeline(sample)); + assert_eq!(refused.timeline(sample), (6_000 + Q) as i64); + + // A per-source seek to 5 makes a session seek at 4 stale. Both the long source's own + // generation (5) and the session's published generation refuse it here, so this case + // does not tell the two rules apart; `a_removed_sources_generation_keeps_a_session_seek_stale` + // isolates the published generation. + assert_eq!( + crate::ffi::test_source_seek(refused.session, LONG, 5, 0), + crate::RESULT_OK + ); + refused.render(); + assert_eq!(refused.seek(4, 0), crate::RESULT_INVALID_ARGUMENT); + assert_eq!(refused.last_error(), b"source.generation.stale"); + let sample = refused.next(); + refused.render(); + assert_eq!(refused.landed(SHORT).0, 2, "the refusal pushed nothing"); + assert_eq!(refused.timeline(sample), (6_000 + 3 * Q) as i64); + assert_eq!(refused.seek(6, 0), crate::RESULT_OK); + } + + /// Gate 2, the published generation alone (batch 16b review, MAJOR 1): a per-source seek of + /// the short source to 10, then a transaction that removes it, swapped in. No live producer + /// holds a generation above 1 now, so only the session's published generation (10) refuses a + /// session seek at 5; one at 11 is accepted. + /// + /// Test value: red if a session seek is checked only against the live producers' own + /// generations and not against the largest the session has published: a source removed at a + /// higher generation would let a lower one through. + #[test] + fn a_removed_sources_generation_keeps_a_session_seek_stale() { + let model = two_sources(); + let mut rig = Rig::new(&model); + assert_eq!( + crate::ffi::test_source_seek(rig.session, SHORT, 10, 0), + crate::RESULT_OK + ); + submit_block_c(rig.session, LONG, 1, rig.block + 1, 0, 48_000); + rig.render(); + assert_eq!(rig.landed(SHORT).0, 10); + transact_c( + rig.session, + 1, + model.revision, + &[ + protocol::SessionEdit::RemoveRoute { + route_id: model.routes[1].id.clone(), + }, + protocol::SessionEdit::RemoveTrack { + track_id: model.tracks[1].id.clone(), + }, + protocol::SessionEdit::RemoveSource { + source_id: model.sources[1].id.clone(), + }, + ], + ); + submit_block_c(rig.session, LONG, 1, rig.block + 1, 0, 48_000); + rig.render(); + assert_eq!( + rig.landed(LONG).0, + 1, + "the long source is still at generation 1" + ); + assert_eq!(rig.seek(5, 0), crate::RESULT_INVALID_ARGUMENT); + assert_eq!(rig.last_error(), b"source.generation.stale"); + let sample = rig.next(); + rig.render(); + assert_eq!(rig.landed(LONG).0, 1, "the refusal pushed nothing"); + assert_eq!( + rig.timeline(sample), + sample as i64, + "the timeline did not move" + ); + assert_eq!(rig.seek(11, 0), crate::RESULT_OK); + } + + /// Gate 3, one full slot refuses all. + /// + /// Test value: red if any slot is pushed before every slot is checked: the partial seek the + /// acked-batch rule forbids. + #[test] + fn one_full_slot_refuses_every_consumer() { + let mut rig = Rig::new(&two_sources()); + assert_eq!( + crate::ffi::test_source_seek(rig.session, LONG, 2, 640), + crate::RESULT_OK + ); + assert_eq!(rig.seek(3, 2_000), crate::RESULT_BACKPRESSURE); + assert_eq!(rig.last_error(), b"source.seek.backpressure"); + // The short source's slot (and the timeline's) stayed empty. + assert_eq!( + crate::ffi::test_source_seek(rig.session, SHORT, 3, 100), + crate::RESULT_OK + ); + let sample = rig.next(); + rig.render(); + assert_eq!(rig.landed(LONG), (2, sample, 640)); + assert_eq!(rig.landed(SHORT), (3, sample, 100)); + assert_eq!( + rig.timeline(sample), + sample as i64, + "the timeline did not move" + ); + } + + /// Gate 4, a session seek while a structural candidate waits for its swap. + /// + /// Test value: red if the call seeks the running plan's producers instead of the newest + /// committed session's: the added source would not be sought, and the carried source's seek + /// would land in a ring the candidate does not hold. + #[test] + fn a_seek_before_the_swap_reaches_the_carried_and_the_added_source_and_the_timeline() { + let mut model = stateless_session(48_000); + model.tracks.truncate(1); + model.routes.truncate(1); + let mut rig = Rig::new(&model); + transact_c( + rig.session, + 1, + 42, + &add_source_and_track(&model, "added", "t2"), + ); + assert_eq!(rig.seek(2, 3_000), crate::RESULT_OK); + rig.feed_long(2, 3_000); + let swap = rig.next(); + rig.render(); + assert_eq!( + crate::ffi::test_plan_carry_counts(rig.plan), + (1, 0), + "the swap block carried the source" + ); + assert_eq!(rig.landed(LONG), (2, swap, 3_000)); + assert_eq!(rig.landed(b"added"), (2, swap, 3_000)); + assert_eq!(rig.timeline(swap), 3_000); + } + + /// Gate 5, a per-source seek leaves the timeline where it is. + /// + /// Test value: red if a per-source seek moves the playhead. + #[test] + fn a_source_seek_leaves_the_timeline() { + let mut rig = Rig::new(&two_sources()); + assert_eq!( + crate::ffi::test_source_seek(rig.session, LONG, 2, 2_048), + crate::RESULT_OK + ); + let sample = rig.next(); + rig.render(); + assert_eq!(rig.landed(LONG), (2, sample, 2_048)); + assert_eq!(rig.landed(SHORT).0, 1); + assert_eq!(rig.timeline(sample), sample as i64); + } +} + +/// Root ruling B16-R9 on the C ABI: a plain session seek declares the discontinuity in the same +/// call, after its check; an anchored one declares nothing. Each case starts from #1285's floors: +/// the limited session, its removal transaction committed and swapped, so the running plan runs +/// behind its natural timing and a declaration has a successor to publish. +mod session_seek_declares { + use super::*; + + const SOURCE: &[u8] = b"fixture-source"; + const Q: u64 = 128; + + /// The floored C children: the session, the plan, the next block and the floored latency. + struct Floored { + session: *mut crate::Session, + plan: *mut crate::Plan, + block: u64, + floored: u64, + } + + impl Floored { + /// With `swap` false the removal's candidate still waits for its block. + fn new(swap: bool) -> Self { + let (model, edits) = limited_session(); + let document = session::canonical_session_json(&model).expect("canonical"); + let (session, plan) = boxed_c_children(&document); + let floored = crate::ffi::test_plan_latency(plan); + assert!(floored > 0, "the limiter delays the plan"); + let (left, right) = continuity_block(0, 0); + submit_c(session, 1, 0, 48_000, &left, &right, false); + feed_and_render_c(session, plan, 48_000, 0, 6); + assert_eq!( + command_c(session, &transaction_at(1, 42, &edits)).0, + crate::RESULT_OK + ); + let mut block = 6; + if swap { + feed_and_render_c(session, plan, 48_000, 6, 4); + block = 10; + assert_eq!(crate::ffi::test_plan_latency(plan), floored, "#1285 floors"); + } + Self { + session, + plan, + block, + floored, + } + } + + fn epoch(&self) -> u64 { + crate::ffi::test_newest_plan_epoch(self.session) + } + + fn latency(&self) -> u64 { + crate::ffi::test_plan_latency(self.plan) + } + + /// Feeds generation `generation` from `frame` and renders `blocks` blocks. + fn play(&mut self, generation: u64, frame: u64, blocks: u64) { + for step in 0..blocks { + let (left, right) = continuity_block(self.block + 1, 0); + submit_c( + self.session, + generation, + frame + step * Q, + 48_000, + &left, + &right, + false, + ); + render_c(self.plan, self.block); + self.block += 1; + } + } + } + + impl Drop for Floored { + fn drop(&mut self) { + crate::ffi::test_plan_destroy(self.plan); + crate::ffi::test_session_destroy(self.session); + } + } + + /// Gate 1: a refused plain session seek publishes nothing. + /// + /// Test value: red if the seek declares before (or without) its check: the stale seek would + /// publish the floorless successor, and the epoch and the latency would move. + #[test] + fn a_refused_plain_session_seek_publishes_nothing() { + let mut rig = Floored::new(true); + let epoch = rig.epoch(); + assert_eq!( + crate::ffi::test_session_seek(rig.session, 1, 0, None), + crate::RESULT_INVALID_ARGUMENT + ); + assert_eq!( + crate::ffi::test_last_error(rig.session), + b"source.generation.stale" + ); + assert_eq!(rig.epoch(), epoch, "nothing is published"); + rig.play(1, (rig.block + 1) * Q, 2); + assert_eq!(rig.latency(), rig.floored, "the floors stay"); + assert_eq!(seek_report_c(rig.session, SOURCE).0, 1, "nothing is pushed"); + } + + /// Gate 2: an accepted plain session seek publishes exactly one discontinuity successor and its + /// seek applies, at the next block; also while the removal's candidate still waits, which the + /// declaration supersedes before the seek is pushed to the same producers. + /// + /// Test value: red if the plain seek does not declare (the latency stays floored), publishes + /// more than one plan, or pushes to a set other than the one the successor carries (the seek + /// would not land, or the debug assertion of the re-check fires). + #[test] + fn an_accepted_plain_session_seek_publishes_one_successor_and_applies() { + for swap in [true, false] { + let mut rig = Floored::new(swap); + let epoch = rig.epoch(); + assert_eq!( + crate::ffi::test_session_seek(rig.session, 2, 1_280, None), + crate::RESULT_OK, + "swap {swap}" + ); + assert_eq!(rig.epoch(), epoch + 1, "swap {swap}: one successor"); + let sample = rig.block * Q; + rig.play(2, 1_280, 3); + let (generation, first, frame, _, _) = seek_report_c(rig.session, SOURCE); + assert_eq!( + (generation, first, frame), + (2, sample, 1_280), + "swap {swap}" + ); + assert!(rig.latency() < rig.floored, "swap {swap}: the floors reset"); + assert_eq!( + rig.epoch(), + epoch + 1, + "swap {swap}: nothing else published" + ); + } + } + + /// Gate 1b: a plain session seek whose declaration is refused pushes nothing and returns the + /// declaration's own error; the same generation is accepted once the fault clears. + /// + /// Test value: red if the seek ignores its declaration's error (`let _ =`): the refused + /// declaration would be followed by a push, the report would carry generation 2 and the seek + /// would return OK. + #[test] + fn a_refused_declaration_pushes_nothing_and_leaves_the_generation_free() { + use crate::runtime::TestStructuralFaultPhase::BeforeProtocolCommit; + let mut rig = Floored::new(true); + let epoch = rig.epoch(); + crate::ffi::test_set_structural_faults(rig.session, [Some(BeforeProtocolCommit), None]); + assert_eq!( + crate::ffi::test_session_seek(rig.session, 2, 1_280, None), + crate::RESULT_BACKPRESSURE, + "the declaration's refusal is returned" + ); + assert_eq!(rig.epoch(), epoch, "nothing is published"); + rig.play(1, (rig.block + 1) * Q, 2); + assert_eq!(rig.latency(), rig.floored, "the floors stay"); + assert_eq!(seek_report_c(rig.session, SOURCE).0, 1, "nothing is pushed"); + // The fault is spent: the same generation is not burned by the refused attempt. + assert_eq!( + crate::ffi::test_session_seek(rig.session, 2, 1_280, None), + crate::RESULT_OK + ); + assert_eq!(rig.epoch(), epoch + 1, "one successor"); + let sample = rig.block * Q; + rig.play(2, 1_280, 3); + let (generation, first, frame, _, _) = seek_report_c(rig.session, SOURCE); + assert_eq!((generation, first, frame), (2, sample, 1_280)); + } + + /// Gate 3: an anchored session seek publishes no successor; it applies at its anchor and the + /// floors stay. + /// + /// Test value: red if the anchored form declares (the successor would publish, the epoch + /// would move and the latency drop before the anchor). + #[test] + fn an_anchored_session_seek_publishes_no_successor() { + let mut rig = Floored::new(true); + let epoch = rig.epoch(); + let anchor = (rig.block + 2) * Q; + assert_eq!( + crate::ffi::test_session_seek(rig.session, 2, 1_280, Some(anchor)), + crate::RESULT_OK + ); + assert_eq!(rig.epoch(), epoch, "nothing is published"); + rig.play(2, 1_280, 4); + let (generation, first, frame, _, _) = seek_report_c(rig.session, SOURCE); + assert_eq!((generation, first, frame), (2, anchor, 1_280)); + assert_eq!(rig.latency(), rig.floored, "the floors stay"); + assert_eq!(rig.epoch(), epoch); + } +} diff --git a/crates/capi/tests/c/abi_smoke.c b/crates/capi/tests/c/abi_smoke.c index 6c48a4a43..ec0ab7f39 100644 --- a/crates/capi/tests/c/abi_smoke.c +++ b/crates/capi/tests/c/abi_smoke.c @@ -22,7 +22,10 @@ ABI_ASSERT(MISO_ENGINE_V1_EXACT_LAUNCH_RATE_MASK == 15); ABI_ASSERT(MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_AT == 32); ABI_ASSERT(MISO_ENGINE_V1_FEATURE_DECLARE_DISCONTINUITY == 256); ABI_ASSERT(MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_REPORT == 512); -ABI_ASSERT(MISO_ENGINE_V1_FEATURE_MASK == 1023); +ABI_ASSERT(MISO_ENGINE_V1_FEATURE_SESSION_SEEK == 1024); +ABI_ASSERT(MISO_ENGINE_V1_FEATURE_MASK == 2047); +ABI_ASSERT((MISO_ENGINE_V1_FEATURE_MASK & MISO_ENGINE_V1_FEATURE_SESSION_SEEK) == + MISO_ENGINE_V1_FEATURE_SESSION_SEEK); ABI_ASSERT((MISO_ENGINE_V1_FEATURE_MASK & MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_REPORT) == MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_REPORT); ABI_ASSERT((MISO_ENGINE_V1_FEATURE_MASK & MISO_ENGINE_V1_FEATURE_DECLARE_DISCONTINUITY) == @@ -141,6 +144,10 @@ static uint32_t (*const watermark_signature)(const miso_engine_v1_plan *, static uint32_t (*const service_signature)(miso_engine_v1_session *) = miso_engine_v1_service; static uint32_t (*const declare_discontinuity_signature)(miso_engine_v1_session *) = miso_engine_v1_declare_discontinuity; +static uint32_t (*const session_seek_signature)(miso_engine_v1_session *, uint64_t, uint64_t) = + miso_engine_v1_session_seek; +static uint32_t (*const session_seek_at_signature)(miso_engine_v1_session *, uint64_t, uint64_t, + uint64_t) = miso_engine_v1_session_seek_at; static uint32_t (*const last_error_signature)(const void *, miso_engine_v1_bytes_out *) = miso_engine_v1_last_error; static void (*const session_destroy_signature)(miso_engine_v1_session *) = @@ -266,7 +273,8 @@ static int reports_every_defined_feature(uint64_t mask) { (mask & MISO_ENGINE_V1_FEATURE_PLAN_WATERMARK) != 0 && (mask & MISO_ENGINE_V1_FEATURE_SERVICE) != 0 && (mask & MISO_ENGINE_V1_FEATURE_DECLARE_DISCONTINUITY) != 0 && - (mask & MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_REPORT) != 0; + (mask & MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_REPORT) != 0 && + (mask & MISO_ENGINE_V1_FEATURE_SESSION_SEEK) != 0; } int main(void) { @@ -320,13 +328,22 @@ int main(void) { return 7; } + /* A host checks the session-seek bit before calling either symbol (issue 1642); a null session + * is refused. */ + if ((capabilities.feature_mask & MISO_ENGINE_V1_FEATURE_SESSION_SEEK) == 0 || + session_seek_signature(NULL, 2, 0) != MISO_ENGINE_V1_INVALID_ARGUMENT || + session_seek_at_signature(NULL, 2, 0, 0) != MISO_ENGINE_V1_INVALID_ARGUMENT) { + return 8; + } + return compile_session_signature == NULL || source_submit_signature == NULL || source_seek_signature == NULL || source_seek_report_signature == NULL || submit_command_signature == NULL || dequeue_event_signature == NULL || render_signature == NULL || resources_signature == NULL || watermark_signature == NULL || service_signature == NULL || - declare_discontinuity_signature == NULL || + declare_discontinuity_signature == NULL || session_seek_signature == NULL || + session_seek_at_signature == NULL || last_error_signature == NULL || session_destroy_signature == NULL || plan_destroy_signature == NULL ? 3 diff --git a/crates/capi/tests/c/header_smoke.cpp b/crates/capi/tests/c/header_smoke.cpp index 6386b6785..f298ce89e 100644 --- a/crates/capi/tests/c/header_smoke.cpp +++ b/crates/capi/tests/c/header_smoke.cpp @@ -47,3 +47,11 @@ static_assert(std::is_same::value); static_assert(std::is_same::value); +static_assert(MISO_ENGINE_V1_FEATURE_SESSION_SEEK == UINT64_C(1024)); +static_assert((MISO_ENGINE_V1_FEATURE_MASK & MISO_ENGINE_V1_FEATURE_SESSION_SEEK) == + MISO_ENGINE_V1_FEATURE_SESSION_SEEK); +static_assert(std::is_same::value); +static_assert(std::is_same::value); diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index d316a4481..4e4f56b60 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -731,7 +731,7 @@ pub fn observation_reserve_bytes( )) } -/// The binding table's charge alone ([`observation_binding_rows`]) for a control frame of +/// The binding table's charge alone (`observation_binding_rows`) for a control frame of /// `frame_bytes` and `slots` observation slots, so a test can hold a measured table to it. /// /// # Errors diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index 4995352f9..17949a9f4 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -2153,6 +2153,12 @@ where /// for an accounting fault. pub fn declare_discontinuity(&mut self) -> Result<(), CommandError> { self.service()?; + self.declare_discontinuity_serviced() + } + + /// [`Self::declare_discontinuity`] after its service step: the one core path of a declaration, + /// which a plain session seek also runs (B16-R9). + fn declare_discontinuity_serviced(&mut self) -> Result<(), CommandError> { if !self .newest_providers() .inventory @@ -4084,6 +4090,72 @@ where .map_err(SourceFailure::Control) } + /// Seeks the timeline and every source of the newest committed session to timeline sample + /// `timeline_sample` under one new `generation`, all or nothing, after the service step + /// (#1642 D1, D2): [`host_core::SourceControlSet::seek_session`] on the newest session's + /// producers, which while a structural transaction's candidate waits for its swap are the + /// candidate's (its carried producers feed the rings the running plan renders, and a source + /// it adds has its own new ring). The one core function behind both hosts' session seek. + /// + /// **A plain session seek is a declared discontinuity (root ruling B16-R9).** With no + /// `anchor_sample`, the engine declares the discontinuity in the same call, in this order: + /// every producer checks its command ([`host_core::SourceControlSet::check_session_seek`]), + /// then the declaration runs ([`Self::declare_discontinuity`]'s core path, all or nothing), + /// then the commands are pushed. A refused check declares nothing and pushes nothing; a + /// refused declaration pushes nothing and returns its own error, + /// [`SessionSeekFailure::Declaration`]. A host therefore never declares separately before a + /// session seek. + /// + /// **An anchored session seek keeps continuity.** With `anchor_sample`, every command is + /// anchored there, as [`Self::seek_at`]'s, and nothing is declared: it is the loop and + /// exact-start form (#1643), and a declaration publishes its successor at the next block, + /// before the anchor, so the effects would go to rest while the old pass still plays. A host + /// that wants the floors reset declares at a stop. + /// + /// # Errors + /// + /// [`SessionSeekFailure::Seek`] with the facade's refusal, or `Internal` for an epoch fault; + /// [`SessionSeekFailure::Declaration`] with the declaration's refusal (plain form only). + pub fn session_seek( + &mut self, + generation: u64, + timeline_sample: u64, + anchor_sample: Option, + ) -> Result<(), SessionSeekFailure> { + self.service() + .map_err(|_| SessionSeekFailure::Seek(SourceFailure::Internal))?; + if anchor_sample.is_none() { + self.newest_providers() + .sources + .check_session_seek(generation, timeline_sample, None) + .map_err(|error| SessionSeekFailure::Seek(SourceFailure::Control(error)))?; + self.declare_discontinuity_serviced() + .map_err(SessionSeekFailure::Declaration)?; + // The declaration may have superseded a pending candidate, so the newest providers + // may now be a new epoch's. Its set holds the same producer objects the check ran + // on: a discontinuity successor carries every source of the committed model, the + // candidate's model, and its commit moves each producer it carries from the set it + // supersedes (the checked donation of the candidate's fresh rings, then + // `adopt_persisting` for the persisting producers and the timeline), so no command + // was pushed in between and the check's verdict still holds. `seek_session` checks + // again; a refusal there is that invariant broken. + return self + .newest_providers_mut() + .sources + .seek_session(generation, timeline_sample, None) + .map_err(|_| { + debug_assert!(false, "a checked session seek holds across its declaration"); + SessionSeekFailure::Seek(SourceFailure::Control(SourceControlError::Chunk( + source::HostChunkError::InternalInvariant, + ))) + }); + } + self.newest_providers_mut() + .sources + .seek_session(generation, timeline_sample, anchor_sample) + .map_err(|error| SessionSeekFailure::Seek(SourceFailure::Control(error))) + } + /// Where a source of the newest committed session landed, as render last published it, after /// the service step (#1316 D6, D7): [`host_core::SourceControlSet::seek_report`] of the ring /// the newest session's producer feeds, which is the one `seek` queues on. @@ -4110,6 +4182,18 @@ pub enum SourceFailure { Internal, } +/// Why a session seek was refused (root ruling B16-R9): the seek's own refusal, or, for the +/// plain form, the refusal of the discontinuity it declares. Either way nothing was pushed. The +/// adapter maps `Seek` as it maps a source seek's [`SourceFailure`] and `Declaration` as it maps +/// its declaration's [`CommandError`]. +#[derive(Debug)] +pub enum SessionSeekFailure { + /// A producer refused the seek, or the epochs could not synchronise; nothing was declared. + Seek(SourceFailure), + /// The plain form's declaration refused; nothing was pushed. + Declaration(CommandError), +} + #[cfg(test)] mod tests { use super::EventError; diff --git a/crates/control-plane/src/lib.rs b/crates/control-plane/src/lib.rs index bcd1617ad..7ca233ea5 100644 --- a/crates/control-plane/src/lib.rs +++ b/crates/control-plane/src/lib.rs @@ -11,7 +11,7 @@ //! control plane in a Worker. An adapter describes itself through [`ControlAdapter`]: the row type //! of its plan resource table and the fixed allocations it keeps per session. It passes plain //! [`ControlLimits`], and reads plain [`PlanResources`] and typed failures -//! ([`CompileFailure`], [`SourceFailure`], [`CommandError`], [`EventError`]) that it maps to its +//! ([`CompileFailure`], [`SourceFailure`], [`SessionSeekFailure`], [`CommandError`], [`EventError`]) that it maps to its //! own codes. The crate owns every command and event refusal text (decision 17 C1), through //! [`CommandError::diagnostic`], [`CompileFailure::diagnostic`] and [`EventError::diagnostic`]; an //! adapter owns only its result-code numbering. @@ -65,7 +65,7 @@ pub use compile::{CompiledChildren, RuntimePreparer, compile_children}; pub use control::{ CommandError, EventError, EventLane, ObservationChange, ObservationForward, ObservationKeyError, ObservationKeyRef, ObservationLimit, ObservationRefusal, - REPREPARE_REQUIRED, ResolvedObservationTap, SessionState, SourceFailure, + REPREPARE_REQUIRED, ResolvedObservationTap, SessionSeekFailure, SessionState, SourceFailure, resolve_observation_tap, }; pub use error::{CompileFailure, ResourceFault}; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index bbb9dcd8e..749549601 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -12,6 +12,9 @@ name = "engine" [features] default = [] realtime-audit = [] +# Root ruling B16-R8: `RealtimePlanOwner::active_plan_mut`, for other crates' tests only (capi's +# #1642 gates read the active plan's timeline through it). Never a production feature. +test-support = [] [dev-dependencies] # MIT licensed; test/model-checking only. It is not reachable from production, Wasm, or render. diff --git a/crates/engine/src/realtime/plan_exchange.rs b/crates/engine/src/realtime/plan_exchange.rs index 3c7b9cb33..61d1df178 100644 --- a/crates/engine/src/realtime/plan_exchange.rs +++ b/crates/engine/src/realtime/plan_exchange.rs @@ -777,6 +777,14 @@ impl RealtimePlanOwner { pub fn active_attachment_mut(&mut self) -> Option<&mut T> { self.active.1.host_attachment_mut::() } + /// The currently active complete plan, for the plan's exclusive owner between render calls + /// (#1642): a test reads the plan's own state, such as its timeline, through it. Nothing here + /// adopts, swaps or renders; the borrow ends before the next render call. Test support only + /// (root ruling B16-R8): no production caller needs `&mut` access to the active plan. + #[cfg(feature = "test-support")] + pub fn active_plan_mut(&mut self) -> &mut PreparedRenderPlan { + &mut self.active.1 + } /// Saturating count of applied swaps whose incoming plan took state from its predecessor. #[must_use] pub const fn carried_count(&self) -> u64 { diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index a4c5f2cc5..dd8895ea7 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -48,8 +48,8 @@ use session::{ parse_session_json, }; use source::{ - PcmSourceRing, PcmSourceRingConfig, SourceFrame, SourceGeneration, SourceGraphSource, - SourceGraphTrackMapping, SourceTimeline, prepare_graph_source_set, + PcmSourceRing, PcmSourceRingConfig, PublishedGeneration, SourceFrame, SourceGeneration, + SourceGraphSource, SourceGraphTrackMapping, SourceTimeline, prepare_session_graph_source_set, }; use std::collections::BTreeMap; @@ -779,6 +779,11 @@ pub struct PlanStateInventory { /// predecessor's for an ordinary successor, 0 for a first plan and at a declared /// discontinuity. source_read_offset: u64, + /// The session's published seek generation (#1642 B16-R2): the one word every source + /// producer, the timeline producer and every plan's source-set driver of the session share. A + /// first plan allocates it; a successor takes its predecessor's from this row, so every plan of + /// one session, and every consumer it carries or creates, is fenced by the same object. + published_generation: PublishedGeneration, } /// One strip stage of a prepared plan, owner key `(strip ID, PostFader)` or `(strip ID, @@ -1051,6 +1056,8 @@ impl PlanStateInventory { raised_arrivals: Box::default(), // #1396 D2: no read-ahead. source_read_offset: 0, + // #1642 B16-R2: the same session. + published_generation: self.published_generation.clone(), } } @@ -1080,6 +1087,8 @@ impl PlanStateInventory { input_floors: self.input_floors.clone(), raised_arrivals: self.raised_arrivals.clone(), source_read_offset: 0, + // A fresh reference: a session of its own. + published_generation: PublishedGeneration::new(SourceGeneration(1)), } } @@ -1116,7 +1125,8 @@ impl PlanStateInventory { strip_stage_row(&self.strip_stages, id, owner) } - /// Heap bytes this inventory retains: its row tables and their ID text. + /// Heap bytes this inventory retains: its row tables, their ID text and the session's + /// published seek generation (#1642 B16-R2). #[must_use] pub fn retained_bytes(&self) -> u64 { let ids: usize = self.sources.iter().map(|row| row.id.len()).sum::() @@ -1178,6 +1188,9 @@ impl PlanStateInventory { + core::mem::size_of_val::<[Box]>(&self.restarted) + core::mem::size_of_val::<[(graph::GraphNodeId, u64)]>(&self.input_floors) + core::mem::size_of_val::<[(graph::GraphNodeId, u64)]>(&self.raised_arrivals) + // #1642 B16-R2: the session's published generation, charged where it is held + // across plans (every inventory of the session holds the one word). + + PublishedGeneration::retained_bytes() + ids, ) .unwrap_or(u64::MAX) @@ -2219,6 +2232,13 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( graph_sources .try_reserve_exact(compiled.source_count()) .map_err(|_| resource("host.resource.allocation"))?; + // #1642 B16-R2: the session's published seek generation. A first plan allocates it here, on + // the control thread; a successor shares its predecessor's, so every plan of the session and + // every producer feeding it publishes to, and every driver loads, the same word. + let published_generation = successor.map_or_else( + || PublishedGeneration::new(SourceGeneration(1)), + |base| base.inventory.published_generation.clone(), + ); let mut builder = ControlSourceBuilder::with_capacity(source_id_bytes, compiled.source_count()) .map_err(|()| resource("host.resource.allocation"))?; // Issue #1272 D3: the predecessor's committed source declarations, by ID. @@ -2273,9 +2293,12 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( graph_sources.push(SourceGraphSource::vacant(u32::from(source.channels))); carried_sources.push((index, predecessor_index)); } else { - let (producer, consumer, resources) = - PcmSourceRing::prepare_host_region(ring, SourceFrame(0)) - .map_err(|_| resource("host.source.prepare"))?; + let (producer, consumer, resources) = PcmSourceRing::prepare_host_region( + ring, + SourceFrame(0), + published_generation.clone(), + ) + .map_err(|_| resource("host.source.prepare"))?; builder.push( source.id.as_str(), compiled.sample_rate().0, @@ -2474,7 +2497,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( .map_err(|_| resource("host.resource.arithmetic"))?, ) .ok_or_else(|| resource("host.resource.arithmetic"))?; - let mut sources = builder.finish(); + let mut sources = builder.finish(published_generation.clone()); // Source semantics: tracks only. let mappings = model @@ -2942,8 +2965,9 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( let timeline = if timeline_carries { // D3, D7: a vacant timeline owns only its history; the command queue its carry brings is // counted with the carried source overhead, as a carried ring's is. - let (consumer, report) = SourceTimeline::prepare_vacant(quantum, history_blocks) - .map_err(|_| resource("host.source.prepare"))?; + let (consumer, report) = + SourceTimeline::prepare_vacant(quantum, history_blocks, published_generation.clone()) + .map_err(|_| resource("host.source.prepare"))?; carried_total_bytes = carried_total_bytes .checked_add(report.command_queue_bytes) .ok_or_else(|| resource("host.resource.arithmetic"))?; @@ -2954,14 +2978,20 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( carried_largest_bytes.max(report.command_queue_largest_allocation_bytes); consumer } else { - let (producer, consumer, _) = SourceTimeline::prepare(quantum, history_blocks) - .map_err(|_| resource("host.source.prepare"))?; + let (producer, consumer, _) = + SourceTimeline::prepare(quantum, history_blocks, published_generation.clone()) + .map_err(|_| resource("host.source.prepare"))?; sources.set_timeline(producer); consumer }; - let source_set = - prepare_graph_source_set(artifact.envelope(), graph_sources, timeline, mappings) - .map_err(|_| graph_failure("host.source.graph.prepare"))?; + let source_set = prepare_session_graph_source_set( + artifact.envelope(), + graph_sources, + timeline, + published_generation.clone(), + mappings, + ) + .map_err(|_| graph_failure("host.source.graph.prepare"))?; let source_resources = source_set.resource_report(); // Issue #1272 D4: a plan's source caps count the rings it allocated plus the rings it will // carry, so the active plan never exceeds them after the swap. @@ -3507,6 +3537,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( input_floors, raised_arrivals, source_read_offset, + published_generation: published_generation.clone(), }; let report = HostPrepareReport { sample_rate_hz: compiled.sample_rate().0, @@ -6163,6 +6194,7 @@ mod tests { raised_arrivals: Box::default(), // #1396 D2: no read-ahead. source_read_offset: 0, + published_generation: source::PublishedGeneration::new(source::SourceGeneration(1)), } } diff --git a/crates/host-core/src/source.rs b/crates/host-core/src/source.rs index fe0557b22..20543d456 100644 --- a/crates/host-core/src/source.rs +++ b/crates/host-core/src/source.rs @@ -11,8 +11,9 @@ use engine::SampleRateHz; use engine::realtime::PreparedRenderPlan; use session::SessionModel; use source::{ - HostChunkError, HostChunkProvider, HostPlanarChunk, SourceCommand, SourceFrame, - SourceGeneration, SourceSeekError, SourceSeekReport, SubmitReport, TimelineProducer, + HostChunkError, HostChunkProvider, HostPlanarChunk, PublishedGeneration, SessionSeek, + SessionSeekError, SourceCommand, SourceFrame, SourceGeneration, SourceSeekError, + SourceSeekReport, SubmitReport, TimelineProducer, }; use crate::PlanStateInventory; @@ -30,6 +31,59 @@ pub(crate) struct ControlSource { provider: Option, } +impl ControlSource { + /// The frame a session seek to `timeline_sample` sends this source: the sample clamped to + /// the source's region (#1642 D1). + fn session_frame(&self, timeline_sample: u64) -> u64 { + timeline_sample.min(self.region_end).max(self.region_start) + } +} + +/// Step 1 of [`SourceControlSet::seek_session`]: generation 0 is +/// [`SourceControlError::GenerationZero`], as for a per-source seek; a stale generation is the +/// word's [`SourceControlError::Seek`] refusal. +fn session_refusal(error: SourceSeekError) -> SourceControlError { + match error { + SourceSeekError::GenerationZero => SourceControlError::GenerationZero, + error => SourceControlError::Seek(error), + } +} + +/// Step 2 of [`SourceControlSet::seek_session`]: the timeline producer, then every source +/// producer in ID order, checks its command with nothing pushed; the first refusal is returned. A +/// vacant entry is [`SourceControlError::Vacated`]; a producer of another session's word is an +/// engine invariant ([`HostChunkError::InternalInvariant`]): every producer of the set is prepared +/// with the set's word. +fn check_session_producers( + seek: &SessionSeek<'_>, + timeline: Option<&TimelineProducer>, + sources: &[ControlSource], + timeline_sample: u64, +) -> Result<(), SourceControlError> { + let refused = |error| match error { + SessionSeekError::Seek(error) => SourceControlError::Seek(error), + SessionSeekError::ForeignSession => { + SourceControlError::Chunk(HostChunkError::InternalInvariant) + } + }; + seek.check_timeline( + timeline.ok_or(SourceControlError::Vacated)?, + timeline_sample, + ) + .map_err(refused)?; + for source in sources { + seek.check_source( + source + .provider + .as_ref() + .ok_or(SourceControlError::Vacated)?, + source.session_frame(timeline_sample), + ) + .map_err(refused)?; + } + Ok(()) +} + /// One borrowed planar chunk offered to a named source. /// /// Borrowed rather than owned: the facade copies once, into the ring, and never retains the planes. @@ -141,14 +195,23 @@ pub struct SourceControlSet { /// The session timeline's producer (#1641 D2). `None` while vacant: a successor's, until /// [`Self::adopt_persisting`] moves its predecessor's in, or a predecessor's once moved. timeline: Option, + /// The session's published seek generation (#1642 B16-R2), which every producer here + /// publishes to and every plan of the session loads at block entry: the same word in every + /// set of the session. + published: PublishedGeneration, } impl SourceControlSet { - pub(crate) fn new(ids: Box<[u8]>, sources: Box<[ControlSource]>) -> Self { + pub(crate) fn new( + ids: Box<[u8]>, + sources: Box<[ControlSource]>, + published: PublishedGeneration, + ) -> Self { Self { ids, sources, timeline: None, + published, } } @@ -158,7 +221,7 @@ impl SourceControlSet { } /// The session timeline's producer, for a test that seeks the timeline directly (#1641 - /// gates). `None` while vacant. Host-core has no public timeline seek; #1642 adds it. + /// gates). `None` while vacant. A host seeks the timeline with [`Self::seek_session`]. #[cfg(feature = "test-support")] pub fn test_only_timeline_producer(&mut self) -> Option<&mut TimelineProducer> { self.timeline.as_mut() @@ -262,6 +325,97 @@ impl SourceControlSet { self.queue_seek(id, generation, frame, Some(anchor_sample)) } + /// Seek the session's timeline and every source to timeline sample `timeline_sample`, under + /// one new `generation`, all or nothing (#1642 D1): the session seek both hosts call. + /// + /// The timeline goes to `timeline_sample` as given (up to [`source::TIMELINE_SAMPLE_LIMIT`]); + /// each source to `min(timeline_sample, its region end)`. With `anchor_sample` every command is + /// the anchored form of [`Self::seek_at`], with that anchor. The host then submits generation + /// `generation` from each source's frame. + /// + /// 1. Generation 0 is [`SourceControlError::GenerationZero`]; a generation not above the + /// session's published generation ([`Self::published_generation`]) is stale + /// (`source.generation.stale`), so a source removed at a higher generation cannot let a + /// command through early. + /// 2. The timeline producer and every source producer, in ID order, check their command with + /// no push: the first refusal is returned and nothing is pushed. A vacant entry is + /// [`SourceControlError::Vacated`]; a generation not above a producer's, a full slot, an + /// unaligned anchor and a timeline frame over the limit are that producer's + /// [`SourceControlError::Seek`] refusal. + /// 3. Every command is pushed, the timeline first, then the sources in ID order, none of them + /// published; then the generation is published once (B16-R2), so render observes all of + /// them at one block entry or none. Only this thread pushes to those slots and render only + /// empties them, so after step 2 no push can fail; one that fails anyway is an engine + /// invariant, [`HostChunkError::InternalInvariant`] (the pushed commands are published, so + /// none stays held in its slot). + /// + /// Acknowledged only when every consumer holds the seek in its slot: no ack precedes a drop. + pub fn seek_session( + &mut self, + generation: u64, + timeline_sample: u64, + anchor_sample: Option, + ) -> Result<(), SourceControlError> { + let mut seek = self + .published + .session_seek(generation, anchor_sample) + .map_err(session_refusal)?; + check_session_producers( + &seek, + self.timeline.as_ref(), + &self.sources, + timeline_sample, + )?; + // The source crate pushes unpublished and publishes once, when `seek` drops, whatever + // the pushes return: a pushed command never stays held in its slot. + let mut pushed = self + .timeline + .as_mut() + .is_some_and(|timeline| seek.push_timeline(timeline, timeline_sample).is_ok()); + for source in &mut self.sources { + let frame = source.session_frame(timeline_sample); + pushed &= source + .provider + .as_mut() + .is_some_and(|provider| seek.push_source(provider, frame).is_ok()); + } + seek.publish(); + if !pushed { + debug_assert!(false, "a checked session seek pushes to every slot"); + return Err(SourceControlError::Chunk(HostChunkError::InternalInvariant)); + } + Ok(()) + } + + /// Every rule of [`Self::seek_session`] (steps 1 and 2) with nothing pushed and nothing + /// published: the check a caller runs before it commits to the seek (B16-R9 declares the + /// discontinuity between this check and [`Self::seek_session`], which checks again). + pub fn check_session_seek( + &self, + generation: u64, + timeline_sample: u64, + anchor_sample: Option, + ) -> Result<(), SourceControlError> { + let seek = self + .published + .session_seek(generation, anchor_sample) + .map_err(session_refusal)?; + check_session_producers( + &seek, + self.timeline.as_ref(), + &self.sources, + timeline_sample, + ) + } + + /// The session's published seek generation (#1642 B16-R2): the largest generation any seek of + /// this session has published, 1 before the first. A session seek must be above it; a host + /// that chooses one above the largest generation it has used on the session always is. + #[must_use] + pub fn published_generation(&self) -> u64 { + self.published.generation().0 + } + /// Where the named source's newest generation landed, as render last published it (#1316 D7): /// the generation in effect, the source-read sample of the first block it played in and the /// source frame that block started at, plus the ring's cumulative underrun and held frames. @@ -361,6 +515,10 @@ impl SourceControlSet { if self.timeline.is_none() { self.timeline = predecessor.timeline.take(); } + debug_assert!( + self.published.is_shared_with(&predecessor.published), + "a successor's set shares its predecessor's published generation (#1642 B16-R2)" + ); moved } @@ -463,13 +621,17 @@ impl ControlSourceBuilder { }); } - pub(crate) fn finish(mut self) -> SourceControlSet { + pub(crate) fn finish(mut self, published: PublishedGeneration) -> SourceControlSet { let ids = self.ids; self.sources.sort_unstable_by(|left, right| { ids[left.id_offset..left.id_offset + left.id_bytes] .cmp(&ids[right.id_offset..right.id_offset + right.id_bytes]) }); - SourceControlSet::new(ids.into_boxed_slice(), self.sources.into_boxed_slice()) + SourceControlSet::new( + ids.into_boxed_slice(), + self.sources.into_boxed_slice(), + published, + ) } } @@ -791,3 +953,264 @@ pub fn apply_donation( debug_assert_eq!(producers, consumers, "both halves move every pair"); consumers } + +#[cfg(test)] +mod tests { + //! #1642 D1: the session seek, on a hand-built set of two sources and the timeline. + + use core::num::NonZeroUsize; + + use engine::QuantumFrames; + use source::{ + PcmSourceConsumer, PcmSourceRing, PcmSourceRingConfig, SourceTimeline, + TIMELINE_SAMPLE_LIMIT, TimelineConsumer, + }; + + use super::*; + + const Q: u32 = 4; + const LONG: &[u8] = b"long"; + const SHORT: &[u8] = b"short"; + + /// A set of `long` (10,000 frames) and `short` (4,000 frames) and the timeline, all sharing + /// one published generation, and their consumers, rendered here by hand. + struct Rig { + set: SourceControlSet, + long: PcmSourceConsumer, + short: PcmSourceConsumer, + timeline: TimelineConsumer, + next: u64, + } + + impl Rig { + fn new() -> Self { + let word = PublishedGeneration::new(SourceGeneration(1)); + let ring = PcmSourceRingConfig { + channel_count: 1, + quantum_frames: QuantumFrames(Q), + frame_capacity: 16, + initial_generation: SourceGeneration(1), + }; + let mut builder = ControlSourceBuilder::with_capacity(9, 2).expect("builder"); + let mut consumers = Vec::new(); + for (id, frames) in [("long", 10_000), ("short", 4_000)] { + let (producer, consumer, _) = + PcmSourceRing::prepare_host_region(ring, SourceFrame(0), word.clone()) + .expect("ring"); + builder.push( + id, + 48_000, + 1, + 0, + frames, + Some(producer.into_host_chunk_provider(SampleRateHz(48_000))), + ); + consumers.push(consumer); + } + let mut set = builder.finish(word.clone()); + let (producer, timeline, _) = + SourceTimeline::prepare(QuantumFrames(Q), NonZeroUsize::MIN, word) + .expect("timeline"); + set.set_timeline(producer); + let short = consumers.pop().expect("short"); + let long = consumers.pop().expect("long"); + Self { + set, + long, + short, + timeline, + next: 0, + } + } + + /// Render one block of every consumer; returns its source-read sample. + fn block(&mut self) -> u64 { + let sample = self.next; + self.timeline.begin_block_at(sample); + self.long.begin_block_at(sample); + self.short.begin_block_at(sample); + self.next += u64::from(Q); + sample + } + + /// `(generation, source frame)` each source's report names, then the timeline at `sample`. + fn landed(&self, sample: u64) -> ((u64, u64), (u64, u64), i64) { + let report = |id| { + let report = self.set.seek_report(id).expect("report"); + (report.generation, report.source_frame) + }; + ( + report(LONG), + report(SHORT), + self.timeline.timeline_at(sample), + ) + } + + /// Whether every producer still takes a seek of `generation`: no slot is full. + fn every_slot_empty(&self, generation: u64) -> bool { + let command = SourceCommand::Seek { + generation: SourceGeneration(generation), + frame: SourceFrame(0), + }; + self.set + .timeline + .as_ref() + .is_some_and(|timeline| timeline.check_seek(command).is_ok()) + && self.set.sources.iter().all(|source| { + source + .provider + .as_ref() + .is_some_and(|provider| provider.check_seek(command).is_ok()) + }) + } + } + + /// Test value: red if a source is not clamped to its region, the timeline is, a consumer + /// takes the seek in another block, or the anchored form applies before its anchor. + #[test] + fn both_forms_move_the_timeline_and_every_source_clamped_to_its_region() { + let mut rig = Rig::new(); + rig.block(); + rig.set.seek_session(2, 6_000, None).expect("session seek"); + assert_eq!(rig.set.published_generation(), 2); + let sample = rig.block(); + assert_eq!(rig.landed(sample), ((2, 6_000), (2, 4_000), 6_000)); + + let anchor = rig.next + 2 * u64::from(Q); + rig.set + .seek_session(3, 1_000, Some(anchor)) + .expect("anchored session seek"); + for _ in 0..2 { + let sample = rig.block(); + let ((long, _), (short, _), _) = rig.landed(sample); + assert_eq!((long, short), (2, 2), "held before the anchor"); + } + let sample = rig.block(); + assert_eq!(sample, anchor); + assert_eq!(rig.landed(sample), ((3, 1_000), (3, 1_000), 1_000)); + // The timeline is not clamped to any region. + rig.set + .seek_session(4, 50_000, None) + .expect("past every region"); + let sample = rig.block(); + assert_eq!(rig.landed(sample), ((4, 10_000), (4, 4_000), 50_000)); + } + + /// Test value: red if any slot is pushed before every check passes (a partial seek). Each + /// stale case here is also a live producer's own refusal, so the word's rule is + /// `a_generation_not_above_the_word_is_stale_when_every_producer_takes_it`'s. + #[test] + fn a_refusal_pushes_nothing_and_publishes_nothing() { + let mut rig = Rig::new(); + let stale = SourceControlError::Seek(SourceSeekError::GenerationNotStrictlyIncreasing { + active: SourceGeneration(1), + requested: SourceGeneration(1), + }); + assert_eq!( + rig.set.seek_session(0, 0, None), + Err(SourceControlError::GenerationZero) + ); + assert_eq!(rig.set.seek_session(1, 0, None), Err(stale)); + assert_eq!(stale.diagnostic(), "source.generation.stale"); + assert_eq!( + rig.set.seek_session(2, 0, Some(1)), + Err(SourceControlError::Seek(SourceSeekError::AnchorUnaligned)) + ); + assert_eq!( + rig.set.seek_session(2, TIMELINE_SAMPLE_LIMIT + 1, None), + Err(SourceControlError::Seek(SourceSeekError::FrameOutOfRange)) + ); + assert_eq!(rig.set.published_generation(), 1); + assert!(rig.every_slot_empty(2)); + + // One full slot refuses all: the short source holds a per-source seek. + rig.set.seek(SHORT, 2, 10).expect("per-source seek"); + assert_eq!( + rig.set.published_generation(), + 2, + "a per-source seek publishes" + ); + let refused = rig.set.seek_session(3, 0, None); + assert!( + matches!( + refused, + Err(SourceControlError::Seek( + SourceSeekError::Backpressure { .. } + )) + ), + "{refused:?}" + ); + assert_eq!(rig.set.published_generation(), 2); + // The timeline's and the long source's slots stayed empty. + rig.set + .seek(LONG, 3, 20) + .expect("the long source's slot is empty"); + let sample = rig.block(); + let (long, short, timeline) = rig.landed(sample); + assert_eq!((long, short, timeline), ((3, 20), (2, 10), 0)); + + // A per-source seek at 5 makes a session seek at 4 stale. + rig.set.seek(LONG, 5, 0).expect("per-source seek"); + assert_eq!( + rig.set.seek_session(4, 0, None), + Err(SourceControlError::Seek( + SourceSeekError::GenerationNotStrictlyIncreasing { + active: SourceGeneration(5), + requested: SourceGeneration(4), + } + )) + ); + } + + /// Test value: red if the session seek is not refused against the session's word when every + /// live producer would take it (the case of a source removed at a higher generation), or the + /// check and the seek disagree on it. + #[test] + fn a_generation_not_above_the_word_is_stale_when_every_producer_takes_it() { + let mut rig = Rig::new(); + rig.set.published.publish(SourceGeneration(10)); + assert!(rig.every_slot_empty(5), "every live producer takes 5"); + let stale = Err(SourceControlError::Seek( + SourceSeekError::GenerationNotStrictlyIncreasing { + active: SourceGeneration(10), + requested: SourceGeneration(5), + }, + )); + assert_eq!(rig.set.check_session_seek(5, 0, None), stale); + assert_eq!(rig.set.seek_session(5, 0, None), stale); + assert!(rig.every_slot_empty(5), "nothing pushed"); + assert_eq!(rig.set.published_generation(), 10, "nothing published"); + assert_eq!(rig.set.check_session_seek(11, 0, None), Ok(())); + assert!(rig.every_slot_empty(11), "a check pushes nothing"); + assert_eq!( + rig.set.published_generation(), + 10, + "a check publishes nothing" + ); + rig.set.seek_session(11, 0, None).expect("above the word"); + assert_eq!(rig.set.published_generation(), 11); + } + + /// Test value: red if a vacant entry is skipped instead of refusing the whole seek. + #[test] + fn a_vacant_entry_refuses_the_whole_seek() { + let mut rig = Rig::new(); + let timeline = rig.set.timeline.take(); + assert_eq!( + rig.set.seek_session(2, 0, None), + Err(SourceControlError::Vacated) + ); + rig.set.timeline = timeline; + let provider = rig.set.sources[1].provider.take(); + assert_eq!( + rig.set.seek_session(2, 0, None), + Err(SourceControlError::Vacated) + ); + rig.set.sources[1].provider = provider; + assert!(rig.every_slot_empty(2)); + assert_eq!(rig.set.published_generation(), 1); + rig.set + .seek_session(2, 0, None) + .expect("every entry occupied"); + } +} diff --git a/crates/host-core/tests/session_seek.rs b/crates/host-core/tests/session_seek.rs new file mode 100644 index 000000000..6c9459045 --- /dev/null +++ b/crates/host-core/tests/session_seek.rs @@ -0,0 +1,335 @@ +//! Issue #1642, gate 1b (B16-R2): a session seek is one committed act, so the timeline and every +//! source take it in the same render block, while the session thread seeks concurrently with the +//! render thread. +//! +//! The session thread pushes 10,000 plain and anchored session seeks, each one generation newer, +//! through [`SourceControlSet::seek_session`], while a render thread renders the plan back to back. +//! The render thread records every block whose timeline value is not the previous block's plus a +//! quantum; the session thread reads each source's seek report (the block its generation started +//! at and the frame it started from). Every seek must be one timeline step, in the block both +//! sources started it at, at the frames the seek names. No PCM is fed: a seek applies with or +//! without it. + +#![cfg(feature = "test-support")] + +#[path = "support/successor.rs"] +mod successor; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use bench_support::alloc as bench_alloc; +use engine::realtime::{PlanarBufferMut, PreparedRenderPlan, RenderIo}; +use graph_compiler::Backend; +use host_core::{SourceControlSet, test_only_timeline_at}; +use session::{RouteSource, SessionModel, Source, StableId, parse_session_json}; +use successor::{QUANTUM, Session}; + +const FIXTURE: &str = include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); +const LONG: &str = "fixture-source"; +const SHORT: &str = "short-source"; +const LONG_FRAMES: u64 = 10_000; +const SHORT_FRAMES: u64 = 4_000; +const Q: u64 = QUANTUM as u64; +const SEEKS: usize = 10_000; +/// The span after a block's start the session thread pushes within. +const PUSH_WINDOW_NS: u64 = 40_000; + +fn id(value: &str) -> StableId { + StableId::parse(value).expect("stable ID") +} + +/// Track `eq0` on [`LONG`] (10,000 frames) and track `eq1` on [`SHORT`] (4,000 frames), each with +/// its own route, no effect and no filter. +fn two_sources() -> SessionModel { + let mut model: SessionModel = parse_session_json(FIXTURE).expect("fixture parses"); + model.console.pre_insert.clear(); + model.console.post_insert.clear(); + model.tracks.retain(|track| track.id.as_str() == "eq0"); + for track in &mut model.tracks { + track.console.clear(); + track.inserts.effects.clear(); + for channel in [&mut track.builtins.left, &mut track.builtins.right] { + channel.hpf_hz = 0.0; + channel.lpf_hz = 0.0; + } + } + model.routes.retain(|route| route.id.as_str() == "eq0-main"); + model.sources[0].frames = LONG_FRAMES; + let template = model.sources[0].clone(); + model.sources.push(Source { + id: id(SHORT), + content: template.content.replace("7e94", "0a0a"), + frames: SHORT_FRAMES, + ..template + }); + let mut short = model.tracks[0].clone(); + short.id = id("eq1"); + short.source_id = id(SHORT); + model.tracks.push(short); + let mut route = model.routes[0].clone(); + route.id = id("eq1-main"); + let RouteSource::Track { track_id, .. } = &mut route.source else { + panic!("a track route"); + }; + *track_id = id("eq1"); + model.routes.push(route); + model +} + +fn backends() -> Vec { + vec![ + #[cfg(target_feature = "avx2")] + Backend::Simd8, + Backend::Simd4, + ] +} + +/// One session seek as the session thread pushed it, and where each source's report says it +/// landed: `(first sample, source frame)`. +struct Seek { + timeline_sample: u64, + anchor: Option, + long: (u64, u64), + short: (u64, u64), +} + +/// What the render thread saw: every timeline step `(block sample, timeline value)`, its +/// allocator counts over the measured blocks, and how many blocks it rendered. +struct Rendered { + plan: PreparedRenderPlan, + steps: Vec<(u64, i64)>, + overflow: bool, + allocator: (u64, u64, u64), + blocks: u64, +} + +fn render_block(plan: &mut PreparedRenderPlan, output: &mut [f32]) -> u64 { + let sample = plan.next_absolute_sample(); + plan.render_contiguous( + RenderIo { + output: PlanarBufferMut::try_new(output, 2, QUANTUM, QUANTUM).expect("output"), + }, + sample, + ) + .unwrap_or_else(|error| panic!("render: {error:?}")); + sample +} + +/// Render back to back until `stop`, publishing the next block's sample in `position`. +fn render_thread( + mut plan: PreparedRenderPlan, + stop: Arc, + position: Arc, +) -> Rendered { + let mut output = vec![0.0_f32; QUANTUM * 2]; + engine::realtime::audit::warm_up(); + let mut expected = 0_i64; + for _ in 0..4 { + let sample = render_block(&mut plan, &mut output); + expected = test_only_timeline_at(&mut plan, sample).expect("timeline") + Q as i64; + } + position.store(plan.next_absolute_sample(), Ordering::Release); + let mut steps = Vec::with_capacity(SEEKS + 64); + let mut overflow = false; + let mut blocks = 0_u64; + let mark = bench_alloc::current_thread_counters(); + while !stop.load(Ordering::Acquire) { + let sample = render_block(&mut plan, &mut output); + let value = test_only_timeline_at(&mut plan, sample).expect("timeline"); + if value != expected { + if steps.len() < steps.capacity() { + steps.push((sample, value)); + } else { + overflow = true; + } + } + expected = value + Q as i64; + blocks += 1; + position.store(sample + Q, Ordering::Release); + } + let delta = bench_alloc::current_thread_delta_since(mark); + Rendered { + plan, + steps, + overflow, + allocator: (delta.allocations, delta.reallocations, delta.deallocations), + blocks, + } +} + +/// The source's report once it names `generation`: `(first sample, source frame)`. +fn landed(sources: &SourceControlSet, id: &str, generation: u64) -> Option<(u64, u64)> { + let report = sources.seek_report(id.as_bytes()).ok()?; + assert!(report.generation <= generation, "{id}: a newer generation"); + (report.generation == generation).then_some((report.first_sample, report.source_frame)) +} + +/// Push [`SEEKS`] session seeks, each after the one before it has landed on both sources. +fn session_thread(sources: &mut SourceControlSet, position: &AtomicU64) -> Vec { + let mut seeks = Vec::with_capacity(SEEKS); + let mut state = 0x2545_f491_4f6c_dd1d_u64; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for index in 0..SEEKS { + let generation = index as u64 + 2; + // Residue `generation mod Q`, which the previous seek's never shares, so every seek is a + // visible timeline step; below 10,000, so the long source never clamps. + let timeline_sample = (next() % 78) * Q + generation % Q; + let anchor = (next() % 2 == 0).then(|| { + let blocks_ahead = next() % 4; + (position.load(Ordering::Acquire) / Q + blocks_ahead) * Q + }); + // Push at a random instant shortly after a block begins, where the render's source set + // pops the timeline and then each source: a push there lands between two of the pops. + let seen = position.load(Ordering::Acquire); + while position.load(Ordering::Acquire) == seen { + core::hint::spin_loop(); + } + let wait = Duration::from_nanos(next() % PUSH_WINDOW_NS); + let start = Instant::now(); + while start.elapsed() < wait { + core::hint::spin_loop(); + } + loop { + match sources.seek_session(generation, timeline_sample, anchor) { + Ok(()) => break, + Err(error) if error.is_backpressure() => thread::yield_now(), + Err(error) => panic!("seek {generation}: {error:?}"), + } + } + let (long, short) = loop { + if let (Some(long), Some(short)) = ( + landed(sources, LONG, generation), + landed(sources, SHORT, generation), + ) { + break (long, short); + } + thread::yield_now(); + }; + seeks.push(Seek { + timeline_sample, + anchor, + long, + short, + }); + } + seeks +} + +fn run(backend: Backend) { + let session = Session::compile(two_sources()); + let host = session.prepare(backend); + let mut sources = host.sources; + let stop = Arc::new(AtomicBool::new(false)); + let position = Arc::new(AtomicU64::new(0)); + let renderer = { + let (stop, position) = (Arc::clone(&stop), Arc::clone(&position)); + let plan = host.plan; + thread::spawn(move || render_thread(plan, stop, position)) + }; + while position.load(Ordering::Acquire) == 0 { + thread::yield_now(); + } + let seeks = session_thread(&mut sources, &position); + stop.store(true, Ordering::Release); + let rendered = renderer.join().expect("the render thread"); + + assert_eq!( + rendered.allocator, + (0, 0, 0), + "{backend:?}: the render thread allocates and frees nothing over {} blocks", + rendered.blocks + ); + assert!( + !rendered.overflow, + "{backend:?}: more timeline steps than seeks" + ); + assert_eq!( + rendered.steps.len(), + seeks.len(), + "{backend:?}: one timeline step per seek" + ); + let mut anchored = 0; + let mut late = 0; + for (index, (seek, &(sample, value))) in seeks.iter().zip(&rendered.steps).enumerate() { + let generation = index as u64 + 2; + assert_eq!( + (seek.long.0, seek.short.0), + (sample, sample), + "{backend:?}: seek {generation} split across blocks: timeline at {sample}, long at {}, \ + short at {}", + seek.long.0, + seek.short.0 + ); + let lateness = match seek.anchor { + None => 0, + Some(anchor) => { + anchored += 1; + assert!( + sample >= anchor, + "{backend:?}: seek {generation} before its anchor" + ); + if sample > anchor { + late += 1; + } + sample - anchor + } + }; + assert_eq!( + value, + (seek.timeline_sample + lateness) as i64, + "{backend:?}: seek {generation}'s timeline value" + ); + assert_eq!( + (seek.long.1, seek.short.1), + ( + seek.timeline_sample.min(LONG_FRAMES) + lateness, + seek.timeline_sample.min(SHORT_FRAMES) + lateness + ), + "{backend:?}: seek {generation}'s source frames" + ); + } + // Not vacuous: both forms ran, on time and late, and the short source clamped. + assert!( + anchored > SEEKS / 4 && anchored < SEEKS * 3 / 4, + "{anchored} anchored" + ); + assert!(late > 0, "an anchored seek observed late"); + assert!( + seeks + .iter() + .any(|seek| seek.anchor.is_none() && seek.timeline_sample > SHORT_FRAMES), + "a plain seek past the short source's end" + ); + drop(rendered.plan); +} + +/// Gate 1b. +/// +/// Test value: red if a consumer observes a session seek's command before the session publishes +/// it (the timeline and the sources pop at different instants while the session thread pushes, so +/// a seek pushed during a block splits across two blocks), if the render thread allocates or +/// frees, or if a source is not clamped or the anchor not kept. +#[test] +fn concurrent_session_seeks_reach_the_timeline_and_every_source_in_one_block() { + use bench_support::alloc::{Mode, assert_installed, mode, set_mode}; + struct RestoreMode(Mode); + impl Drop for RestoreMode { + fn drop(&mut self) { + set_mode(self.0); + } + } + assert_installed(); + let _restore = RestoreMode(mode()); + set_mode(Mode::Count); + for backend in backends() { + run(backend); + } +} diff --git a/crates/host-core/tests/timeline.rs b/crates/host-core/tests/timeline.rs index 869fa9b76..4ff409b23 100644 --- a/crates/host-core/tests/timeline.rs +++ b/crates/host-core/tests/timeline.rs @@ -213,21 +213,21 @@ fn producer(sources: &mut SourceControlSet) -> &mut TimelineProducer { .expect("the set holds the timeline producer") } -/// Push `command` to the source (through its control set) and to the timeline (through its -/// producer directly). +/// Seek the source and the timeline with one session seek (#1642 D1): `command`'s generation, +/// its frame as the timeline sample (inside the source's region, so the source takes it too) and +/// its anchor, if any. fn seek_both(sources: &mut SourceControlSet, command: SourceCommand) { - match command { - SourceCommand::Seek { generation, frame } => { - sources.seek(SOURCE.as_bytes(), generation.0, frame.0) - } + let (generation, frame, anchor) = match command { + SourceCommand::Seek { generation, frame } => (generation, frame, None), SourceCommand::SeekAt { generation, frame, anchor_sample, - } => sources.seek_at(SOURCE.as_bytes(), generation.0, frame.0, anchor_sample), - } - .expect("source seek"); - producer(sources).try_seek(command).expect("timeline seek"); + } => (generation, frame, Some(anchor_sample)), + }; + sources + .seek_session(generation.0, frame.0, anchor) + .expect("session seek"); } /// Render `blocks` blocks of `plan` (whose source-read clock leads its render clock by `offset`) @@ -704,12 +704,18 @@ fn a_discontinuity_keeps_the_sources_and_the_timeline_in_step() { #[test] fn a_prime_advances_the_timeline_as_it_advances_a_source() { let quantum = QuantumFrames(QUANTUM as u32); - let (producer, mut source, _) = PcmSourceRing::prepare(PcmSourceRingConfig { - channel_count: 1, - quantum_frames: quantum, - frame_capacity: 8 * Q, - initial_generation: SourceGeneration(1), - }) + // One session: the ring and the timeline share its published generation (#1642 B16-R2). + let word = source::PublishedGeneration::new(SourceGeneration(1)); + let (producer, mut source, _) = PcmSourceRing::prepare_host_region( + PcmSourceRingConfig { + channel_count: 1, + quantum_frames: quantum, + frame_capacity: 8 * Q, + initial_generation: SourceGeneration(1), + }, + SourceFrame(0), + word.clone(), + ) .expect("ring"); let mut host = producer.into_host_chunk_provider(engine::SampleRateHz(48_000)); for start in (0..6 * Q).step_by(QUANTUM) { @@ -725,7 +731,8 @@ fn a_prime_advances_the_timeline_as_it_advances_a_source() { .expect("source PCM"); } let (mut timeline_producer, mut timeline, _) = - SourceTimeline::prepare(quantum, NonZeroUsize::new(4).expect("four")).expect("timeline"); + SourceTimeline::prepare(quantum, NonZeroUsize::new(4).expect("four"), word) + .expect("timeline"); // Two ordinary blocks. for sample in [0, Q] { source.begin_block_at(sample); diff --git a/crates/source/src/lib.rs b/crates/source/src/lib.rs index cbc080f0a..63a393b5b 100644 --- a/crates/source/src/lib.rs +++ b/crates/source/src/lib.rs @@ -12,7 +12,7 @@ use core::{ cmp, mem::size_of, num::NonZeroUsize, - sync::atomic::{AtomicBool, AtomicUsize, Ordering}, + sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, }; use std::{fmt, sync::Arc}; @@ -639,29 +639,42 @@ impl PcmSourceRing { }) } - /// Allocate the exact fixed source-ring resources and split producer/render ownership. + /// Allocate the exact fixed source-ring resources and split producer/render ownership, for a + /// ring that is a session of its own: it allocates its own published generation (#1642 + /// B16-R2), at `config.initial_generation`, which its producer publishes to and its consumer + /// loads. A ring of a multi-source session is prepared with [`Self::prepare_host_region`] and + /// the session's word. The word's bytes are the session's, so no row of the report counts + /// them. pub fn prepare( config: PcmSourceRingConfig, ) -> Result<(PcmSourceProducer, PcmSourceConsumer, SourceResourceReport), PcmSourceRingError> { - Self::prepare_at_source_frame(config, SourceFrame(0)) + Self::prepare_at_source_frame( + config, + SourceFrame(0), + PublishedGeneration::new(config.initial_generation), + ) } - /// Allocate a host-fed ring whose first accepted chunk begins at `initial_frame`. + /// Allocate a host-fed ring of the session whose published generation is `published` (#1642 + /// B16-R2), whose first accepted chunk begins at `initial_frame`. Every seek of its producer + /// publishes to `published`, and its consumer observes no command above it. /// /// Shape validation, allocation, ownership, and resource accounting are identical to - /// [`Self::prepare`]; only the initial absolute source position differs. + /// [`Self::prepare`]; only the initial absolute source position and the word differ. pub fn prepare_host_region( config: PcmSourceRingConfig, initial_frame: SourceFrame, + published: PublishedGeneration, ) -> Result<(PcmSourceProducer, PcmSourceConsumer, SourceResourceReport), PcmSourceRingError> { - Self::prepare_at_source_frame(config, initial_frame) + Self::prepare_at_source_frame(config, initial_frame, published) } pub(crate) fn prepare_at_source_frame( config: PcmSourceRingConfig, initial_frame: SourceFrame, + published: PublishedGeneration, ) -> Result<(PcmSourceProducer, PcmSourceConsumer, SourceResourceReport), PcmSourceRingError> { let shape = PreparedShape::validate(config)?; @@ -720,6 +733,10 @@ impl PcmSourceRing { published_seek_report: SourceSeekReport::default(), next_source_read_sample: 0, prime_required: Arc::clone(&prime_required), + // Every entry point sets it before it is read; until then it fences at the initial + // generation, which holds every command. + observed_published: config.initial_generation.0, + published: published.clone(), }; // The producer starts with exactly the configured blocks; the retained block above never // enters the recycle queue until the consumer has a played block to hold instead. @@ -739,6 +756,7 @@ impl PcmSourceRing { active_generation: config.initial_generation, quantum_frames: config.quantum_frames.0, frame_limit: None, + published, }, shape: consumer.shape, channel_count: config.channel_count, @@ -868,6 +886,249 @@ fn prime_flag_retained_bytes() -> usize { Layout::new::().size() } +/// One session's published seek generation (#1642 B16-R2): the fence that makes every command of +/// one session seek visible to render at the same block entry. +/// +/// One word per session, allocated on the control thread when the session's first plan is +/// prepared, and shared by every source producer, the timeline producer and the source-set driver +/// of every plan of that session: a successor's preparation takes its predecessor's word, so a +/// fresh and a carried consumer of one plan are fenced by the same object. +/// +/// * Every seek push is followed by [`Self::publish`] of its generation, a `Release` +/// read-modify-write to `max(word, g)`: a per-source seek right after its one push, a session +/// seek only after its last push. +/// * Render loads the word once per block entry with `Acquire`, before the timeline and the +/// sources, and each consumer leaves in its queue (by `peek`, never `pop`) a command whose +/// generation is above the loaded value. The command keeps its slot, so nothing is dropped and a +/// later push to that slot is still backpressure; it is observed at the first block whose load +/// covers it. +/// +/// A load that returns at least `g` read the publication of `g` or a later one of the same +/// control thread, so every push sequenced before it is visible: either every consumer of a +/// session seek takes it at a block entry or none does. A word never decreases. +#[derive(Clone)] +pub struct PublishedGeneration(Arc); + +impl PublishedGeneration { + /// A new session's word at `initial`, the generation every prepared ring starts at. Control + /// thread only: it allocates. + #[must_use] + pub fn new(initial: SourceGeneration) -> Self { + Self(Arc::new(AtomicU64::new(initial.0))) + } + + /// Exact bytes of the word's shared allocation: `Arc`'s `{ strong, weak, data }`. + #[must_use] + pub fn retained_bytes() -> usize { + #[repr(C)] + struct SharedAllocation { + strong: AtomicUsize, + weak: AtomicUsize, + word: AtomicU64, + } + Layout::new::().size() + } + + /// The newest generation published on this session. Control thread: its only writer. + #[must_use] + pub fn generation(&self) -> SourceGeneration { + SourceGeneration(self.0.load(Ordering::Relaxed)) + } + + /// Publish `generation` after the last push it covers: the word becomes `max(word, + /// generation)` with `Release`, so a block entry whose load covers it sees every such push. + pub fn publish(&self, generation: SourceGeneration) { + self.0.fetch_max(generation.0, Ordering::Release); + } + + /// Whether `self` and `other` are the same session's word. + #[must_use] + pub fn is_shared_with(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } + + /// The block entry's load (render): one `Acquire` load, wait-free. + fn load_at_block_entry(&self) -> u64 { + self.0.load(Ordering::Acquire) + } +} + +/// Identity: two words are equal when they are one session's. +impl PartialEq for PublishedGeneration { + fn eq(&self, other: &Self) -> bool { + self.is_shared_with(other) + } +} + +impl Eq for PublishedGeneration {} + +impl fmt::Debug for PublishedGeneration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("PublishedGeneration") + .field(&self.0.load(Ordering::Relaxed)) + .finish() + } +} + +/// One session seek (#1642 D1, B16-R2): the only path that pushes a seek without publishing it. +/// +/// [`PublishedGeneration::session_seek`] opens it for one generation and anchor, after the +/// session rules; [`Self::check_source`] and [`Self::check_timeline`] check one producer's command +/// with nothing pushed; [`Self::push_source`] and [`Self::push_timeline`] push one, unpublished, so +/// render observes none of them yet. [`Self::publish`], or the drop, publishes the generation once +/// if anything was pushed, so render observes every pushed command at one block entry, and never +/// leaves one held in its slot. A caller checks every producer before it pushes to any, so the +/// seek is all or nothing. +/// +/// Every producer must be of this word's session: one of another session is +/// [`SessionSeekError::ForeignSession`], checked and pushed nothing. +pub struct SessionSeek<'a> { + published: &'a PublishedGeneration, + generation: SourceGeneration, + anchor_sample: Option, + pushed: bool, +} + +/// A refusal of one [`SessionSeek`] check or push. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionSeekError { + /// The producer's own refusal. + Seek(SourceSeekError), + /// The producer publishes to another session's word: render would never observe its seek + /// under this one. + ForeignSession, +} + +impl PublishedGeneration { + /// Open a session seek to `generation`, plain or, with `anchor_sample`, anchored (#1642 D1). + /// Generation 0 is [`SourceSeekError::GenerationZero`]; a generation not above this word is + /// stale ([`SourceSeekError::GenerationNotStrictlyIncreasing`] against the word), so a + /// source removed at a higher generation cannot let a command through early. Opening it + /// changes nothing. + pub fn session_seek( + &self, + generation: u64, + anchor_sample: Option, + ) -> Result, SourceSeekError> { + let generation = + SourceGeneration::new(generation).ok_or(SourceSeekError::GenerationZero)?; + let published = self.generation(); + if generation <= published { + return Err(SourceSeekError::GenerationNotStrictlyIncreasing { + active: published, + requested: generation, + }); + } + Ok(SessionSeek { + published: self, + generation, + anchor_sample, + pushed: false, + }) + } +} + +impl SessionSeek<'_> { + /// The seek's generation. + #[must_use] + pub const fn generation(&self) -> SourceGeneration { + self.generation + } + + /// The command this seek sends a producer at `frame`. + #[must_use] + pub const fn command(&self, frame: u64) -> SourceCommand { + match self.anchor_sample { + None => SourceCommand::Seek { + generation: self.generation, + frame: SourceFrame(frame), + }, + Some(anchor_sample) => SourceCommand::SeekAt { + generation: self.generation, + frame: SourceFrame(frame), + anchor_sample, + }, + } + } + + fn own(&self, seek: &SeekProducer) -> Result<(), SessionSeekError> { + if seek.published == *self.published { + Ok(()) + } else { + Err(SessionSeekError::ForeignSession) + } + } + + /// Check `provider`'s command at `frame`, pushing nothing ([`PcmSourceProducer::check_seek`]). + pub fn check_source( + &self, + provider: &HostChunkProvider, + frame: u64, + ) -> Result<(), SessionSeekError> { + self.own(&provider.producer.seek)?; + provider + .check_seek(self.command(frame)) + .map_err(SessionSeekError::Seek) + } + + /// Check the timeline's command at `frame`, pushing nothing ([`TimelineProducer::check_seek`]). + pub fn check_timeline( + &self, + timeline: &TimelineProducer, + frame: u64, + ) -> Result<(), SessionSeekError> { + self.own(&timeline.seek)?; + timeline + .check_seek(self.command(frame)) + .map_err(SessionSeekError::Seek) + } + + /// Push `provider`'s command at `frame`, unpublished; its rules are + /// [`PcmSourceProducer::try_seek`]'s, and the host submits this generation's PCM from `frame`. + pub fn push_source( + &mut self, + provider: &mut HostChunkProvider, + frame: u64, + ) -> Result<(), SessionSeekError> { + self.own(&provider.producer.seek)?; + provider + .producer + .push_seek(self.command(frame)) + .map_err(SessionSeekError::Seek)?; + self.pushed = true; + Ok(()) + } + + /// Push the timeline's command at `frame`, unpublished; its rules are + /// [`TimelineProducer::try_seek`]'s. + pub fn push_timeline( + &mut self, + timeline: &mut TimelineProducer, + frame: u64, + ) -> Result<(), SessionSeekError> { + self.own(&timeline.seek)?; + timeline + .seek + .push(self.command(frame)) + .map_err(SessionSeekError::Seek)?; + self.pushed = true; + Ok(()) + } + + /// Publish the generation, if anything was pushed: render observes every pushed command at + /// the first block entry whose load covers it. The drop does the same. + pub fn publish(self) {} +} + +impl Drop for SessionSeek<'_> { + fn drop(&mut self) { + if self.pushed { + self.published.publish(self.generation); + } + } +} + /// Name for the prepared producer when it is used solely to control source seeks. pub type SourceController = PcmSourceProducer; @@ -893,11 +1154,17 @@ impl PcmSourceProducer { /// Either command switches this producer to the new generation at once, so the host submits /// that generation's PCM from `frame` straight away. pub fn try_seek(&mut self, command: SourceCommand) -> Result<(), SourceSeekError> { - self.seek.try_seek(command)?; + let generation = self.push_seek(command)?; + self.seek.published.publish(generation); + Ok(()) + } + + fn push_seek(&mut self, command: SourceCommand) -> Result { + let generation = self.seek.push(command)?; let (SourceCommand::Seek { frame, .. } | SourceCommand::SeekAt { frame, .. }) = command; self.next_write_frame = frame; self.end_of_region_submitted = false; - Ok(()) + Ok(generation) } /// Whether [`Self::try_seek`] would accept `command` now, pushing nothing and changing @@ -1187,6 +1454,14 @@ pub struct PcmSourceConsumer { next_source_read_sample: u64, /// The ring's `prime_required` flag, which control sets (#1320 D4). prime_required: Arc, + /// The session's published generation this block observes commands under (#1642 B16-R2): the + /// block entry's load of [`Self::published`], set by every entry point that observes a + /// command, the source-set driver's one load for the whole set. Render-owned. + observed_published: u64, + /// The session's published generation (#1642 B16-R2), the word this ring's producer publishes + /// to. A consumer on its own loads it at each block entry; the source-set driver loads the + /// same word once for every consumer of the set. + published: PublishedGeneration, } /// A [`SourceCommand::SeekAt`] the consumer has popped and holds until its anchor block. @@ -1367,6 +1642,29 @@ impl SeekState { .held_seek .is_none_or(|held| generation > held.generation) } + + /// The pop of the observe step every source-transport consumer shares (#1642 B16-R2): the + /// command at the head of `queue`, popped, when its generation is at most `published`, the + /// block entry's load of the session's published generation; otherwise `None`, and the + /// command stays in its slot for the first block whose load covers it. It peeks first and + /// pops only what it peeked (only this consumer pops), so a command pushed after the peek is + /// never taken here. Nothing is dropped. Wait-free: at most two loads and one pop. + fn take_published( + queue: &mut Consumer, + published: u64, + ) -> Option { + let generation = match queue.peek(0) { + Some( + &(SourceCommand::Seek { generation, .. } + | SourceCommand::SeekAt { generation, .. }), + ) => generation, + None => return None, + }; + if generation.0 > published { + return None; + } + queue.try_pop().ok() + } // REALTIME_POLICY_END } @@ -1382,6 +1680,9 @@ struct SeekProducer { /// The largest frame a seek may name, if any: [`TIMELINE_SAMPLE_LIMIT`] for a timeline, /// `None` for a PCM source. frame_limit: Option, + /// The session's published generation (#1642 B16-R2): a seek on its own publishes to it + /// after its push, a session seek ([`SessionSeek`]) after its last push. + published: PublishedGeneration, } impl SeekProducer { @@ -1429,18 +1730,26 @@ impl SeekProducer { Ok(()) } - /// The rules of [`Self::check_seek`], then the push; the generation switches only when the - /// push lands. A full slot changes nothing but the queue's full count. - fn try_seek(&mut self, command: SourceCommand) -> Result<(), SourceSeekError> { + /// The rules of [`Self::check_seek`], then the push, with nothing published; the generation + /// switches only when the push lands. A full slot changes nothing but the queue's full count. + /// Returns the pushed command's generation, which the caller publishes after its last push. + fn push(&mut self, command: SourceCommand) -> Result { let generation = self.check_rules(command)?; match self.command_producer.try_push(command) { Ok(()) => { self.active_generation = generation; - Ok(()) + Ok(generation) } Err(QueueFull { full_count, .. }) => Err(SourceSeekError::Backpressure { full_count }), } } + + /// [`Self::push`], then publish the pushed generation: one seek on its own. + fn try_seek(&mut self, command: SourceCommand) -> Result<(), SourceSeekError> { + let generation = self.push(command)?; + self.published.publish(generation); + Ok(()) + } } /// Whether an acquire may observe the admitted command when it meets a block of an unobserved @@ -1463,6 +1772,19 @@ impl PcmSourceConsumer { /// holds for a seek a producer pushes while this runs, too: if the acquire's re-observe /// (#1274 MINOR-3) takes a newer seek, plain or anchored, this returns `false`. pub fn prepare_seek(&mut self, generation: SourceGeneration, frame: SourceFrame) -> bool { + let published = self.published.load_at_block_entry(); + self.prepare_seek_published(generation, frame, published) + } + + /// [`Self::prepare_seek`] under the session's published generation `published` (#1642 + /// B16-R2): the source-set driver's form, which observes no command above it. + fn prepare_seek_published( + &mut self, + generation: SourceGeneration, + frame: SourceFrame, + published: u64, + ) -> bool { + self.observed_published = published; self.end_block(); self.flush_deferred_recycle(); // The prepared source command queue has one slot. Observe exactly that admitted @@ -1517,6 +1839,7 @@ impl PcmSourceConsumer { /// Without a block time an anchored seek applies at once, as a plain seek to its frame; the /// graph calls [`Self::begin_block_at`] instead. pub fn begin_block(&mut self) -> SourceReadReport { + self.observed_published = self.published.load_at_block_entry(); self.begin_block_with(SeekClock::Now) } @@ -1536,6 +1859,19 @@ impl PcmSourceConsumer { /// The block publishes the ring's seek report when a seek applies at it, at the ring's first /// block and when a cumulative counter moves ([`SourceSeekReport`], #1316 D3). pub fn begin_block_at(&mut self, source_read_sample: u64) -> SourceReadReport { + let published = self.published.load_at_block_entry(); + self.begin_block_at_published(source_read_sample, published) + } + + /// [`Self::begin_block_at`] under the session's published generation `published` (#1642 + /// B16-R2): the source-set driver's form. A command whose generation is above `published` + /// stays in its slot, and a block of its generation stays queued behind it, for a later block. + fn begin_block_at_published( + &mut self, + source_read_sample: u64, + published: u64, + ) -> SourceReadReport { + self.observed_published = published; self.begin_block_with(SeekClock::At { source_read_sample }) } @@ -1811,13 +2147,15 @@ impl PcmSourceConsumer { } } - /// Pop the admitted command, if any, then apply a held anchored seek whose block has come. + /// Pop the admitted command, if any, then apply a held anchored seek whose block has come. A + /// command above the block's published generation stays in its slot ([`SeekState::take_published`]). /// /// The seek rules are the shared [`SeekState::observe`]; the source's own work runs on its /// result: a newly held anchored seek makes the pending block of a replaced generation stale, /// and a seek to apply repositions the PCM. fn observe_seek_at_block_boundary(&mut self, clock: SeekClock) { - let command = self.command_consumer.try_pop().ok(); + let command = + SeekState::take_published(&mut self.command_consumer, self.observed_published); let observation = self.seek.observe(command, clock); if let Some(held) = observation.newly_held { // A newer command replaces a held one; the replaced seek's pending block is stale. @@ -1953,11 +2291,15 @@ impl PcmSourceConsumer { /// /// The call pops at most the blocks queued when it begins (`available`), never the blocks the /// host publishes while it runs: a count taken at entry, not the ring's capacity, bounds it, so - /// a host that keeps the ring fed cannot hold the render here. Two counted phases, with the - /// one seek re-observe between them and in neither loop. + /// a host that keeps the ring fed cannot hold the render here. Each block is peeked before it + /// is popped, with at most one seek re-observe in the call. /// - /// With [`CommandObservation::Withheld`] (a prime) it stops before a block of an unobserved - /// generation, which stays queued with its command, so it never observes a command. + /// A block of a generation this consumer has not observed is never popped: with + /// [`CommandObservation::Withheld`] (a prime) the call stops before it, so it never observes a + /// command; otherwise it re-observes the command queue once, and if the block's seek is still + /// unobserved (its command is above the block's published generation, #1642 B16-R2) it stops + /// before it too. Either way the block stays queued with its command, and plays at the block + /// that observes the seek: no acked PCM is discarded for a seek render has not observed yet. fn acquire_current_block(&mut self, clock: SeekClock, observation: CommandObservation) { if self.current.is_some() { return; @@ -1966,58 +2308,36 @@ impl PcmSourceConsumer { .data_consumer .available_at_entry() .min(self.transfer_block_count); - let mut popped = 0_usize; - let mut unobserved = None; + let mut reobserved = false; for _ in 0..available { - if observation == CommandObservation::Withheld - && self - .data_consumer - .peek(0) - .is_some_and(|block| self.is_unobserved_generation(block.generation)) - { - return; - } - let Ok(block) = self.data_consumer.try_pop() else { + let Some(generation) = self.data_consumer.peek(0).map(|block| block.generation) else { break; }; - popped += 1; - self.note_sanitized_samples(&block); - if self.is_unobserved_generation(block.generation) { - unobserved = Some(block); - break; - } - if self.settle_block(block) { - return; + if self.is_unobserved_generation(generation) { + if observation == CommandObservation::Withheld || reobserved { + return; + } + // This block's seek was pushed after this block's command pop. The host pushes a + // seek's command before any of its PCM, on one thread, through release/acquire + // queues, so the command is in the one-slot command queue now: observe it before + // judging the block rather than discard accepted PCM (#1274 MINOR-3). A counted + // block was published after its seek's command was pushed, so that command was + // pushed before the count too. Only `observe_seek_at_block_boundary` pops the + // one-slot queue (its callers are this acquire, `prepare_seek` and + // `begin_block_observe`, all on this consumer's thread), so at the count at most + // one command is pushed and not yet popped, and at most one generation among the + // counted blocks is unobserved. This one re-observe takes it, unless the session + // has not published its generation at this block's entry: then the block waits. + reobserved = true; + self.observe_seek_at_block_boundary(clock); + if self.is_unobserved_generation(generation) { + return; + } } - } - let Some(block) = unobserved else { - return; - }; - // This block's seek was pushed after this block's command pop. The host pushes a seek's - // command before any of its PCM, on one thread, through release/acquire queues, so the - // command is in the one-slot command queue now: observe it before judging the block - // rather than discard accepted PCM (#1274 MINOR-3). Bounded: at most one command pop per - // call. A counted block was published after its seek's command was pushed, so that - // command was pushed before the count too. Only `observe_seek_at_block_boundary` pops the - // one-slot queue (its callers are this acquire, `prepare_seek` and `begin_block_observe`, - // all on this consumer's thread), so at the count at most one command is pushed and not - // yet popped, and at most one generation among the counted blocks is unobserved. This one - // pop observes it, and a later command's blocks were published after the count. - self.observe_seek_at_block_boundary(clock); - if self.settle_block(block) { - return; - } - let unpopped = available.saturating_sub(popped); - let rest = self.data_consumer.available_at_entry().min(unpopped); - for _ in 0..rest { let Ok(block) = self.data_consumer.try_pop() else { break; }; self.note_sanitized_samples(&block); - debug_assert!( - !self.is_unobserved_generation(block.generation), - "a block counted at entry has an observed seek after one re-observe" - ); if self.settle_block(block) { return; } @@ -2178,10 +2498,13 @@ impl SourceTimeline { } /// Allocate a timeline at generation 1, timeline sample 0, with a history of `history_blocks` - /// entries, and split producer and render ownership. Control thread only. + /// entries, of the session whose published generation is `published` (#1642 B16-R2), and + /// split producer and render ownership. Every seek of the producer publishes to `published`, + /// and the consumer observes no command above it. Control thread only. pub fn prepare( quantum_frames: QuantumFrames, history_blocks: NonZeroUsize, + published: PublishedGeneration, ) -> Result<(TimelineProducer, TimelineConsumer, TimelineResourceReport), PcmSourceRingError> { let report = Self::resource_report(quantum_frames, history_blocks)?; @@ -2189,8 +2512,12 @@ impl SourceTimeline { let (command_producer, command_consumer) = bounded_spsc(NonZeroUsize::MIN, QueueGeneration(initial_generation.0)) .map_err(map_spsc_error)?; - let consumer = - TimelineConsumer::with_history(quantum_frames, history_blocks, Some(command_consumer))?; + let consumer = TimelineConsumer::with_history( + quantum_frames, + history_blocks, + Some(command_consumer), + published.clone(), + )?; Ok(( TimelineProducer { seek: SeekProducer { @@ -2198,6 +2525,7 @@ impl SourceTimeline { active_generation: initial_generation, quantum_frames: quantum_frames.0, frame_limit: Some(TIMELINE_SAMPLE_LIMIT), + published, }, }, consumer, @@ -2209,13 +2537,16 @@ impl SourceTimeline { /// command queue, for a successor plan that carries its predecessor's timeline at the swap /// block. Until a carry fills it, it advances from timeline sample 0 and observes no seek, so /// render stays defined. Its report's command-queue rows name the queue the carry brings, - /// which the successor does not allocate. Control thread only. + /// which the successor does not allocate. `published` is the session's published generation + /// (#1642 B16-R2), the word of the timeline the carry brings. Control thread only. pub fn prepare_vacant( quantum_frames: QuantumFrames, history_blocks: NonZeroUsize, + published: PublishedGeneration, ) -> Result<(TimelineConsumer, TimelineResourceReport), PcmSourceRingError> { let report = Self::resource_report(quantum_frames, history_blocks)?; - let consumer = TimelineConsumer::with_history(quantum_frames, history_blocks, None)?; + let consumer = + TimelineConsumer::with_history(quantum_frames, history_blocks, None, published)?; Ok((consumer, report)) } } @@ -2297,6 +2628,10 @@ pub struct TimelineConsumer { /// plus a quantum, or the starting position while nothing is evicted. With it the oldest entry /// reports a seek as every other entry does. oldest_expected_timeline: u64, + /// The session's published generation (#1642 B16-R2): a timeline on its own loads it at each + /// block entry; the source-set driver loads the same word once for the whole set. A carry + /// moves the command queue, never the word: both sets are the session's. + published: PublishedGeneration, } impl TimelineConsumer { @@ -2306,6 +2641,7 @@ impl TimelineConsumer { quantum_frames: QuantumFrames, history_blocks: NonZeroUsize, command_consumer: Option>, + published: PublishedGeneration, ) -> Result { let mut history = Vec::new(); history @@ -2322,6 +2658,7 @@ impl TimelineConsumer { oldest: 0, len: 0, oldest_expected_timeline: 0, + published, }) } @@ -2362,9 +2699,16 @@ impl TimelineConsumer { /// quantum. It runs on every block, so the timeline never stops. Allocation-free, lock-free /// and bounded. pub fn begin_block_at(&mut self, source_read_sample: u64) { + let published = self.published.load_at_block_entry(); + self.begin_block_at_published(source_read_sample, published); + } + + /// [`Self::begin_block_at`] under the session's published generation `published` (#1642 + /// B16-R2), the source-set driver's form: a command above it stays in its slot. + fn begin_block_at_published(&mut self, source_read_sample: u64, published: u64) { // A vacant timeline has no queue and observes nothing. let command = match self.command_consumer.as_mut() { - Some(consumer) => consumer.try_pop().ok(), + Some(consumer) => SeekState::take_published(consumer, published), None => None, }; let observation = self @@ -2696,6 +3040,10 @@ pub enum SourceGraphSourceSetError { ArithmeticOverflow, /// The timeline's quantum is not the envelope's (#1641 D1). TimelineQuantum, + /// The timeline or an occupied source was prepared with another session's published + /// generation than the set's (#1642 B16-R2): its seeks would publish to a word render does + /// not load. + SessionWord, } struct GraphSourceEntry { @@ -2708,6 +3056,9 @@ struct GraphSourceEntry { struct SourceGraphSourceSetDriver { /// The session's playhead (#1641 D1), inline: the driver's own allocation counts its value. timeline: TimelineConsumer, + /// The session's published generation (#1642 B16-R2), loaded once per block entry: the word + /// of the timeline and of every consumer of the set, checked at sealing. + published: PublishedGeneration, sources: Box<[GraphSourceEntry]>, mappings: Box<[SourceGraphTrackMapping]>, quantum_frames: u32, @@ -2782,11 +3133,14 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { let Some(generation) = SourceGeneration::new(generation) else { return false; }; + let published = self.published_at_entry(); let prepared = self .sources .get_mut(source_index) .and_then(|source| source.consumer.as_mut()) - .is_some_and(|consumer| consumer.prepare_seek(generation, SourceFrame(frame))); + .is_some_and(|consumer| { + consumer.prepare_seek_published(generation, SourceFrame(frame), published) + }); if prepared { self.pending_generation_change = true; } @@ -2810,9 +3164,13 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { source_generation_changed: self.pending_generation_change, }; self.pending_generation_change = false; + // #1642 B16-R2: one load of the session's published generation, before the timeline and + // the sources, so every consumer observes the same session seeks in this block. + let published = self.published_at_entry(); // #1641 D1: the timeline first, at the sample the sources read, so it observes a seek in // the same block as a source given the same seek. It applies no state to the sources. - self.timeline.begin_block_at(first_sample); + self.timeline + .begin_block_at_published(first_sample, published); for source in &mut self.sources { // A vacant entry has nothing to play: its block is an underrun. let Some(consumer) = source.consumer.as_mut() else { @@ -2821,7 +3179,7 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { }; // The graph passes the plan's source-read sample, which an anchored seek waits on: the // render clock plus the plan's source-read offset (#1316 D1, #1396 D1). - let report = consumer.begin_block_at(first_sample); + let report = consumer.begin_block_at_published(first_sample, published); self.block_validity.source_underrun |= report.underrun_event; self.block_validity.source_generation_changed |= report.generation_changed; } @@ -2949,7 +3307,8 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { /// Move each named predecessor consumer into this set's vacant entry with a swap, so the /// predecessor's entry becomes vacant and nothing is allocated, freed or dropped. Every move /// is checked first -- both indices in range, this entry vacant, the predecessor's occupied, - /// the channel counts and quanta equal -- and any failure moves nothing. The predecessor's + /// the channel counts, quanta and session words (#1642 B16-R2) equal -- and any failure moves + /// nothing. The predecessor's /// pending generation change (a seek prepared since its last block) is carried too. /// /// A vacant timeline takes an occupied predecessor's (#1641 D3): its seek state, command @@ -2968,6 +3327,7 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { return false; }; let movable = predecessor.quantum_frames == self.quantum_frames + && predecessor.published == self.published && moves.iter().all(|&(successor, source)| { match ( self.sources.get(successor as usize), @@ -3016,7 +3376,7 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { } /// Every pair names an occupied entry on both sides, of equal channel count, no index twice on - /// either side, and the two sets share one quantum. + /// either side, and the two sets share one quantum and one session word (#1642 B16-R2). fn can_swap_sources( &self, other: &dyn GraphPreparedSourceSetDriver, @@ -3068,9 +3428,16 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { } impl SourceGraphSourceSetDriver { + /// The block entry's load of the session's published generation (#1642 B16-R2). One + /// `Acquire` load, wait-free. + fn published_at_entry(&self) -> u64 { + self.published.load_at_block_entry() + } + /// [`GraphPreparedSourceSetDriver::can_swap_sources`] against a set of this type. fn swappable(&self, other: &Self, pairs: &[(u32, u32)]) -> bool { other.quantum_frames == self.quantum_frames + && other.published == self.published && pairs.iter().enumerate().all(|(index, &(own, theirs))| { let fresh = pairs[..index].iter().all(|&(earlier_own, earlier_theirs)| { earlier_own != own && earlier_theirs != theirs @@ -3091,26 +3458,44 @@ impl SourceGraphSourceSetDriver { } } -/// Seal one or more prepared source consumers into a graph-owned fan-out source set. +/// Seal one or more prepared source consumers of one session into a graph-owned fan-out source +/// set, for one plan of that session. /// /// The wrapper owns exactly one consumer per source and the session's timeline (#1641 D1). The /// graph calls `begin_block` once, which begins the timeline's block and then each source's, then /// each mapping copies directly from the retained played block, which stays the consumer's until /// the next `begin_block` or seek preparation (#917). /// +/// The driver loads `published`, the session's published generation (#1642 B16-R2), once per +/// block entry and passes it to the timeline's and every source's observe step, so a session seek +/// reaches every consumer at one block. `published` is the word every producer of the session +/// publishes to, the same object for every plan of the session; the timeline and every occupied +/// source must be prepared with it, or the set is [`SourceGraphSourceSetError::SessionWord`]. It +/// is the session's, not this set's: no row of the report counts it. +/// /// The timeline's fixed bytes join the set's overhead and largest allocation (#1641 D7): its /// history, and for an occupied timeline its command queue. A vacant timeline's queue arrives /// with its carry, so its host counts it with the carried source overhead. The consumer value is /// inline in the driver, whose own allocation already counts it. -pub fn prepare_graph_source_set( +pub fn prepare_session_graph_source_set( envelope: engine::realtime::RenderEnvelope, sources: Vec, timeline: TimelineConsumer, + published: PublishedGeneration, mappings: Vec, ) -> Result { if timeline.quantum_frames != envelope.quantum.0 { return Err(SourceGraphSourceSetError::TimelineQuantum); } + let foreign = |consumer: &PcmSourceConsumer| consumer.published != published; + if timeline.published != published + || sources + .iter() + .filter_map(|source| source.consumer.as_ref()) + .any(foreign) + { + return Err(SourceGraphSourceSetError::SessionWord); + } let retained = source_set_retained_resources(&sources, &mappings)?; let timeline_report = SourceTimeline::resource_report(envelope.quantum, timeline.capacity) .map_err(|_| SourceGraphSourceSetError::ArithmeticOverflow)?; @@ -3182,6 +3567,7 @@ pub fn prepare_graph_source_set( }, Box::new(SourceGraphSourceSetDriver { timeline, + published, sources: entries.into_boxed_slice(), mappings: mappings.into_boxed_slice(), quantum_frames: envelope.quantum.0, @@ -3197,9 +3583,38 @@ mod tests { const RATE: SampleRateHz = SampleRateHz(48_000); + std::thread_local! { + static SESSION: PublishedGeneration = PublishedGeneration::new(SourceGeneration(1)); + } + + /// The test's session word (#1642 B16-R2): every ring, timeline and set one test prepares + /// shares it, as every plan of one session does. Each test runs on its own thread, so each + /// test is one session. + fn session() -> PublishedGeneration { + SESSION.with(Clone::clone) + } + + /// A ring of the test's session, starting at frame 0. + fn session_ring( + config: PcmSourceRingConfig, + ) -> Result<(PcmSourceProducer, PcmSourceConsumer, SourceResourceReport), PcmSourceRingError> + { + PcmSourceRing::prepare_host_region(config, SourceFrame(0), session()) + } + + /// [`prepare_session_graph_source_set`] under the test's session word. + fn session_source_set( + envelope: engine::realtime::RenderEnvelope, + sources: Vec, + timeline: TimelineConsumer, + mappings: Vec, + ) -> Result { + prepare_session_graph_source_set(envelope, sources, timeline, session(), mappings) + } + /// An occupied timeline of one history entry at `quantum` frames, for a hand-built source set. fn test_timeline(quantum: u32) -> TimelineConsumer { - SourceTimeline::prepare(QuantumFrames(quantum), NonZeroUsize::MIN) + SourceTimeline::prepare(QuantumFrames(quantum), NonZeroUsize::MIN, session()) .expect("timeline") .1 } @@ -3300,7 +3715,7 @@ mod tests { #[test] fn paused_seek_prepares_full_queues_without_consuming_target() { for retained in [false, true] { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); let old = [0.25; 4]; host.submit(chunk(1, 0, &[&old], 4, false)).unwrap(); @@ -3356,7 +3771,7 @@ mod tests { /// producer's generation anyway. #[test] fn an_unaligned_anchor_is_refused_without_a_generation_switch() { - let (producer, _consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, _consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); assert_eq!( host.try_seek(seek_at(2, 100, 6)), @@ -3377,7 +3792,7 @@ mod tests { /// either way: `apply_seek` and the generation compare already refuse it.) #[test] fn a_newer_anchored_seek_replaces_a_held_one() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 16)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 16)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); let depth = consumer.transfer_block_capacity(); host.try_seek(seek_at(2, 100, 24)).unwrap(); @@ -3422,7 +3837,7 @@ mod tests { /// compare the generation), or if it is discarded rather than kept as the pending block. #[test] fn a_primed_block_waits_for_its_anchor() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); host.try_seek(seek_at(2, 4, 8)).unwrap(); host.submit(chunk(2, 4, &[&[2.0; 4]], 4, false)).unwrap(); @@ -3447,7 +3862,7 @@ mod tests { /// seek, or applies it with no block time. #[test] fn prepare_seek_holds_an_anchored_seek() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); host.submit(chunk(1, 0, &[&[1.0; 4]], 4, false)).unwrap(); host.try_seek(seek_at(2, 100, 8)).unwrap(); @@ -3469,7 +3884,7 @@ mod tests { /// the pending block it was primed with must still report where the region ends. #[test] fn a_late_anchored_seek_past_the_region_end_is_the_end_of_region() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); host.try_seek(seek_at(2, 100, 8)).unwrap(); host.submit(chunk(2, 100, &[&[2.0, 2.0]], 2, true)).unwrap(); @@ -3493,7 +3908,7 @@ mod tests { /// report an underrun on every block after it ends, and never reach the end of its region. #[test] fn an_on_time_anchored_seek_to_a_last_short_block_ends_its_region() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); host.try_seek(seek_at(2, 100, 8)).unwrap(); host.submit(chunk(2, 100, &[&[2.0, 2.0]], 2, true)).unwrap(); @@ -3515,12 +3930,14 @@ mod tests { } /// Render one block with a producer step between its command pop and its data pop: the - /// window in which a seek's PCM can reach the render before its command does. + /// window in which a seek's PCM can reach the render before its command does. The block + /// loads the session's word at its entry, before the producer step, as every entry does. fn render_with_producer_in_window( consumer: &mut PcmSourceConsumer, clock: SeekClock, producer: impl FnOnce(), ) -> ([f32; 4], SourceReadReport) { + consumer.observed_published = consumer.published.load_at_block_entry(); consumer.begin_block_observe(clock); producer(); let report = consumer.begin_block_play(clock, CommandObservation::Allowed); @@ -3537,7 +3954,12 @@ mod tests { #[test] fn a_seek_whose_pcm_arrives_inside_the_block_window_keeps_its_pcm() { for anchored in [false, true] { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + // Each run is a session of its own whose word already covers generation 2 (another + // producer of the session published a later one), so the block entry's fence admits + // the seek pushed in its window: the re-observe's case under the fence (#1642 B16-R2). + let word = PublishedGeneration::new(SourceGeneration(3)); + let (producer, mut consumer, _) = + PcmSourceRing::prepare_host_region(config(1, 4, 8), SourceFrame(0), word).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); let command = if anchored { seek_at(2, 100, 8) @@ -3577,8 +3999,14 @@ mod tests { // An anchored seek whose anchor has already passed when the window's re-observe takes // it applies at once, at `frame` plus the lateness, by the block's own clock: block 8 - // plays frame 104, and only the skipped block at frame 100 is discarded. - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 16)).unwrap(); + // plays frame 104, and only the skipped block at frame 100 is discarded. The word covers + // generation 2 at the block's entry, as above. + let (producer, mut consumer, _) = PcmSourceRing::prepare_host_region( + config(1, 4, 16), + SourceFrame(0), + PublishedGeneration::new(SourceGeneration(3)), + ) + .unwrap(); let mut host = producer.into_host_chunk_provider(RATE); let (output, report) = render_with_producer_in_window( &mut consumer, @@ -3615,7 +4043,7 @@ mod tests { #[test] fn the_acquire_does_not_pop_a_block_published_after_its_count() { const BLOCKS: usize = 200_000; - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); assert!(consumer.transfer_block_count > 1); consumer.next_frame = SourceFrame(u64::MAX / 2); let host = producer.into_host_chunk_provider(RATE); @@ -3652,7 +4080,7 @@ mod tests { /// generation as prepared while the source is about to jump. #[test] fn prepare_seek_of_the_playing_generation_is_not_ready_while_a_seek_is_held() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 16)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 16)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); host.try_seek(seek_at(2, 100, 64)).unwrap(); render_at(&mut consumer, 0); @@ -3668,15 +4096,15 @@ mod tests { #[test] fn prepare_rejects_invalid_fixed_ring_shape() { assert!(matches!( - PcmSourceRing::prepare(config(0, 4, 4)), + session_ring(config(0, 4, 4)), Err(PcmSourceRingError::ZeroChannelCount) )); assert!(matches!( - PcmSourceRing::prepare(config(2, 0, 4)), + session_ring(config(2, 0, 4)), Err(PcmSourceRingError::ZeroQuantumFrames) )); assert!(matches!( - PcmSourceRing::prepare(config(2, 4, 6)), + session_ring(config(2, 4, 6)), Err(PcmSourceRingError::CapacityNotQuantumMultiple) )); } @@ -3685,11 +4113,13 @@ mod tests { fn host_region_preparation_preserves_resources_and_absolute_ownership() { let config = config(1, 4, 8); let (_, _, zero_report) = - PcmSourceRing::prepare_host_region(config, SourceFrame(0)).expect("zero origin"); + PcmSourceRing::prepare_host_region(config, SourceFrame(0), session()) + .expect("zero origin"); let (_, _, one_report) = - PcmSourceRing::prepare_host_region(config, SourceFrame(1)).expect("one origin"); + PcmSourceRing::prepare_host_region(config, SourceFrame(1), session()) + .expect("one origin"); let (producer, mut consumer, session_report) = - PcmSourceRing::prepare_host_region(config, SourceFrame(48_123)) + PcmSourceRing::prepare_host_region(config, SourceFrame(48_123), session()) .expect("session origin"); assert_eq!(zero_report, PcmSourceRing::resource_report(config).unwrap()); assert_eq!(one_report, zero_report); @@ -3725,7 +4155,7 @@ mod tests { #[test] fn host_submission_is_fifo_wraparound_and_never_accepts_a_prefix() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(2, 4, 4)).expect("ring"); + let (producer, mut consumer, _) = session_ring(config(2, 4, 4)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); let left_a = [1.0, 2.0, 3.0, 4.0]; let right_a = [11.0, 12.0, 13.0, 14.0]; @@ -3760,7 +4190,7 @@ mod tests { #[test] fn underrun_is_positive_zero_and_eof_is_not_an_underrun() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).expect("ring"); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); let mut output_plane = [-1.0; 4]; let missing = { @@ -3801,7 +4231,7 @@ mod tests { #[test] fn retained_played_block_supports_repeat_fanout_and_auto_recycles_once() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(2, 4, 8)).expect("ring"); + let (producer, mut consumer, _) = session_ring(config(2, 4, 8)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); let left_first = [1.0, 2.0, 3.0, 4.0]; let right_first = [-1.0, -2.0, -3.0, -4.0]; @@ -3896,7 +4326,7 @@ mod tests { /// Full PCM, short PCM with a zero tail, and silence overwrite every poisoned destination. #[test] fn copy_channel_preserves_pcm_and_zeroes_missing_frames() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(2, 4, 8)).expect("ring"); + let (producer, mut consumer, _) = session_ring(config(2, 4, 8)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); // No retained quantum at all: the whole plane is the underrun. @@ -3949,6 +4379,7 @@ mod tests { ) -> SourceGraphSourceSetDriver { SourceGraphSourceSetDriver { timeline: test_timeline(4), + published: session(), sources: vec![GraphSourceEntry { channel_count: consumer.channel_count(), consumer: Some(consumer), @@ -3963,7 +4394,7 @@ mod tests { #[test] fn graph_driver_forwards_underrun_and_seek_generation_facts() { - let (producer, consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).expect("ring"); + let (producer, consumer, _) = session_ring(config(1, 4, 8)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); let mut driver = test_driver(consumer, vec![test_mapping(0)]); @@ -3999,10 +4430,9 @@ mod tests { /// the fix swallows a true underrun on another source in the same block. #[test] fn graph_driver_does_not_flag_a_held_block_as_an_underrun() { - let (producer, held, _) = PcmSourceRing::prepare(config(1, 4, 8)).expect("held ring"); + let (producer, held, _) = session_ring(config(1, 4, 8)).expect("held ring"); let mut host = producer.into_host_chunk_provider(RATE); - let (_other_producer, starved, _) = - PcmSourceRing::prepare(config(1, 4, 8)).expect("starved ring"); + let (_other_producer, starved, _) = session_ring(config(1, 4, 8)).expect("starved ring"); host.try_seek(seek_at(2, 100, 16)).expect("anchored seek"); let mut driver = test_driver(held, vec![test_mapping(0)]); driver.begin_block(0, 4).expect("held block"); @@ -4057,7 +4487,7 @@ mod tests { #[test] fn a_wait_for_an_anchor_is_held_and_starvation_after_it_is_an_underrun() { for delivered in [true, false] { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); host.try_seek(seek_at(2, 100, 16)).unwrap(); if delivered { @@ -4122,7 +4552,7 @@ mod tests { /// rewrites the sample and frame. #[test] fn the_seek_report_names_the_block_and_frame_each_generation_started_at() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 16)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 16)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); assert_eq!(host.seek_report(), Ok(SourceSeekReport::default())); for block in 0..2_u64 { @@ -4163,7 +4593,7 @@ mod tests { /// `begin_block` publishes no sample, or does not continue its own clock. #[test] fn a_clockless_consumer_reports_consecutive_quanta() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); let mut output = [0.0; 4]; host.submit(chunk(1, 0, &[&[1.0; 4]], 4, false)).unwrap(); @@ -4184,7 +4614,7 @@ mod tests { /// and frame. #[test] fn the_seek_report_carries_the_cumulative_held_and_underrun_frames() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); host.try_seek(seek_at(2, 100, 16)).unwrap(); for block in 0..4_u64 { @@ -4204,7 +4634,7 @@ mod tests { #[test] fn queued_pcm_of_the_playing_generation_plays_while_a_seek_is_held() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); host.submit(chunk(1, 0, &[&[1.0; 4]], 4, false)).unwrap(); host.submit(chunk(1, 4, &[&[1.5; 4]], 4, false)).unwrap(); @@ -4234,7 +4664,7 @@ mod tests { let samples = [1.0, 2.0, 3.0, 4.0]; let later = [5.0, 6.0, 7.0, 8.0]; - let (producer, consumer, _) = PcmSourceRing::prepare(config(1, 4, 4)).expect("ring"); + let (producer, consumer, _) = session_ring(config(1, 4, 4)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); host.submit(chunk(1, 0, &[&samples], 4, false)) .expect("first block"); @@ -4263,7 +4693,7 @@ mod tests { host.submit(chunk(1, 8, &[&later], 4, false)) .expect("next begin recycled the previous played block"); - let (producer, consumer, _) = PcmSourceRing::prepare(config(1, 4, 4)).expect("ring"); + let (producer, consumer, _) = session_ring(config(1, 4, 4)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); host.submit(chunk(1, 0, &[&samples], 4, false)) .expect("first block"); @@ -4284,7 +4714,7 @@ mod tests { host.submit(chunk(1, 8, &[&samples], 4, false)) .expect("missing claim's block recycled on next begin"); - let (producer, consumer, _) = PcmSourceRing::prepare(config(1, 4, 4)).expect("ring"); + let (producer, consumer, _) = session_ring(config(1, 4, 4)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); host.submit(chunk(1, 0, &[&samples], 4, false)) .expect("first block"); @@ -4315,7 +4745,7 @@ mod tests { #[test] fn seek_switches_at_boundary_and_discards_older_queued_audio() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 12)).expect("ring"); + let (producer, mut consumer, _) = session_ring(config(1, 4, 12)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); let old_a = [1.0, 1.0, 1.0, 1.0]; let old_b = [2.0, 2.0, 2.0, 2.0]; @@ -4361,7 +4791,7 @@ mod tests { SourceDiagnosticPath::for_sources_collection().as_str(), "$.sources" ); - let (producer, _consumer, _) = PcmSourceRing::prepare(config(2, 4, 4)).expect("ring"); + let (producer, _consumer, _) = session_ring(config(2, 4, 4)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); let mono = [0.0; 4]; assert!(matches!( @@ -4382,8 +4812,7 @@ mod tests { #[test] fn one_quantum_ring_shape_matches_its_report_and_reads_back_planar() { - let (producer, mut consumer, report) = - PcmSourceRing::prepare(config(2, 4, 4)).expect("ring"); + let (producer, mut consumer, report) = session_ring(config(2, 4, 4)).expect("ring"); assert_eq!(producer.shape(), consumer.shape()); assert_eq!(producer.shape().frame_capacity, 4); // The shape and the report keep the configured count; the retained block is separate. @@ -4408,7 +4837,7 @@ mod tests { #[test] fn rejected_host_submission_publishes_nothing_and_leaves_the_producer_untouched() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).expect("ring"); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); host.try_seek(SourceCommand::Seek { generation: SourceGeneration(2), @@ -4606,7 +5035,7 @@ mod tests { frame_capacity: 2, initial_generation: SourceGeneration(1), }; - let (producer, consumer, resources) = PcmSourceRing::prepare(config).expect("ring"); + let (producer, consumer, resources) = session_ring(config).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); let c0 = [1.0_f32, 1.0]; let c1 = [2.0_f32, 2.0]; @@ -4621,7 +5050,7 @@ mod tests { end_of_region: true, }) .expect("source PCM"); - prepare_graph_source_set( + session_source_set( envelope, vec![SourceGraphSource::new(consumer, resources, 0, 0)], test_timeline(2), @@ -4703,7 +5132,7 @@ mod tests { #[test] fn seek_preparation_preserves_configured_depth_and_prefetches_without_consuming() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 12)).expect("ring"); + let (producer, mut consumer, _) = session_ring(config(1, 4, 12)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); host.try_seek(SourceCommand::Seek { generation: SourceGeneration(2), @@ -4751,7 +5180,7 @@ mod tests { fn played_planes_stay_intact_while_the_producer_fills_its_configured_depth() { for configured in 1_u64..=3 { let (producer, mut consumer, report) = - PcmSourceRing::prepare(config(2, 4, 4 * configured)).expect("ring"); + session_ring(config(2, 4, 4 * configured)).expect("ring"); assert_eq!(report.transfer_block_count, configured); let mut host = producer.into_host_chunk_provider(RATE); let first_left = [1.0, 2.0, 3.0, 4.0]; @@ -4807,7 +5236,7 @@ mod tests { /// Red mutation: drop the in-place tail fill in `play` -- the borrowed tail keeps the poison. #[test] fn played_plane_is_the_quantum_with_a_zeroed_short_tail_and_none_without_a_block() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(2, 4, 4)).expect("ring"); + let (producer, mut consumer, _) = session_ring(config(2, 4, 4)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); assert!(consumer.played_plane(0).is_none(), "nothing played yet"); assert_copy_channel(&consumer, 0, &[0.0; 4], "before any block"); @@ -4867,7 +5296,7 @@ mod tests { assert_copy_channel(&consumer, 1, &[0.0; 4], "past the end of the region"); // An underrun (in-region, nothing queued) plays nothing either. - let (_producer, mut starved, _) = PcmSourceRing::prepare(config(1, 4, 8)).expect("ring"); + let (_producer, mut starved, _) = session_ring(config(1, 4, 8)).expect("ring"); let underrun = starved.begin_block(); assert!(underrun.underrun_event); assert!(starved.played_plane(0).is_none()); @@ -4885,7 +5314,7 @@ mod tests { [1000.0, 2000.0, 3000.0, 4000.0], ]; let planes: Vec<&[f32]> = channels.iter().map(|plane| &plane[..]).collect(); - let (producer, consumer, _) = PcmSourceRing::prepare(config(4, 4, 8)).expect("ring"); + let (producer, consumer, _) = session_ring(config(4, 4, 8)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); host.submit(chunk(1, 0, &planes, 4, false)) .expect("first block"); @@ -4948,7 +5377,7 @@ mod tests { ); // No claims: `begin_block` releases at once. - let (producer, consumer, _) = PcmSourceRing::prepare(config(1, 4, 4)).expect("ring"); + let (producer, consumer, _) = session_ring(config(1, 4, 4)).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); host.submit(chunk(1, 0, &[&channels[0]], 4, false)) .expect("first block"); @@ -5176,9 +5605,8 @@ mod tests { mappings: Vec, validity: &Arc, ) -> PreparedRenderPlan { - let set = - prepare_graph_source_set(envelope(), sources, test_timeline(FRAMES), mappings) - .expect("source set"); + let set = session_source_set(envelope(), sources, test_timeline(FRAMES), mappings) + .expect("source set"); let bindings = GraphRuntimeBindings { envelope: envelope(), nodes: vec![graph::GraphNodeBinding::new(output(), Box::new(Noop))], @@ -5209,7 +5637,7 @@ mod tests { frame_capacity: BLOCKS as u64 * u64::from(FRAMES), initial_generation: SourceGeneration(1), }; - let (producer, consumer, resources) = PcmSourceRing::prepare(config).expect("ring"); + let (producer, consumer, resources) = session_ring(config).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); for block in 0..BLOCKS as u64 { let planes = frame_samples(block * u64::from(FRAMES)); @@ -5404,7 +5832,7 @@ mod tests { frame_capacity: 4 * u64::from(FRAMES), initial_generation: SourceGeneration(1), }; - let (producer, consumer, resources) = PcmSourceRing::prepare(config).expect("ring"); + let (producer, consumer, resources) = session_ring(config).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); let first = frame_samples(0); host.submit(chunk(1, 0, &[&first[0], &first[1]], FRAMES, false)) @@ -5505,14 +5933,14 @@ mod tests { fn a_vacant_source_charges_no_ring_and_is_counted_apart() { let (_host, occupied_source) = filled_source(); let ring = occupied_source.resources.expect("ring report"); - let occupied = prepare_graph_source_set( + let occupied = session_source_set( envelope(), vec![occupied_source], test_timeline(FRAMES), mappings(), ) .expect("occupied set"); - let vacant = prepare_graph_source_set( + let vacant = session_source_set( envelope(), vec![SourceGraphSource::vacant(2)], test_timeline(FRAMES), @@ -5536,7 +5964,7 @@ mod tests { ); // Mappings are still checked against a vacant source's channel count. assert!(matches!( - prepare_graph_source_set( + session_source_set( envelope(), vec![SourceGraphSource::vacant(1)], test_timeline(FRAMES), @@ -5632,6 +6060,7 @@ mod tests { let [a, _] = inputs(); let mut driver = SourceGraphSourceSetDriver { timeline: test_timeline(FRAMES), + published: session(), sources: vec![GraphSourceEntry { consumer: None, channel_count: 2, @@ -5709,7 +6138,7 @@ mod tests { frame_capacity: BLOCKS as u64 * u64::from(FRAMES), initial_generation: SourceGeneration(1), }; - let (producer, consumer, resources) = PcmSourceRing::prepare(config).expect("ring"); + let (producer, consumer, resources) = session_ring(config).expect("ring"); let mut host = producer.into_host_chunk_provider(RATE); for block in 0..BLOCKS as u64 { let planes = frame_samples(block * u64::from(FRAMES)); @@ -5753,6 +6182,7 @@ mod tests { let driver = |sources: Vec, quantum_frames| SourceGraphSourceSetDriver { timeline: test_timeline(quantum_frames), + published: session(), sources: sources.into_boxed_slice(), mappings: Vec::new().into_boxed_slice(), quantum_frames, @@ -5787,7 +6217,7 @@ mod tests { #[test] fn the_source_moves_carry_whatever_the_two_timelines_hold() { let vacant_timeline = || { - SourceTimeline::prepare_vacant(QuantumFrames(FRAMES), NonZeroUsize::MIN) + SourceTimeline::prepare_vacant(QuantumFrames(FRAMES), NonZeroUsize::MIN, session()) .expect("vacant timeline") .0 }; @@ -5836,6 +6266,7 @@ mod tests { ) -> SourceGraphSourceSetDriver { SourceGraphSourceSetDriver { timeline: test_timeline(quantum_frames), + published: session(), sources: entries.into_boxed_slice(), mappings: Vec::new().into_boxed_slice(), quantum_frames, @@ -6015,7 +6446,7 @@ mod tests { fn a_source_swap_carries_a_prepared_seek_into_the_next_block() { let swapped = |pairs: &[(u32, u32)]| { let (_own_host, own_entry) = occupied(2); - let (producer, consumer, _) = PcmSourceRing::prepare(PcmSourceRingConfig { + let (producer, consumer, _) = session_ring(PcmSourceRingConfig { channel_count: 2, quantum_frames: QuantumFrames(FRAMES), frame_capacity: BLOCKS as u64 * u64::from(FRAMES), @@ -6060,7 +6491,7 @@ mod tests { fn ring() -> (HostChunkProvider, PcmSourceConsumer) { let (producer, consumer, _) = - PcmSourceRing::prepare(config(2, Q, 16 * u64::from(Q))).expect("ring"); + session_ring(config(2, Q, 16 * u64::from(Q))).expect("ring"); (producer.into_host_chunk_provider(RATE), consumer) } @@ -6403,6 +6834,7 @@ mod tests { let (producer, consumer, _) = SourceTimeline::prepare( QuantumFrames(Q), NonZeroUsize::new(history_blocks).unwrap(), + session(), ) .unwrap(); (producer, consumer) @@ -6626,9 +7058,12 @@ mod tests { /// records no history (its reader would extrapolate from a position it never held). #[test] fn a_vacant_timeline_never_adopted_advances_from_zero() { - let (mut consumer, _) = - SourceTimeline::prepare_vacant(QuantumFrames(Q), NonZeroUsize::new(2).unwrap()) - .unwrap(); + let (mut consumer, _) = SourceTimeline::prepare_vacant( + QuantumFrames(Q), + NonZeroUsize::new(2).unwrap(), + session(), + ) + .unwrap(); assert!(consumer.is_vacant()); assert!(consumer.prime_ready(0, 3), "nothing is queued"); assert_eq!(run(&mut consumer, 0, 4), vec![0, 128, 256, 384]); @@ -6887,7 +7322,7 @@ mod tests { /// with `try_seek` and pushes nothing. #[test] fn a_source_check_seek_agrees_with_its_try_seek_and_pushes_nothing() { - let (producer, mut consumer, _) = PcmSourceRing::prepare(config(1, 4, 8)).unwrap(); + let (producer, mut consumer, _) = session_ring(config(1, 4, 8)).unwrap(); let mut host = producer.into_host_chunk_provider(RATE); assert_eq!( host.check_seek(seek_at(2, 100, 6)), @@ -6907,4 +7342,257 @@ mod tests { assert_eq!(host.check_seek(seek(3, u64::MAX)), Ok(())); } } + + /// #1642 B16-R2: the publish fence a session seek is made visible to render through. + mod published { + use super::*; + + fn seek(generation: u64, frame: u64) -> SourceCommand { + SourceCommand::Seek { + generation: SourceGeneration(generation), + frame: SourceFrame(frame), + } + } + + /// A set of one mono source and an occupied timeline at quantum 4, fenced by `word`, with + /// both producers sharing it. + fn fenced( + word: &PublishedGeneration, + ) -> ( + HostChunkProvider, + TimelineProducer, + SourceGraphSourceSetDriver, + ) { + let (producer, consumer, _) = + PcmSourceRing::prepare_host_region(config(1, 4, 16), SourceFrame(0), word.clone()) + .expect("ring"); + let (timeline_producer, timeline, _) = + SourceTimeline::prepare(QuantumFrames(4), NonZeroUsize::MIN, word.clone()) + .expect("timeline"); + let driver = SourceGraphSourceSetDriver { + timeline, + published: word.clone(), + sources: vec![GraphSourceEntry { + channel_count: consumer.channel_count(), + consumer: Some(consumer), + }] + .into_boxed_slice(), + mappings: vec![test_mapping(0)].into_boxed_slice(), + quantum_frames: 4, + block_validity: GraphObservationValidity::CLEAR, + pending_generation_change: false, + }; + ( + producer.into_host_chunk_provider(RATE), + timeline_producer, + driver, + ) + } + + fn telemetry(driver: &SourceGraphSourceSetDriver) -> SourceConsumerTelemetry { + driver.sources[0] + .consumer + .as_ref() + .expect("occupied") + .telemetry() + } + + /// Test value: red if a consumer pops a command above the block's published generation (a + /// session seek could split across two blocks), drops it or its PCM instead of leaving + /// them queued, or does not take both at the first block after the publication. + #[test] + fn an_unpublished_seek_and_its_pcm_wait_in_their_slots_until_published() { + let word = PublishedGeneration::new(SourceGeneration(1)); + let (mut host, mut timeline, mut driver) = fenced(&word); + driver.begin_block(0, 4).expect("block 0"); + let mut session_seek = word.session_seek(2, None).expect("open"); + session_seek + .push_source(&mut host, 100) + .expect("source push"); + session_seek + .push_timeline(&mut timeline, 1_000) + .expect("timeline push"); + let plane = [100.0_f32, 101.0, 102.0, 103.0]; + host.submit(chunk(2, 100, &[&plane], 4, false)) + .expect("generation 2 PCM"); + assert_eq!(word.generation(), SourceGeneration(1), "nothing published"); + + driver.begin_block(4, 4).expect("block 1"); + let held = telemetry(&driver); + assert_eq!(held.active_generation, SourceGeneration(1)); + assert_eq!(held.cumulative_read_frames, 0); + assert_eq!(held.stale_generation_discard_count, 0, "the PCM waits"); + assert_eq!(driver.timeline_at(4), Some(4), "the timeline runs on"); + // Both commands still hold their slots. + assert!(matches!( + host.check_seek(seek(3, 0)), + Err(SourceSeekError::Backpressure { .. }) + )); + assert!(matches!( + timeline.check_seek(seek(3, 0)), + Err(SourceSeekError::Backpressure { .. }) + )); + + session_seek.publish(); + assert_eq!(word.generation(), SourceGeneration(2)); + driver.begin_block(8, 4).expect("block 2"); + let applied = telemetry(&driver); + assert_eq!(applied.active_generation, SourceGeneration(2)); + assert_eq!(applied.cumulative_read_frames, 4, "the waiting PCM plays"); + assert_eq!(applied.stale_generation_discard_count, 0); + assert_eq!(driver.timeline_at(8), Some(1_000)); + assert!(driver.observation_validity().source_generation_changed); + } + + /// Test value: red if a seek on its own does not publish its generation (render would + /// hold it forever) or publishing lowers the word. + #[test] + fn a_seek_on_its_own_publishes_its_generation_and_the_word_never_falls() { + let word = PublishedGeneration::new(SourceGeneration(1)); + let (mut host, mut timeline, mut driver) = fenced(&word); + host.try_seek(seek(5, 40)).expect("source seek"); + assert_eq!(word.generation(), SourceGeneration(5)); + timeline.try_seek(seek(3, 700)).expect("timeline seek"); + assert_eq!(word.generation(), SourceGeneration(5), "max, not last"); + driver.begin_block(0, 4).expect("block 0"); + assert_eq!(telemetry(&driver).active_generation, SourceGeneration(5)); + assert_eq!(driver.timeline_at(0), Some(700)); + } + + /// Test value: red if a session seek opens at or below its word, publishes when nothing + /// was pushed (a refused seek would make its generation stale), or pushes to a producer + /// of another session (render would never observe that seek under this word). + #[test] + fn a_session_seek_opens_above_its_word_and_pushes_only_its_own_producers() { + let word = PublishedGeneration::new(SourceGeneration(1)); + let (mut host, mut timeline, _driver) = fenced(&word); + assert_eq!( + word.session_seek(0, None).err(), + Some(SourceSeekError::GenerationZero) + ); + word.publish(SourceGeneration(4)); + assert_eq!( + word.session_seek(4, None).err(), + Some(SourceSeekError::GenerationNotStrictlyIncreasing { + active: SourceGeneration(4), + requested: SourceGeneration(4), + }) + ); + assert_eq!(host.check_seek(seek(4, 0)), Ok(()), "the producer takes 4"); + + let other = PublishedGeneration::new(SourceGeneration(1)); + let (mut foreign_host, mut foreign_timeline, _) = fenced(&other); + let mut session_seek = word.session_seek(5, Some(8)).expect("open"); + assert_eq!( + session_seek.command(3), + SourceCommand::SeekAt { + generation: SourceGeneration(5), + frame: SourceFrame(3), + anchor_sample: 8, + } + ); + assert_eq!( + session_seek.check_source(&foreign_host, 0), + Err(SessionSeekError::ForeignSession) + ); + assert_eq!( + session_seek.push_source(&mut foreign_host, 0), + Err(SessionSeekError::ForeignSession) + ); + assert_eq!( + session_seek.push_timeline(&mut foreign_timeline, 0), + Err(SessionSeekError::ForeignSession) + ); + assert_eq!( + session_seek.check_timeline(&timeline, 0), + Ok(()), + "its own producers check" + ); + drop(session_seek); + assert_eq!( + word.generation(), + SourceGeneration(4), + "nothing pushed, nothing published" + ); + assert_eq!( + foreign_host.check_seek(seek(2, 0)), + Ok(()), + "the foreign slot is empty" + ); + assert_eq!(foreign_timeline.check_seek(seek(2, 0)), Ok(())); + + // A refused push publishes nothing either; one that lands publishes on drop. + let mut session_seek = word.session_seek(5, Some(1)).expect("open"); + assert_eq!( + session_seek.push_source(&mut host, 0), + Err(SessionSeekError::Seek(SourceSeekError::AnchorUnaligned)) + ); + drop(session_seek); + assert_eq!(word.generation(), SourceGeneration(4)); + let mut session_seek = word.session_seek(5, None).expect("open"); + session_seek.push_timeline(&mut timeline, 0).expect("push"); + drop(session_seek); + assert_eq!(word.generation(), SourceGeneration(5), "the drop publishes"); + } + + /// Test value: red if a set seals, or takes a carry, with a consumer of another session's + /// word: that consumer's producer would publish to a word the set's render never loads. + #[test] + fn a_set_takes_only_consumers_of_its_session() { + let envelope = engine::realtime::RenderEnvelope { + sample_rate: RATE, + quantum: QuantumFrames(4), + output_channels: NonZeroUsize::MIN, + }; + let word = PublishedGeneration::new(SourceGeneration(1)); + let other = PublishedGeneration::new(SourceGeneration(1)); + let timeline = |word: &PublishedGeneration| { + SourceTimeline::prepare(QuantumFrames(4), NonZeroUsize::MIN, word.clone()) + .expect("timeline") + .1 + }; + let source = |word: &PublishedGeneration| { + let (_, consumer, resources) = PcmSourceRing::prepare_host_region( + config(1, 4, 16), + SourceFrame(0), + word.clone(), + ) + .expect("ring"); + SourceGraphSource::new(consumer, resources, 0, 0) + }; + let seal = |sources, timeline| { + prepare_session_graph_source_set( + envelope, + sources, + timeline, + word.clone(), + vec![test_mapping(0)], + ) + .err() + }; + assert_eq!(seal(vec![source(&word)], timeline(&word)), None); + assert_eq!( + seal(vec![source(&other)], timeline(&word)), + Some(SourceGraphSourceSetError::SessionWord) + ); + assert_eq!( + seal(vec![source(&word)], timeline(&other)), + Some(SourceGraphSourceSetError::SessionWord) + ); + assert_eq!( + seal(vec![SourceGraphSource::vacant(1)], timeline(&word)), + None, + "a vacant entry has no word" + ); + + let (_, _, mut predecessor) = fenced(&other); + let (_, _, mut successor) = fenced(&word); + successor.sources[0].consumer = None; + assert!(!successor.adopt_sources(&mut predecessor, &[(0, 0)])); + assert!(!successor.swappable(&predecessor, &[])); + assert!(successor.sources[0].consumer.is_none(), "nothing moved"); + let (_, _, mut same_session) = fenced(&word); + assert!(successor.adopt_sources(&mut same_session, &[(0, 0)])); + } + } } diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index d62541d1f..612e45ed4 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -166,7 +166,8 @@ reclaims the plans render has retired and brings the provider epochs up to rende provider's counters (the controller's telemetry counters today), stages the render observation, and refreshes the counters again so a record that staging coalesced or dropped is counted in the same call. Every other session control call (`source_submit_planar_f32`, `source_seek`, -`source_seek_at`, `submit_command`, `dequeue_event`) that passes its argument checks runs the same +`source_seek_at`, `session_seek` and `session_seek_at` (#1642), `submit_command`, `dequeue_event`) +that passes its argument checks runs the same step first, in place of the parts each ran before; a call refused for its arguments (a null `source_id`, a bad struct size, an invalid lane, an empty request) returns before the step. Before #1348 only `submit_command` refreshed the counters and the source @@ -218,6 +219,53 @@ untouched. The frozen exported set is now 19 `miso_engine_v1_*` definitions (wit offset and the signature, and the capi runtime tests cover a plain seek, a late anchored seek, a scrub-then-align across a transaction, a carried and an added ring, and the two counters. +A host moves the playhead with one call, `miso_engine_v1_session_seek(session, g, T)` and its +anchored form `miso_engine_v1_session_seek_at(session, g, T, A)` (#1642), an in-place V1 +amendment: two new exported symbols, announced by `MISO_ENGINE_V1_FEATURE_SESSION_SEEK` (bit 10, +1024) in the capability report, whose feature mask grows to 2047; no struct, size or `ABI_VERSION` +changes. The timeline is the session's playhead: it starts at 0, advances one quantum per render +and stored automation follows it (#1058). The call moves it and every source of the newest +committed session (a pending candidate's set while a swap waits, so a source the candidate adds is +sought too) in one step, all or nothing: the timeline to `T`, each source to `min(T, its frames)`; +the host then submits generation `g` from each source's frame. `g` must be above every generation +the session has used (one above the largest the host has used is always valid). It checks every +consumer's slot before it pushes to any, pushes every command unpublished and then publishes `g` +once to the session's published-generation word, which render loads once per block entry and +at or below which alone a consumer takes a command; so every consumer takes the seek at the same block +entry or none does, and a held-back command keeps its slot, so no ack precedes a drop. Results: +`OK`; `INVALID_ARGUMENT` for `g = 0` (`source.generation.zero`), a `g` not above the session's +published generation or a consumer's (`source.generation.stale`), an unaligned anchor +(`source.seek.anchor_unaligned`) or a `T` over the timeline limit +(`source.seek.frame_out_of_range`); `BACKPRESSURE` with `source.seek.backpressure` while any slot +holds a seek; the source seek's mapping otherwise (`INVALID_ARGUMENT` with `source.ring.vacated`, +`INTERNAL` with `capi.source.epoch` or `source.internal`). The anchored form takes the anchor of +`miso_engine_v1_source_seek_at`. A per-source seek leaves the timeline where it is. + +A plain session seek is a declared discontinuity; the engine declares it in the same call, and a +refused seek declares nothing (root ruling B16-R9). `control_plane::SessionState::session_seek`, +the one function behind both hosts, runs the service step, checks every consumer +(`SourceControlSet::check_session_seek`), declares the discontinuity through the core path of +`miso_engine_v1_declare_discontinuity` (#1323, all or nothing), then pushes and publishes. A refused +check declares nothing and pushes nothing; a refused declaration pushes nothing and returns its own +result and diagnostic, as `miso_engine_v1_declare_discontinuity` returns them (`COMPILE_REJECTED`, +`BACKPRESSURE` or `INTERNAL`). A declaration that supersedes a waiting replacement moves every +producer the check ran on into the successor, so the push reaches the producers it checked; a +refusal of that push is an engine invariant (`INTERNAL`, `source.internal`). An anchored session +seek keeps continuity: it declares nothing, because it is the loop and exact-start form, and a +declaration publishes its successor at the next block, before the anchor, which would put the +effects at rest and drop the compensation lines while the old pass still plays. A host that wants +the floors reset declares at a stop. A seek of every source made with per-source seeks is still the +host's own call, so `miso_engine_v1_declare_discontinuity` keeps its duty for that case and for a +stop. No host or SDK calls it before a session seek (this retires B15-R7's order). The frozen +exported set is now 21 `miso_engine_v1_*` definitions; `abi_smoke.c` checks the bit against the +queried mask and calls both symbols with a null session for `INVALID_ARGUMENT`, `header_smoke.cpp` +pins the bit inside the mask and both signatures, and `audit capi` runs a session seek (which +declares the discontinuity itself; on the audit session it publishes nothing) outside its render +scope with 0 violations. The gates: `capi` `runtime::tests::session_seek_declares` (a refused plain +seek publishes nothing; an accepted one publishes exactly one successor and applies, also across a +waiting replacement; an anchored one publishes none) and the parity row +`control_parity::a_plain_session_seek_declares_and_an_anchored_one_does_not_alike_on_both_hosts`. + The seek contract in the header (#1317, decision 15 D15-12) states, from the code, the source-read clock and its origin (0 at compile, one quantum per render call, continued by a swapped-in plan), the plain and the anchored seek, how to choose the anchor, held behaviour (queued PCM of the @@ -996,8 +1044,10 @@ that window is admitted against the plan that renders. service step first. The browser's `miso_engine_web_v1_declare_discontinuity(handle)` forwards to the same control-plane function, `control_plane::SessionState::declare_discontinuity` (root ruling B15-R7); the browser SDK surface is #1296's. -- **Duty.** A host calls it at a stop, and before a seek of every source: the next block it renders - need not continue the previous output. +- **Duty.** A host calls it at a stop, and before a seek of every source made with per-source + seeks: the next block it renders need not continue the previous output. A plain session seek + (#1642) declares it in the same call, so a host does not call it before one; an anchored session + seek keeps continuity (root ruling B16-R9). - **Behavior.** If the newest plan (the waiting replacement, else the running plan) runs behind its natural timing (a floor raised some node's input arrival above its natural one; the inventory records each raised node's natural arrival beside its floored one), the committed model is diff --git a/hosts/host-web/Cargo.toml b/hosts/host-web/Cargo.toml index 5fb75ac85..aa26fac11 100644 --- a/hosts/host-web/Cargo.toml +++ b/hosts/host-web/Cargo.toml @@ -56,7 +56,14 @@ serde_json = "=1.0.151" [features] default = [] -test-support = ["builtins-compiler/test-support", "host-core/test-support"] +# `control-plane/test-support` and `engine/test-support` let the timeline test reader reach the +# active plan (`PlanState::test_owner_mut`, `RealtimePlanOwner::active_plan_mut`, #1643). +test-support = [ + "builtins-compiler/test-support", + "control-plane/test-support", + "engine/test-support", + "host-core/test-support", +] [lints] workspace = true diff --git a/hosts/host-web/src/ffi.rs b/hosts/host-web/src/ffi.rs index 044051f2d..5df036851 100644 --- a/hosts/host-web/src/ffi.rs +++ b/hosts/host-web/src/ffi.rs @@ -3331,6 +3331,50 @@ pub extern "C" fn miso_engine_web_v1_source_seek_at( }) } +/// Seek the timeline and every source to timeline frame `frame` under one new `generation`, all or +/// nothing (issue #1643 D1). It runs the control plane's `SessionState::session_seek`, the call the +/// C ABI's `miso_engine_v1_session_seek` runs, so the control Worker (or in `single` mode the +/// worklet's message handler) calls it, never `process()`: it runs the service step. It never +/// prepares the plan's consumer; every consumer observes the seek at the next block. It is a +/// declared discontinuity (root ruling B16-R9): after every check and before any push it declares +/// it as [`miso_engine_web_v1_declare_discontinuity`] does, and a plan that publishes is counted as +/// a blocking rebuild; a refused seek declares nothing, so the page never declares before it. +/// +/// Results: [`RESULT_OK`]; [`RESULT_INVALID_ARGUMENT`] for a wrong handle, a generation that is +/// zero or not above every generation the session has published (`source.generation.stale`) or any +/// other malformed seek; [`RESULT_BACKPRESSURE`] when one consumer's slot is full (nothing +/// changes); [`RESULT_REFUSED_LIFECYCLE`] for a host that is not ready (nothing changes); +/// [`RESULT_INTERNAL`]; and the refusals of [`miso_engine_web_v1_declare_discontinuity`] for its +/// declaration, with the core's diagnostic at [`miso_engine_web_v1_control_response_ptr`] and +/// nothing pushed. +#[unsafe(no_mangle)] +pub extern "C" fn miso_engine_web_v1_session_seek(handle: u32, generation: u64, frame: u64) -> u32 { + with_host_mut(handle, RESULT_INVALID_ARGUMENT, |host| { + with_promotion_analysis(host, |host, analysis| { + host.session_seek(analysis, generation, frame, None) + }) + }) +} + +/// The anchored form of [`miso_engine_web_v1_session_seek`] (issue #1643 D1): every consumer +/// applies the seek in the block that starts at absolute render sample `anchor`, which must be a +/// quantum multiple. Same function, same results and same thread rule as the C ABI's +/// `miso_engine_v1_session_seek_at`. It declares no discontinuity: the loop and exact-start form +/// keeps continuity (root ruling B16-R9). +#[unsafe(no_mangle)] +pub extern "C" fn miso_engine_web_v1_session_seek_at( + handle: u32, + generation: u64, + frame: u64, + anchor: u64, +) -> u32 { + with_host_mut(handle, RESULT_INVALID_ARGUMENT, |host| { + with_promotion_analysis(host, |host, analysis| { + host.session_seek(analysis, generation, frame, Some(anchor)) + }) + }) +} + /// Copy where the staged source landed after its newest seek into the fixed 64-byte record at /// [`miso_engine_web_v1_source_seek_report_ptr`] (issue #1293 D6): the generation in effect, the /// source-read sample and source frame of its first block, and the cumulative underrun and held @@ -3440,7 +3484,8 @@ pub extern "C" fn miso_engine_web_v1_service(handle: u32) -> u32 { /// Declare a discontinuity (issue #1323): the next block need not continue the previous output, /// because the page has stopped or is about to seek every source. The page calls it at a stop and -/// before a seek of every source. It runs the control plane's `SessionState::declare_discontinuity`, +/// before a seek of every source made with per-source seeks; a plain +/// [`miso_engine_web_v1_session_seek`] declares it in the same call (root ruling B16-R9). It runs the control plane's `SessionState::declare_discontinuity`, /// the function the C ABI's `miso_engine_v1_declare_discontinuity` runs, so both hosts reset the /// latency floors alike. Control side only: the control Worker, or in `single` mode the worklet's /// message handler, never `process()`; a plan it publishes is counted as a blocking rebuild. @@ -4241,6 +4286,24 @@ pub mod native_staging { super::with_host(handle, None, |host| host.active_plan_epoch_for_test()) } + /// Whether no seek waits in a session seek's command slots, for the host `handle` names + /// (batch 16b review, MINOR f); `None` for an unknown handle or one with no control half. + #[cfg(feature = "test-support")] + pub fn session_seek_slots_empty(handle: u32) -> Option { + super::with_host(handle, None, |host| { + host.session_seek_slots_empty_for_test() + }) + } + + /// The session timeline's sample at `source_read_sample` in the plan that renders, for the + /// host `handle` names (#1643 gates); `None` for an unknown handle. + #[cfg(feature = "test-support")] + pub fn timeline_at(handle: u32, source_read_sample: u64) -> Option { + super::with_host_mut(handle, None, |host| { + host.timeline_at_for_test(source_read_sample) + }) + } + /// The status record of the host `handle` names; `None` for an unknown handle. pub fn status(handle: u32) -> Option { super::with_host(handle, None, |host| Some(*host.status())) diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index fc80aebaa..54415d54b 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -35,8 +35,8 @@ use builtins::{MeterMetricSet, MeterSnapshot, MeterTap}; use builtins_compiler::MeterConsumer; use control_plane::{ CompileFailure, ControlAdapter, ControlLimits, ObservationChange, ObservationKeyError, - ObservationKeyRef, ObservationRefusal, PlanQueries, PlanResources, PlanState, SessionState, - SourceFailure, + ObservationKeyRef, ObservationRefusal, PlanQueries, PlanResources, PlanState, + SessionSeekFailure, SessionState, SourceFailure, }; use effect_contract::{ObservationCost, ObservationDescriptor}; use engine::realtime::{ @@ -1681,9 +1681,12 @@ impl ReadyOwnership { analysis: Option<&mut SpectrumAnalysis>, applied_at_sample: u64, ) -> Result { - self.run_control(analysis, applied_at_sample, |control| { - control.command(request, output_capacity) - }) + self.run_control( + analysis, + applied_at_sample, + control_plane::CommandError::Backpressure, + |control| control.command(request, output_capacity), + ) } /// Declare a discontinuity through the control plane (#1323), the function the C ABI's @@ -1694,24 +1697,53 @@ impl ReadyOwnership { analysis: Option<&mut SpectrumAnalysis>, applied_at_sample: u64, ) -> Result<(), control_plane::CommandError> { - self.run_control(analysis, applied_at_sample, |control| { - control.declare_discontinuity().map(|()| 0) - }) - .map(|_| ()) + self.run_control( + analysis, + applied_at_sample, + control_plane::CommandError::Backpressure, + WebSession::declare_discontinuity, + ) + } + + /// Seek the session through the control plane (#1643 D1): `SessionState::session_seek`, the + /// call the C ABI's `miso_engine_v1_session_seek` and `_session_seek_at` run. The plain form + /// declares the discontinuity in the same call (root ruling B16-R9), so a plan it publishes + /// is kept and counted as [`Self::declare_discontinuity`] keeps one; the anchored form + /// declares nothing and so publishes nothing. + fn session_seek( + &mut self, + analysis: Option<&mut SpectrumAnalysis>, + applied_at_sample: u64, + generation: u64, + timeline_frame: u64, + anchor_sample: Option, + ) -> Result<(), SessionSeekFailure> { + if anchor_sample.is_some() { + return self + .control + .session_seek(generation, timeline_frame, anchor_sample); + } + self.run_control( + analysis, + applied_at_sample, + SessionSeekFailure::Declaration(control_plane::CommandError::Backpressure), + |control| control.session_seek(generation, timeline_frame, None), + ) } /// Run one control-plane call that may publish a replacement plan (#1290a, #1323): reclaim - /// first, check the room for the plan's tables, run `call`, and keep the tables of a plan it - /// published for its adoption block. - fn run_control( + /// first, check the room for the plan's tables (refusing with `full` when there is none, with + /// nothing run), run `call`, and keep the tables of a plan it published for its adoption block. + fn run_control( &mut self, analysis: Option<&mut SpectrumAnalysis>, applied_at_sample: u64, - call: impl FnOnce(&mut WebSession) -> Result, - ) -> Result { + full: E, + call: impl FnOnce(&mut WebSession) -> Result, + ) -> Result { self.reclaim_shapes(analysis); if self.pending_shapes.len() == self.pending_shapes.capacity() { - return Err(control_plane::CommandError::Backpressure); + return Err(full); } let before = self.control.newest_plan_epoch(); let result = call(&mut self.control); @@ -2462,6 +2494,35 @@ impl AudioWorkletEngineHost { self.plan.as_ref().map(|plan| plan.owner().active_epoch().0) } + /// Whether no seek waits in a command slot of the newest committed session's timeline and + /// sources (batch 16b review, MINOR f): a session seek one above the published generation + /// passes its check, which a full slot refuses. `None` for a host with no control half. It + /// shows a stray push on a host that cannot render the block that would empty the slot. + #[cfg(feature = "test-support")] + #[must_use] + pub fn session_seek_slots_empty_for_test(&self) -> Option { + let control = &self.ready.as_ref()?.control; + let providers = control + .test_pending_providers() + .last() + .unwrap_or_else(|| control.test_providers()); + let sources = providers.test_sources(); + let next = sources.published_generation().checked_add(1)?; + Some(sources.check_session_seek(next, 0, None).is_ok()) + } + + /// The session timeline's sample at `source_read_sample` in the plan that renders (#1641's + /// test reader, #1643 gates); `None` for a host with no plan, a plan with no timeline, or a + /// sample the reader has no record for. Between render calls only. + #[cfg(feature = "test-support")] + pub fn timeline_at_for_test(&mut self, source_read_sample: u64) -> Option { + let plan = self.plan.as_mut()?; + host_core::test_only_timeline_at( + plan.test_owner_mut().active_plan_mut(), + source_read_sample, + ) + } + /// Render one block through the plan owner at `absolute_sample` rather than the host's clock, /// and nothing else (#1398 gate 1b); `false` when the owner refused it or the host is not /// ready. A mistimed block adopts a published successor before the owner checks its time, and @@ -2546,7 +2607,9 @@ impl AudioWorkletEngineHost { /// Declare a discontinuity (#1323): the next block this host renders need not continue the /// previous output, because the page has stopped or is about to seek every source. The page - /// calls it at a stop and before a seek of every source. It runs the control plane's + /// calls it at a stop and before a seek of every source made with per-source seeks (a plain + /// [`Self::session_seek`] declares it in the same call, root ruling B16-R9). It runs the + /// control plane's /// `SessionState::declare_discontinuity`, the function the C ABI's /// `miso_engine_v1_declare_discontinuity` runs: if the newest plan runs behind its natural /// timing (a latency floor of a structural transaction raised it, #1285), the committed @@ -3675,6 +3738,54 @@ impl AudioWorkletEngineHost { self.record(code) } + /// Seek the timeline and every source of the newest committed session to timeline sample + /// `timeline_frame` under one new `generation`, all or nothing (#1643 D1). + /// + /// It runs the control plane's `SessionState::session_seek`, the call the C ABI's + /// `miso_engine_v1_session_seek` and `_session_seek_at` run, after its service step, so it is + /// a control-side call (the message handler, never `process()`). With `anchor_sample`, every + /// consumer applies the seek in the block that starts there. This adapter holds no seek + /// logic, and it never prepares the plan's consumer: every consumer observes the seek through + /// its command queue at the next block, the timeline included. A host that is not ready + /// refuses with [`RESULT_REFUSED_LIFECYCLE`] and changes nothing; the core's refusals map + /// through `source_failure_result` (a stale generation or an anchor that is not a quantum + /// multiple is [`RESULT_INVALID_ARGUMENT`], one full slot [`RESULT_BACKPRESSURE`]). The plain + /// form declares the discontinuity in the same call (root ruling B16-R9): a plan it publishes + /// is kept and counted as [`Self::declare_discontinuity`]'s, and a refused declaration + /// reports as that call's does, with nothing pushed. The anchored form declares nothing. + pub fn session_seek( + &mut self, + analysis: Option<&mut SpectrumAnalysis>, + generation: u64, + timeline_frame: u64, + anchor_sample: Option, + ) -> u32 { + self.control_response = ControlResponse::None; + if self.status.state != STATE_READY { + return self.record(RESULT_REFUSED_LIFECYCLE); + } + let applied_at_sample = self.status.next_absolute_sample; + let Some(ready) = self.ready.as_mut() else { + return self.record(RESULT_REFUSED_LIFECYCLE); + }; + match ready.session_seek( + analysis, + applied_at_sample, + generation, + timeline_frame, + anchor_sample, + ) { + Ok(()) => self.record(RESULT_OK), + Err(SessionSeekFailure::Seek(failure)) => { + let code = source_failure_result(failure); + self.record(code) + } + // B16-R9: the plain form's declaration refused, reported as + // [`Self::declare_discontinuity`] reports its own. + Err(SessionSeekFailure::Declaration(error)) => self.refuse_control(&error), + } + } + // REALTIME_POLICY_BEGIN /// Render exactly one prepared quantum at the next absolute sample time. pub fn render_next(&mut self) -> u32 { diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 3c6b54398..b35ff90f5 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -4108,7 +4108,9 @@ fn observe( } /// [`observe`] naming the tap by its stable key: the strip's ID, the console slot's or insert's ID -/// and the tap's ID (#1677), whatever the plan that renders calls its position. +/// and the tap's ID (#1677), whatever the plan that renders calls its position. Test support only: +/// every caller is in a `test-support` module. +#[cfg(feature = "test-support")] fn observe_key( host: &mut AudioWorkletEngineHost, strip_id: &str, @@ -4137,7 +4139,9 @@ fn observe_key( } /// The armed-tap mask of the insert `insert_id` of strip `strip_id` on the plan that renders, or -/// `None` when the plan has no such insert. +/// `None` when the plan has no such insert. Test support only: every caller is in a `test-support` +/// module. +#[cfg(feature = "test-support")] fn armed_mask(host: &AudioWorkletEngineHost, strip_id: &str, insert_id: &str) -> Option { let ready = host.ready.as_ref().expect("ready"); let strip = strip_index( @@ -9534,3 +9538,433 @@ fn a_subscription_past_the_frame_is_refused_and_an_unsubscribe_makes_room() { ); assert_eq!(armed(&host), probe_cap); } + +/// Issue #1643: the session seek exports, driven as the page's message handler drives them. +/// Test support only: it reads the timeline and the slots through `native_staging`'s +/// `test-support` helpers. +#[cfg(feature = "test-support")] +mod session_seek { + use super::*; + use protocol::{ + CommandPayload, DecodeScratch, DecodedSuccessResponsePayload, DecodedTypedResponseFrame, + EditPath, ExpectedRevision, ProtocolCodec, RequestId, SessionEdit, SessionRevision, + TypedCommandFrame, + }; + use session::{RouteSource, SessionModel, StableId}; + + const LONG: &[u8] = b"fixture-source"; + const SHORT: &[u8] = b"short-source"; + const ADDED: &[u8] = b"added-source"; + const Q: u64 = 128; + + /// `base` with one more source, track and route: ID `source_id`, `frames` long. + fn add_source( + base: &SessionModel, + source_id: &str, + track_id: &str, + frames: u64, + ) -> [SessionEdit; 3] { + let mut source = base.sources[0].clone(); + source.id = StableId::parse(source_id).expect("source ID"); + source.frames = frames; + let mut track = base.tracks[0].clone(); + track.id = StableId::parse(track_id).expect("track ID"); + track.source_id = source.id.clone(); + let mut route = base.routes[0].clone(); + route.id = StableId::parse(&format!("{track_id}-main")).expect("route ID"); + let RouteSource::Track { track_id, .. } = &mut route.source else { + panic!("a track route") + }; + *track_id = track.id.clone(); + [ + SessionEdit::UpsertSource { source }, + SessionEdit::UpsertTrack { track }, + SessionEdit::UpsertRoute { route }, + ] + } + + /// The identity fixture at quantum 128 with `fixture-source` 10,000 frames long. + fn one_source() -> SessionModel { + let mut model = parse_session_json(&identity_session(Q as u32, 10_000)).expect("session"); + model.sources[0].frames = 10_000; + model + } + + /// `fixture-source` (10,000 frames) and `short-source` (4,000 frames), one track each. + fn two_sources() -> SessionModel { + let mut model = one_source(); + let [ + SessionEdit::UpsertSource { source }, + SessionEdit::UpsertTrack { track }, + SessionEdit::UpsertRoute { route }, + ] = add_source(&model, "short-source", "t2", 4_000) + else { + panic!("three upserts") + }; + model.sources.push(source); + model.tracks.push(track); + model.routes.push(route); + model + } + + /// One booted host driven through the raw exports, with `fixture-source` primed for four + /// blocks of generation 1 (and `short-source`, if the session has it). + struct Rig { + handle: u32, + block: u64, + sources: Vec<&'static [u8]>, + } + + impl Rig { + fn boot(model: &SessionModel, sources: &[&'static [u8]]) -> Self { + let document = canonical_session_json(model).expect("canonical session"); + let options = WebBootOptions { + source_ring_frames: 512, + ..boot_options(Q as u32) + }; + let handle = crate::ffi::test_boot(document.as_bytes(), options); + assert_ne!(handle, 0); + assert_eq!(crate::ffi::test_fill_source_pcm(handle, 0.25), RESULT_OK); + let mut rig = Self { + handle, + block: 0, + sources: sources.to_vec(), + }; + for _ in 0..4 { + for id in sources { + assert_eq!(rig.submit(id, 1, rig.block * Q), RESULT_OK); + } + rig.render(); + } + rig + } + + fn stage(&self, id: &[u8]) { + assert_eq!( + crate::ffi::test_copy_staging(self.handle, BUFFER_SOURCE_ID, id), + RESULT_OK + ); + } + + /// One quantum of `id`'s generation `generation` from source frame `frame`. + fn submit(&self, id: &[u8], generation: u64, frame: u64) -> u32 { + self.stage(id); + miso_engine_web_v1_source_submit( + self.handle, + id.len() as u32, + generation, + frame, + 2, + Q as u32, + 0, + ) + } + + fn feed(&self, generation: u64, frame: u64) { + for id in &self.sources { + assert_eq!(self.submit(id, generation, frame), RESULT_OK); + } + } + + fn render(&mut self) -> Vec { + assert_eq!(miso_engine_web_v1_render(self.handle, Q as u32), RESULT_OK); + self.block += 1; + crate::ffi::native_staging::output_pcm(self.handle) + .expect("output") + .iter() + .map(|sample| sample.to_bits()) + .collect() + } + + /// The first sample of the next block. + fn next(&self) -> u64 { + self.block * Q + } + + fn timeline(&self, sample: u64) -> i64 { + crate::ffi::native_staging::timeline_at(self.handle, sample).expect("a timeline") + } + + /// `(generation, first_sample, source_frame)` of `id`'s seek report. + fn landed(&self, id: &[u8]) -> (u64, u64, u64) { + self.stage(id); + assert_eq!( + miso_engine_web_v1_source_seek_report(self.handle, id.len() as u32), + RESULT_OK + ); + let record = crate::ffi::native_staging::seek_report(self.handle).expect("record"); + (record.generation, record.first_sample, record.source_frame) + } + + fn last_result(&self) -> u32 { + crate::ffi::test_status(self.handle) + .expect("status") + .last_result + } + } + + impl Drop for Rig { + fn drop(&mut self) { + assert_eq!(miso_engine_web_v1_dispose(self.handle), RESULT_OK); + } + } + + /// Gate 1 up to its anchored seek: `session_seek(2, 6_000)` and the block that applied it. + fn after_plain_seek() -> Rig { + let mut rig = Rig::boot(&two_sources(), &[LONG, SHORT]); + assert_eq!( + miso_engine_web_v1_session_seek(rig.handle, 2, 6_000), + RESULT_OK + ); + // The long source's PCM from the sought frame. The short source clamps to its end, 4,000, + // where it has no frame to submit: its seek report still names that frame. + assert_eq!(rig.submit(LONG, 2, 6_000), RESULT_OK); + let sample = rig.next(); + rig.render(); + assert_eq!(rig.landed(LONG), (2, sample, 6_000)); + assert_eq!(rig.landed(SHORT), (2, sample, 4_000), "clamped to its end"); + assert_eq!(rig.timeline(sample), 6_000); + rig + } + + /// Gate 1, both forms move the timeline and every source in the one block. + /// + /// Test value: red if the export seeks the consumers in different blocks (for example through + /// `prepare_source_seek` for the sources only, which would also leave the timeline behind), + /// clamps a short source wrongly, or drops the anchor of the anchored form. + #[test] + fn both_forms_move_the_timeline_and_every_source_at_one_block() { + let mut rig = after_plain_seek(); + let anchor = rig.next() + 2 * Q; + assert_eq!( + miso_engine_web_v1_session_seek_at(rig.handle, 3, 1_000, anchor), + RESULT_OK + ); + // Held before the anchor: the plain seek's generation and a timeline that runs on. + for block in 1..=2 { + let sample = rig.next(); + rig.render(); + assert_eq!(rig.landed(LONG).0, 2); + assert_eq!(rig.landed(SHORT).0, 2); + assert_eq!(rig.timeline(sample), (6_000 + block * Q) as i64); + } + rig.feed(3, 1_000); + let sample = rig.next(); + assert_eq!(sample, anchor); + rig.render(); + assert_eq!(rig.landed(LONG), (3, anchor, 1_000)); + assert_eq!(rig.landed(SHORT), (3, anchor, 1_000)); + assert_eq!(rig.timeline(anchor), 1_000); + } + + /// Gate 1, a stale generation changes nothing, under the consumers' own rule and under the + /// session's published generation. + /// + /// Test value: red if the export checks the generation per consumer after earlier pushes, or + /// if a stale refusal pushes to any consumer (the short source and the timeline stay put). + #[test] + fn a_stale_generation_changes_nothing() { + // The twin takes no stale call. The host holds one instance per thread, so it runs and is + // disposed first. + let (twin_bits, twin_timeline) = { + let mut twin = after_plain_seek(); + let sample = twin.next(); + assert_eq!(twin.submit(LONG, 2, 6_000 + Q), RESULT_OK); + (twin.render(), twin.timeline(sample)) + }; + let mut refused = after_plain_seek(); + assert_eq!( + miso_engine_web_v1_session_seek(refused.handle, 2, 0), + RESULT_INVALID_ARGUMENT + ); + assert_eq!(refused.last_result(), RESULT_INVALID_ARGUMENT); + let sample = refused.next(); + assert_eq!(refused.submit(LONG, 2, 6_000 + Q), RESULT_OK); + assert_eq!(refused.render(), twin_bits, "the next block is the same"); + assert_eq!(refused.timeline(sample), twin_timeline); + assert_eq!(refused.timeline(sample), (6_000 + Q) as i64); + + // A per-source seek to generation 5 makes a session seek at 4 stale. The long source's + // own generation (5) and the session's published generation both refuse it, so this case + // does not tell the two rules apart; the C ABI's + // `a_removed_sources_generation_keeps_a_session_seek_stale` isolates the published one. + refused.stage(LONG); + assert_eq!( + miso_engine_web_v1_source_seek(refused.handle, LONG.len() as u32, 5, 0), + RESULT_OK + ); + refused.render(); + assert_eq!( + miso_engine_web_v1_session_seek(refused.handle, 4, 0), + RESULT_INVALID_ARGUMENT + ); + let sample = refused.next(); + refused.render(); + assert_eq!(refused.landed(SHORT).0, 2, "the refusal pushed nothing"); + assert_eq!(refused.timeline(sample), (6_000 + 3 * Q) as i64); + assert_eq!( + miso_engine_web_v1_session_seek(refused.handle, 6, 0), + RESULT_OK + ); + } + + /// Gate 1, one full source slot refuses every consumer. + /// + /// Test value: red if a slot is pushed before every slot is checked (the partial seek the + /// acked-batch rule forbids): the short source or the timeline would have moved. + #[test] + fn one_full_slot_refuses_every_consumer() { + let mut rig = Rig::boot(&two_sources(), &[LONG, SHORT]); + let anchor = rig.next() + 4 * Q; + // The long source's slot holds this seek until the next block's pop; it is full here + // only because no block renders between the two calls. + rig.stage(LONG); + assert_eq!( + miso_engine_web_v1_source_seek_at(rig.handle, LONG.len() as u32, 2, 640, anchor), + RESULT_OK + ); + assert_eq!( + miso_engine_web_v1_session_seek(rig.handle, 3, 2_000), + RESULT_BACKPRESSURE + ); + assert_eq!(rig.last_result(), RESULT_BACKPRESSURE); + // The short source's slot and the timeline's stayed empty. + rig.stage(SHORT); + assert_eq!( + miso_engine_web_v1_source_seek_at(rig.handle, SHORT.len() as u32, 3, 100, anchor), + RESULT_OK + ); + let sample = rig.next(); + rig.render(); + assert_eq!(rig.landed(SHORT).0, 1, "held until the anchor"); + assert_eq!( + rig.timeline(sample), + sample as i64, + "the timeline did not move" + ); + } + + /// Gate 3a, the role: on a host that is not ready (a sticky-failed host, which still holds its + /// control state) both exports refuse with `RESULT_REFUSED_LIFECYCLE` and change nothing; a + /// null handle is `RESULT_INVALID_ARGUMENT`. + /// + /// Test value: red if either export runs without checking the host is ready (the failed host + /// still holds its control state, so the seek would be queued in its slots, which no block + /// empties), or moves the report record or the timeline on a refusal. + #[test] + fn the_session_seek_exports_refuse_a_sticky_failed_host_and_change_nothing() { + let mut rig = Rig::boot(&one_source(), &[LONG]); + assert_eq!( + miso_engine_web_v1_session_seek(0, 2, 0), + RESULT_INVALID_ARGUMENT + ); + assert_eq!( + miso_engine_web_v1_session_seek_at(0, 2, 0, 256), + RESULT_INVALID_ARGUMENT + ); + assert_eq!( + miso_engine_web_v1_session_seek(rig.handle, 2, 3_000), + RESULT_OK + ); + assert_eq!(rig.submit(LONG, 2, 3_000), RESULT_OK); + let sample = rig.next(); + rig.render(); + let landed = rig.landed(LONG); + assert_eq!(landed, (2, sample, 3_000)); + let timeline = rig.timeline(sample); + assert_eq!(timeline, 3_000); + + // An output-shape mismatch makes the host sticky-failed: no control half to run on. + assert_eq!( + miso_engine_web_v1_render(rig.handle, 0), + RESULT_REPREPARE_REQUIRED + ); + assert_eq!( + crate::ffi::test_status(rig.handle).expect("status").state, + STATE_FAILED + ); + let record = crate::ffi::native_staging::seek_report(rig.handle); + assert_eq!( + miso_engine_web_v1_session_seek(rig.handle, 3, 0), + RESULT_REFUSED_LIFECYCLE + ); + assert_eq!( + miso_engine_web_v1_session_seek_at(rig.handle, 4, 0, 1_024), + RESULT_REFUSED_LIFECYCLE + ); + assert_eq!(crate::ffi::native_staging::seek_report(rig.handle), record); + assert_eq!(rig.timeline(sample), timeline); + // A failed host renders no block, so a stray push would wait in its slot: none does. + assert_eq!( + crate::ffi::native_staging::session_seek_slots_empty(rig.handle), + Some(true), + "the refusals queued no seek" + ); + } + + /// Gate 3a, the pending candidate: a transaction that adds a source is committed through the + /// control entry, and a session seek before its swap reaches the carried source, the added + /// source and the timeline in the swap block. + /// + /// Test value: red if the export seeks the running plan's producers instead of the newest + /// committed session's: the added source would not be sought, and the carried source's seek + /// would land in a ring the candidate does not hold. + #[test] + fn a_seek_before_the_swap_reaches_the_carried_and_the_added_source_and_the_timeline() { + let model = one_source(); + let mut rig = Rig::boot(&model, &[LONG]); + let codec = ProtocolCodec::default(); + let mut bytes = vec![0_u8; codec.limits().max_frame_bytes]; + let length = codec + .encode_command_frame_into( + &TypedCommandFrame { + request_id: RequestId::new(1).expect("nonzero request"), + expected_revision: ExpectedRevision::Exact(SessionRevision(model.revision)), + payload: CommandPayload::SessionTransactionApply(&add_source( + &model, + "added-source", + "t2", + 5_000, + )), + }, + &mut bytes, + ) + .expect("a transaction frame"); + let (result, response) = + crate::ffi::native_staging::control_command(rig.handle, &bytes[..length]) + .expect("the control staging"); + assert_eq!(result, RESULT_OK, "the control plane answers the frame"); + let mut fields = [0_u16; 64]; + let DecodedTypedResponseFrame::Success { + payload: DecodedSuccessResponsePayload::SessionTransactionApplied(applied), + .. + } = codec + .decode_typed_response(&response, &mut DecodeScratch::new(&mut fields)) + .expect("a transaction response") + else { + panic!("the transaction is applied") + }; + assert_eq!(applied.edit_path, EditPath::Rebuild); + assert_eq!( + crate::ffi::native_staging::active_plan_epoch(rig.handle), + Some(0), + "the candidate waits for its swap" + ); + + assert_eq!( + miso_engine_web_v1_session_seek(rig.handle, 2, 3_000), + RESULT_OK + ); + assert_eq!(rig.submit(LONG, 2, 3_000), RESULT_OK); + let swap = rig.next(); + rig.render(); + assert_eq!( + crate::ffi::native_staging::active_plan_epoch(rig.handle), + Some(1), + "the seek block is the swap block" + ); + assert_eq!(rig.landed(LONG), (2, swap, 3_000)); + assert_eq!(rig.landed(ADDED), (2, swap, 3_000)); + assert_eq!(rig.timeline(swap), 3_000); + } +} diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 337d92d06..d7a397d71 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -3946,3 +3946,346 @@ fn the_seek_report_is_alike_on_both_hosts_for_plain_late_and_future_seeks() { assert!(ahead.4 > 0, "{label} ahead: the wait held frames"); } } + +// --------------------------------------------------------------------------------------------- +// Session seek (#1642, #1643 gate 6) +// --------------------------------------------------------------------------------------------- + +#[cfg(feature = "test-support")] +impl CAbi { + fn session_seek(&self, generation: u64, timeline_frame: u64, anchor: Option) { + let code = self.session_seek_code(generation, timeline_frame, anchor); + assert_eq!(code, C_RESULT_OK, "C ABI session seek"); + } + + /// [`Self::session_seek`]'s result code, unchecked. + fn session_seek_code(&self, generation: u64, timeline_frame: u64, anchor: Option) -> u32 { + // SAFETY: The session is live and used by this thread alone. + unsafe { + match anchor { + None => capi::miso_engine_v1_session_seek(self.session, generation, timeline_frame), + Some(anchor) => capi::miso_engine_v1_session_seek_at( + self.session, + generation, + timeline_frame, + anchor, + ), + } + } + } +} + +#[cfg(feature = "test-support")] +/// Block `block` on both hosts: `feeds[i]` is the generation and the first source block of the +/// chunk fed to `src-t(i + 1)`, or `None` for no chunk. The output bits equal. +fn session_block( + browser: &mut Rig, + c_abi: &mut CAbi, + block: u64, + feeds: [Option<(u64, u64)>; 3], + label: &str, +) { + let rate = browser.rate; + for (feed, strip) in [T1, T2, T3].into_iter().enumerate() { + let Some((generation, start)) = feeds[feed] else { + continue; + }; + let source = format!("src-{}", strip.id); + let [left, right] = planes(feed as u64, block); + assert_eq!( + browser.host.submit_source( + source.as_bytes(), + generation, + start * u64::from(QUANTUM), + rate, + &[&left, &right], + QUANTUM, + false, + ), + host_web::RESULT_OK, + "{label}: browser {source}" + ); + c_abi.submit_from(&source, feed as u64, block, start, generation); + } + assert_eq!(browser.host.render_next(), host_web::RESULT_OK); + let b = bits(browser.host.output_pcm().expect("browser output")); + assert_eq!(b, c_abi.render(block), "{label}: block {block} bits"); +} + +#[cfg(feature = "test-support")] +/// All three sources' seek reports, equal field by field on both hosts. +fn same_session_reports( + browser: &mut Rig, + c_abi: &CAbi, + label: &str, +) -> [(u64, u64, u64, u64, u64); 3] { + ["src-t1", "src-t2", "src-t3"].map(|name| same_seek_report(browser, c_abi, name, label)) +} + +/// #1643 gate 6: a plain session seek and a late anchored session seek, on both hosts at 48 and +/// 96 kHz, over sources of different lengths (`src-t2` is shorter than the other two and clamps +/// to its end). At the call, after every block through the applying one and after, the browser's +/// and the C ABI's seek reports of every source are equal field by field, the output bits are +/// equal, and the browser's timeline (#1641's host-core test reader) reads the frame the long +/// sources' reports name. The C ABI's timeline is not readable from outside the `capi` crate; its +/// own gate (`capi` `runtime::tests::session_seek`) pins it to the same frames, so the row ties +/// the two through the reports both hosts publish. +/// +/// Test value: red if either host splits the session seek across blocks (a source reporting the +/// new generation a block before or after another, or before the timeline), clamps the short +/// source differently, or drops a late anchored seek's lateness (the report's `first_sample` is +/// the block that played, after the anchor). The rows are not vacuous: the reports name the new +/// generation, the short source reports its own end, and the late seek lands after its anchor. +#[cfg(feature = "test-support")] +#[test] +fn the_session_seek_is_alike_on_both_hosts_for_plain_and_late_seeks() { + let quantum = u64::from(QUANTUM); + for rate in [48_000, 96_000] { + let mut model = model_at(rate); + for source in &mut model.sources { + source.frames = if source.id.as_str() == "src-t2" { + 24 * quantum + } else { + 64 * quantum + }; + } + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + let label = format!("{rate} Hz"); + for block in 0..4 { + session_block( + &mut browser, + &mut c_abi, + block, + [Some((1, block)); 3], + &label, + ); + } + let boot = same_session_reports(&mut browser, &c_abi, &label); + assert!(boot.iter().all(|report| report.0 == 1), "{label}: boot"); + + // 1. A plain session seek to 40 quanta: the short source clamps to its end (24 quanta). + let call_block = 4_u64; + let frame = 40 * quantum; + assert_eq!( + browser.host.session_seek(None, 2, frame, None), + host_web::RESULT_OK + ); + c_abi.session_seek(2, frame, None); + let at_call = same_session_reports(&mut browser, &c_abi, &format!("{label} plain: call")); + assert!( + at_call.iter().all(|report| report.0 == 1), + "{label} plain: the call alone reports generation 1" + ); + for step in 0..4 { + let block = call_block + step; + session_block( + &mut browser, + &mut c_abi, + block, + [Some((2, 40 + step)), None, Some((2, 40 + step))], + &format!("{label} plain"), + ); + let reports = same_session_reports( + &mut browser, + &c_abi, + &format!("{label} plain: after block {block}"), + ); + assert!( + reports.iter().all(|report| report.0 == 2), + "{label} plain: generation 2 in effect for every source" + ); + assert_eq!( + browser.host.timeline_at_for_test(block * quantum), + Some((frame + step * quantum) as i64), + "{label} plain: the timeline in block {block}" + ); + } + let plain = same_session_reports(&mut browser, &c_abi, &format!("{label} plain: end")); + for (index, expected_frame) in [frame, 24 * quantum, frame].into_iter().enumerate() { + assert_eq!( + (plain[index].0, plain[index].1), + (2, call_block * quantum), + "{label} plain: source {index} landed in the first block after the call" + ); + assert_eq!( + plain[index].2, expected_frame, + "{label} plain: source {index}" + ); + } + + // 2. A late anchored session seek: its anchor (block 3) is behind the render clock. + let late_call = call_block + 4; + let late_frame = 30 * quantum; + let anchor = 3 * quantum; + assert_eq!( + browser.host.session_seek(None, 3, late_frame, Some(anchor)), + host_web::RESULT_OK + ); + c_abi.session_seek(3, late_frame, Some(anchor)); + same_session_reports(&mut browser, &c_abi, &format!("{label} late: call")); + let mut first_late = None; + for step in 0..4 { + let block = late_call + step; + session_block( + &mut browser, + &mut c_abi, + block, + [Some((3, 30 + step)), None, Some((3, 30 + step))], + &format!("{label} late"), + ); + let reports = same_session_reports( + &mut browser, + &c_abi, + &format!("{label} late: after block {block}"), + ); + let timeline = browser.host.timeline_at_for_test(block * quantum); + let landed = *first_late.get_or_insert((reports[0].1, reports[0].2, timeline)); + assert_eq!( + timeline, + Some(landed.2.expect("a timeline") + (step * quantum) as i64), + "{label} late: the timeline runs on from where it landed" + ); + } + let late = same_session_reports(&mut browser, &c_abi, &format!("{label} late: end")); + assert!( + late.iter().all(|report| report.0 == 3), + "{label} late: generation 3 in effect" + ); + let (first_sample, source_frame, timeline) = first_late.expect("a late block"); + assert!( + first_sample > anchor, + "{label} late: it played after its anchor ({first_sample} vs {anchor})" + ); + assert_eq!( + timeline, + Some(source_frame as i64), + "{label} late: the timeline and the long source land on the same frame" + ); + assert_ne!( + late[1].2, late[0].2, + "{label} late: the short source clamped, so it lands on another frame" + ); + } +} + +/// Root ruling B16-R9 on both hosts, over #1285's floors (the true-peak limiter insert removed, so +/// every node keeps its latency): an anchored session seek declares nothing, and the floors stay; +/// a refused plain session seek declares nothing, and the floors stay; an accepted plain session +/// seek declares the discontinuity in the same call, so the floors reset, and its seek applies at +/// the next block. At each step both hosts render the same bits and report the same latency and +/// the same seek reports. +/// +/// Test value: red if either host's plain seek does not declare (its latency stays floored), if +/// either declares on the anchored form (the latency drops at the anchored call) or before its +/// check (the refused seek resets the floors), or if the two hosts reach different rules (their +/// bits or latencies differ). +#[cfg(feature = "test-support")] +#[test] +fn a_plain_session_seek_declares_and_an_anchored_one_does_not_alike_on_both_hosts() { + let quantum = u64::from(QUANTUM); + for rate in [48_000, 96_000] { + let label = format!("{rate} Hz"); + let model = model_at(rate); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + same_blocks(&mut browser, &mut c_abi, 0..4, &format!("{label} before")); + let edits = [SessionEdit::RemoveTrackEffect { + track_id: id(LIMITER.track.id), + rack_name: session::RackName::Inserts, + effect_id: id(LIMITER.id), + }]; + let (b_verdict, _) = browser.transaction_reply(&edits); + let (c_verdict, _) = c_abi.transaction(&edits); + c_abi.drain_reliable(); + assert_eq!(b_verdict, c_verdict, "{label}"); + same_blocks(&mut browser, &mut c_abi, 4..8, &format!("{label} floored")); + let floored = c_latency(&c_abi); + assert_eq!(browser_plan_latency(&mut browser).0, floored, "{label}"); + let latencies = |browser: &mut Rig, c_abi: &CAbi, step: &str| { + let latency = c_latency(c_abi); + assert_eq!( + browser_plan_latency(browser).0, + latency, + "{label} {step}: both hosts report one latency" + ); + latency + }; + + // 3. An anchored seek at block 10: nothing is declared, the floors stay. + let anchor = 10 * quantum; + assert_eq!( + browser.host.session_seek(None, 2, 0, Some(anchor)), + host_web::RESULT_OK + ); + c_abi.session_seek(2, 0, Some(anchor)); + for block in 8..12 { + let feed = (block >= 10).then(|| (2, block - 10)); + session_block(&mut browser, &mut c_abi, block, [feed; 3], &label); + } + let reports = same_session_reports(&mut browser, &c_abi, &format!("{label} anchored")); + assert!( + reports + .iter() + .all(|report| (report.0, report.1) == (2, anchor)) + ); + assert_eq!( + latencies(&mut browser, &c_abi, "anchored"), + floored, + "{label}: an anchored session seek keeps the floors" + ); + + // 1. A refused plain seek (generation 2 is stale): nothing is declared. + assert_eq!( + browser.host.session_seek(None, 2, 0, None), + host_web::RESULT_INVALID_ARGUMENT + ); + assert_eq!( + c_abi.session_seek_code(2, 0, None), + capi::RESULT_INVALID_ARGUMENT + ); + for block in 12..14 { + session_block( + &mut browser, + &mut c_abi, + block, + [Some((2, block - 10)); 3], + &label, + ); + } + assert_eq!( + latencies(&mut browser, &c_abi, "refused"), + floored, + "{label}: a refused plain session seek keeps the floors" + ); + + // 2. An accepted plain seek: it declares, the floors reset and the seek applies at the + // next block. + let call = 14_u64; + assert_eq!( + browser.host.session_seek(None, 3, 0, None), + host_web::RESULT_OK + ); + c_abi.session_seek(3, 0, None); + for block in call..call + 4 { + session_block( + &mut browser, + &mut c_abi, + block, + [Some((3, block - call)); 3], + &label, + ); + } + let reports = same_session_reports(&mut browser, &c_abi, &format!("{label} plain")); + assert!( + reports + .iter() + .all(|report| (report.0, report.1, report.2) == (3, call * quantum, 0)), + "{label}: the plain seek applied at the next block: {reports:?}" + ); + assert!( + latencies(&mut browser, &c_abi, "plain") < floored, + "{label}: a plain session seek resets the floors ({floored})" + ); + } +} diff --git a/hosts/host-web/tests/fixtures/structural-frames.json b/hosts/host-web/tests/fixtures/structural-frames.json index ae2149367..88ccc3929 100644 --- a/hosts/host-web/tests/fixtures/structural-frames.json +++ b/hosts/host-web/tests/fixtures/structural-frames.json @@ -7,6 +7,7 @@ { "name": "swap_comp_for_soft_clip", "hex": "4d49534f43544c000100000030000100030000000005000001000000000000000100000000000000010000000000000001000b01f804000002000000000000000100020102000000000200000000000002000b01d8040000010000000000000001000b01c80400000a000000000000000100090102000000743100000000000002000901060000007372632d74310000030001010100000000000000000000000400010101000000010000000000000005000b01c8000000020000000000000001000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000002000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000007000b01a0020000020000000000000001000b01e80100000a000000000000000100090102000000657100000000000002000b013800000002000000000000000100010101000000010000000000000002000901120000006d69736f2e706172616d65747269632d657100000000000003000101010000000200000000000000040008010100000000000000000000000500010101000000010000000000000006000b0148000000040000000000000001000301040000000100000000000000020001010100000003000000000000000300010101000000050000000000000004000601040000000000803f0000000006000b014800000004000000000000000100030104000000020000000000000002000101010000000300000000000000030001010100000005000000000000000400060104000000000000400000000006000b01480000000400000000000000010003010400000003000000000000000200010101000000030000000000000003000101010000000200000000000000040006010400000000007a440000000006000b014800000004000000000000000100030104000000040000000000000002000101010000000300000000000000030001010100000001000000000000000400060104000000000000000000000007000b011800000001000000000000000100010101000000010000000000000001000b01a000000006000000000000000100090104000000636f6d700000000002000b0130000000020000000000000001000101010000000100000000000000020009010e0000006d69736f2e736f66742d636c6970000003000101010000000200000000000000040008010100000000000000000000000500010101000000010000000000000007000b011800000001000000000000000100010101000000010000000000000009000b014800000004000000000000000100060104000000000000c0000000000200060104000000000000c00000000003000801010000000000000000000000040008010100000000000000000000000a000b01480000000400000000000000010001010100000001000000000000000200060104000000cdcc4cbf0000000003000601040000009a99193f000000000400030104000000c8000000000000000b000b0128000000020000000000000001000901080000007072652d636f6d70020008010100000000000000000000000b000b013000000002000000000000000100090109000000706f73742d636c69700000000000000002000801010000000000000000000000" }, { "name": "remove_comp", "hex": "4d49534f43544c000100000030000100030000005804000001000000000000000100000000000000010000000000000001000b015004000002000000000000000100020102000000000200000000000002000b0130040000010000000000000001000b01200400000a000000000000000100090102000000743100000000000002000901060000007372632d74310000030001010100000000000000000000000400010101000000010000000000000005000b01c8000000020000000000000001000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000002000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000007000b01f8010000010000000000000001000b01e80100000a000000000000000100090102000000657100000000000002000b013800000002000000000000000100010101000000010000000000000002000901120000006d69736f2e706172616d65747269632d657100000000000003000101010000000200000000000000040008010100000000000000000000000500010101000000010000000000000006000b0148000000040000000000000001000301040000000100000000000000020001010100000003000000000000000300010101000000050000000000000004000601040000000000803f0000000006000b014800000004000000000000000100030104000000020000000000000002000101010000000300000000000000030001010100000005000000000000000400060104000000000000400000000006000b01480000000400000000000000010003010400000003000000000000000200010101000000030000000000000003000101010000000200000000000000040006010400000000007a440000000006000b014800000004000000000000000100030104000000040000000000000002000101010000000300000000000000030001010100000001000000000000000400060104000000000000000000000007000b011800000001000000000000000100010101000000010000000000000009000b014800000004000000000000000100060104000000000000c0000000000200060104000000000000c00000000003000801010000000000000000000000040008010100000000000000000000000a000b01480000000400000000000000010001010100000001000000000000000200060104000000cdcc4cbf0000000003000601040000009a99193f000000000400030104000000c8000000000000000b000b0128000000020000000000000001000901080000007072652d636f6d70020008010100000000000000000000000b000b013000000002000000000000000100090109000000706f73742d636c69700000000000000002000801010000000000000000000000" }, { "name": "remove_eq", "hex": "4d49534f43544c000100000030000100030000001003000001000000000000000100000000000000010000000000000001000b010803000002000000000000000100020102000000000200000000000002000b01e8020000010000000000000001000b01d80200000a000000000000000100090102000000743100000000000002000901060000007372632d74310000030001010100000000000000000000000400010101000000010000000000000005000b01c8000000020000000000000001000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000002000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000007000b01b0000000010000000000000001000b01a000000006000000000000000100090104000000636f6d700000000002000b0130000000020000000000000001000101010000000100000000000000020009010f0000006d69736f2e636f6d70726573736f720003000101010000000200000000000000040008010100000000000000000000000500010101000000010000000000000007000b011800000001000000000000000100010101000000010000000000000009000b014800000004000000000000000100060104000000000000c0000000000200060104000000000000c00000000003000801010000000000000000000000040008010100000000000000000000000a000b01480000000400000000000000010001010100000001000000000000000200060104000000cdcc4cbf0000000003000601040000009a99193f000000000400030104000000c8000000000000000b000b0128000000020000000000000001000901080000007072652d636f6d70020008010100000000000000000000000b000b013000000002000000000000000100090109000000706f73742d636c69700000000000000002000801010000000000000000000000" }, - { "name": "swap_t3_multi_and_limiter", "hex": "4d49534f43544c000100000030000100030000009804000001000000000000000100000000000000010000000000000001000b019004000002000000000000000100020102000000000200000000000002000b0170040000010000000000000001000b01600400000a000000000000000100090102000000743300000000000002000901060000007372632d74330000030001010100000000000000000000000400010101000000010000000000000005000b01c8000000020000000000000001000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000002000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000007000b0118020000030000000000000001000b01a8000000060000000000000001000901070000006c696d697465720002000b013800000002000000000000000100010101000000010000000000000002000901160000006d69736f2e747275652d7065616b2d6c696d69746572000003000101010000000200000000000000040008010100000000000000000000000500010101000000010000000000000007000b011800000001000000000000000100010101000000010000000000000001000b01b0000000060000000000000001000901050000006d756c746900000002000b014000000002000000000000000100010101000000010000000000000002000901190000006d69736f2e6d756c746962616e642d636f6d70726573736f720000000000000003000101010000000200000000000000040008010100000000000000000000000500010101000000010000000000000007000b011800000001000000000000000100010101000000010000000000000001000b01a00000000600000000000000010009010500000064656c617900000002000b0130000000020000000000000001000101010000000100000000000000020009010a0000006d69736f2e64656c617900000000000003000101010000000200000000000000040008010100000000000000000000000500010101000000010000000000000007000b011800000001000000000000000100010101000000010000000000000009000b014800000004000000000000000100060104000000000080c0000000000200060104000000000080c00000000003000801010000000000000000000000040008010100000000000000000000000a000b016800000006000000000000000100010101000000020000000000000002000601040000006666663f000000000300060104000000cdcccc3d000000000400060104000000cdcc4c3e000000000500060104000000cdcc4c3f00000000060003010400000000000000000000000b000b0128000000020000000000000001000901080000007072652d636f6d70020008010100000000000000000000000b000b013000000002000000000000000100090109000000706f73742d636c69700000000000000002000801010000000000000000000000" } + { "name": "swap_t3_multi_and_limiter", "hex": "4d49534f43544c000100000030000100030000009804000001000000000000000100000000000000010000000000000001000b019004000002000000000000000100020102000000000200000000000002000b0170040000010000000000000001000b01600400000a000000000000000100090102000000743300000000000002000901060000007372632d74330000030001010100000000000000000000000400010101000000010000000000000005000b01c8000000020000000000000001000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000002000b01580000000500000000000000010008010100000000000000000000000200060104000000000000000000000003000601040000000000000000000000040006010400000000000000000000000500030104000000000000000000000007000b0118020000030000000000000001000b01a8000000060000000000000001000901070000006c696d697465720002000b013800000002000000000000000100010101000000010000000000000002000901160000006d69736f2e747275652d7065616b2d6c696d69746572000003000101010000000200000000000000040008010100000000000000000000000500010101000000010000000000000007000b011800000001000000000000000100010101000000010000000000000001000b01b0000000060000000000000001000901050000006d756c746900000002000b014000000002000000000000000100010101000000010000000000000002000901190000006d69736f2e6d756c746962616e642d636f6d70726573736f720000000000000003000101010000000200000000000000040008010100000000000000000000000500010101000000010000000000000007000b011800000001000000000000000100010101000000010000000000000001000b01a00000000600000000000000010009010500000064656c617900000002000b0130000000020000000000000001000101010000000100000000000000020009010a0000006d69736f2e64656c617900000000000003000101010000000200000000000000040008010100000000000000000000000500010101000000010000000000000007000b011800000001000000000000000100010101000000010000000000000009000b014800000004000000000000000100060104000000000080c0000000000200060104000000000080c00000000003000801010000000000000000000000040008010100000000000000000000000a000b016800000006000000000000000100010101000000020000000000000002000601040000006666663f000000000300060104000000cdcccc3d000000000400060104000000cdcc4c3e000000000500060104000000cdcc4c3f00000000060003010400000000000000000000000b000b0128000000020000000000000001000901080000007072652d636f6d70020008010100000000000000000000000b000b013000000002000000000000000100090109000000706f73742d636c69700000000000000002000801010000000000000000000000" }, + { "name": "remove_t3_limiter", "hex": "4d49534f43544c000100000030000100030000006000000001000000000000000100000000000000010000000000000001000b015800000002000000000000000100020102000000060200000000000002000b01380000000300000000000000010009010200000074330000000000000200010101000000020000000000000003000901070000006c696d6974657200" } ] } diff --git a/hosts/host-web/tests/structural_frames_fixture.rs b/hosts/host-web/tests/structural_frames_fixture.rs index 1a8cf6d81..a98d5449a 100644 --- a/hosts/host-web/tests/structural_frames_fixture.rs +++ b/hosts/host-web/tests/structural_frames_fixture.rs @@ -195,6 +195,19 @@ fn frames() -> Vec<(&'static str, Vec)> { "swap_t3_multi_and_limiter", frame(&[t3_with_multi_and_limiter_swapped(&model)], revision), ), + // t3's true-peak limiter removed: the natural latency falls, and #1285 keeps the old one + // as floors, so a declared discontinuity has a successor to publish (B16-R9's SDK eval). + ( + "remove_t3_limiter", + frame( + &[SessionEdit::RemoveTrackEffect { + track_id: id("t3"), + rack_name: session::RackName::Inserts, + effect_id: id("limiter"), + }], + revision, + ), + ), ] } diff --git a/scripts/check-abi-layout-v1.py b/scripts/check-abi-layout-v1.py index dffab2fc8..51c65b871 100644 --- a/scripts/check-abi-layout-v1.py +++ b/scripts/check-abi-layout-v1.py @@ -168,6 +168,8 @@ "miso_engine_web_v1_response_frame_reply_ptr", "miso_engine_web_v1_response_frame_submit", "miso_engine_web_v1_service", + "miso_engine_web_v1_session_seek", + "miso_engine_web_v1_session_seek_at", "miso_engine_web_v1_source_channels", "miso_engine_web_v1_source_count", "miso_engine_web_v1_source_frames", @@ -744,6 +746,10 @@ def boolean_named_value(document: dict) -> None: ("the seek report record swaps two field names", swap_seek_report_names), ("the anchored seek export is dropped", drop_seek_export("miso_engine_web_v1_source_seek_at")), + ("the session seek export is dropped", + drop_seek_export("miso_engine_web_v1_session_seek")), + ("the anchored session seek export is dropped", + drop_seek_export("miso_engine_web_v1_session_seek_at")), ("the status omits a watermark word", drop_status_watermark_word), ("the spectrum-hop capability export is dropped", drop_spectrum_hop_capability_export), ("the export set is unsorted", unsorted_exports), diff --git a/scripts/check-capi-abi.sh b/scripts/check-capi-abi.sh index 32d420164..5dd6944a4 100755 --- a/scripts/check-capi-abi.sh +++ b/scripts/check-capi-abi.sh @@ -260,6 +260,8 @@ printf '%s\n' \ miso_engine_v1_render_f32_planar \ miso_engine_v1_service \ miso_engine_v1_session_destroy \ + miso_engine_v1_session_seek \ + miso_engine_v1_session_seek_at \ miso_engine_v1_source_seek \ miso_engine_v1_source_seek_at \ miso_engine_v1_source_seek_report \ diff --git a/scripts/check-web-audioworklet.sh b/scripts/check-web-audioworklet.sh index 5fa795e2e..6a17ea0a6 100755 --- a/scripts/check-web-audioworklet.sh +++ b/scripts/check-web-audioworklet.sh @@ -323,6 +323,8 @@ expected_exports=$(printf '%s\n' \ miso_engine_web_v1_response_frame_submit \ miso_engine_web_v1_resource_ptr \ miso_engine_web_v1_service \ + miso_engine_web_v1_session_seek \ + miso_engine_web_v1_session_seek_at \ miso_engine_web_v1_source_channels \ miso_engine_web_v1_source_count \ miso_engine_web_v1_source_frames \ diff --git a/scripts/fixtures/abi-layout-v1-self-test.json b/scripts/fixtures/abi-layout-v1-self-test.json index 89596f547..26e92bf3a 100644 --- a/scripts/fixtures/abi-layout-v1-self-test.json +++ b/scripts/fixtures/abi-layout-v1-self-test.json @@ -54,6 +54,8 @@ "miso_engine_web_v1_response_frame_reply_ptr", "miso_engine_web_v1_response_frame_submit", "miso_engine_web_v1_service", + "miso_engine_web_v1_session_seek", + "miso_engine_web_v1_session_seek_at", "miso_engine_web_v1_source_channels", "miso_engine_web_v1_source_count", "miso_engine_web_v1_source_frames", diff --git a/scripts/test-test-support-ci.py b/scripts/test-test-support-ci.py index ad425494e..626b2727a 100644 --- a/scripts/test-test-support-ci.py +++ b/scripts/test-test-support-ci.py @@ -20,7 +20,7 @@ DEBUG_A_FEATURES = ( "--features builtins-compiler/test-support,graph/test-support," "host-web/test-support,host-core/test-support,effect-compiler/test-support," - "protocol/test-support,control-plane/test-support,engine/realtime-audit\n" + "protocol/test-support,control-plane/test-support,engine/test-support,engine/realtime-audit\n" ) DEBUG_B_FEATURES = ( "--features math/lane,parametric-eq/test-support,builtins/test-support,lane/test-support\n" @@ -157,8 +157,8 @@ def main() -> int: fails(f"{package}/test-support removed from test-debug-b", {package}, feature_b(f"{package}/test-support")) fails("every test-support feature removed from test-debug-a", - {"builtins-compiler", "control-plane", "effect-compiler", "effect-contract", "graph", - "host-core", "host-web", "protocol", "rack"}, + {"builtins-compiler", "control-plane", "effect-compiler", "effect-contract", "engine", + "graph", "host-core", "host-web", "protocol", "rack"}, in_a(DEBUG_A_FEATURES, "--features engine/realtime-audit\n")) fails("every test-support feature removed from test-debug-b", {"builtins", "lane", "parametric-eq"}, @@ -166,10 +166,12 @@ def main() -> int: # The doctest steps (#1422) run no `--all-targets`, so only the doctest-parity rule sees them: # a feature or package set that drifts from the sibling test step is refused. - refused("control-plane/test-support removed from the test-debug-a doctest step only", + # protocol/test-support: no other feature forwards it (host-web's test-support forwards + # control-plane's since #1643), so dropping it from the doctest step alone is a real drift. + refused("protocol/test-support removed from the test-debug-a doctest step only", DOCTEST_REFUSAL, in_a_doc(DEBUG_A_FEATURES, - DEBUG_A_FEATURES.replace("control-plane/test-support,", "", 1))) + DEBUG_A_FEATURES.replace("protocol/test-support,", "", 1))) refused("builtins/test-support removed from the test-debug-b doctest step only", DOCTEST_REFUSAL, in_b_doc(DEBUG_B_FEATURES, DEBUG_B_FEATURES.replace(",builtins/test-support", "", 1))) @@ -187,8 +189,8 @@ def main() -> int: redundant[2].replace("effect-compiler/test-support,", "", 1)) host_web_forward = ( "hosts/host-web/Cargo.toml", - 'test-support = ["builtins-compiler/test-support", "host-core/test-support"]', - 'test-support = ["builtins-compiler/test-support"]') + ' "engine/test-support",\n "host-core/test-support",\n]', + ' "engine/test-support",\n]') control_plane_forward = ( "crates/control-plane/Cargo.toml", 'test-support = ["host-core/test-support"]', diff --git a/sdk/assets/miso-engine-v1-abi-layout.json b/sdk/assets/miso-engine-v1-abi-layout.json index 89596f547..26e92bf3a 100644 --- a/sdk/assets/miso-engine-v1-abi-layout.json +++ b/sdk/assets/miso-engine-v1-abi-layout.json @@ -54,6 +54,8 @@ "miso_engine_web_v1_response_frame_reply_ptr", "miso_engine_web_v1_response_frame_submit", "miso_engine_web_v1_service", + "miso_engine_web_v1_session_seek", + "miso_engine_web_v1_session_seek_at", "miso_engine_web_v1_source_channels", "miso_engine_web_v1_source_count", "miso_engine_web_v1_source_frames", diff --git a/sdk/src/core/boundary.ts b/sdk/src/core/boundary.ts index 842020caf..ea551a7cd 100644 --- a/sdk/src/core/boundary.ts +++ b/sdk/src/core/boundary.ts @@ -1167,6 +1167,51 @@ export class WasmBoundary { }); } + /** + * Declare a discontinuity: the next block need not continue the previous output, so a latency + * floor a structural edit raised is reset as it is on the C ABI. Control work: call it from the + * message handler, never from `process()`. + */ + declareDiscontinuity(): EngineCallResult { + const result = Number(this.#exports.miso_engine_web_v1_declare_discontinuity(this.#live())); + return Object.freeze({ + ok: result === constantValue("resultCodes", "ok"), + result, + code: resultName(result, "call"), + }); + } + + /** + * Seek the timeline and every source to `frame` under one new `generation`, all or nothing. + * A plain session seek is a declared discontinuity: the engine declares it in the same call, and + * a refused seek declares nothing, so this makes no separate declaration. The seek does not + * prepare the consumers: each applies it at the next block, and the caller then submits every + * source's PCM from its clamped frame under the same generation. Control work, never + * `process()`. + */ + sessionSeek(request: { + readonly generation: bigint; + readonly frame: bigint; + }): EngineCallResult { + const handle = this.#live(); + if (typeof request.generation !== "bigint" || request.generation <= 0n) { + throw new MisoUsageError("session seek generation must be a positive bigint"); + } + if (typeof request.frame !== "bigint" || request.frame < 0n) { + throw new MisoUsageError("session seek frame must be a non-negative bigint"); + } + const result = Number(this.#exports.miso_engine_web_v1_session_seek( + handle, + request.generation, + request.frame, + )); + return Object.freeze({ + ok: result === constantValue("resultCodes", "ok"), + result, + code: resultName(result, "call"), + }); + } + /** Take or release the decimated meter lease. */ meterLease(enabled: boolean): EngineCallResult { if (typeof enabled !== "boolean") { diff --git a/sdk/src/generated/abi.ts b/sdk/src/generated/abi.ts index 0e5d432b9..c9de900cd 100644 --- a/sdk/src/generated/abi.ts +++ b/sdk/src/generated/abi.ts @@ -81,6 +81,8 @@ export const ABI_LAYOUT = deepFreeze( "miso_engine_web_v1_response_frame_reply_ptr", "miso_engine_web_v1_response_frame_submit", "miso_engine_web_v1_service", + "miso_engine_web_v1_session_seek", + "miso_engine_web_v1_session_seek_at", "miso_engine_web_v1_source_channels", "miso_engine_web_v1_source_count", "miso_engine_web_v1_source_frames", diff --git a/sdk/src/headless/engine.ts b/sdk/src/headless/engine.ts index 6dd5fc94d..da35ace4a 100644 --- a/sdk/src/headless/engine.ts +++ b/sdk/src/headless/engine.ts @@ -134,6 +134,12 @@ export class OfflineEngine { #controlQueue: ControlTransactionQueue | undefined; readonly #responseLimits: TrackResponseSubscriptionLimits | undefined; readonly #spectrumLimits: SpectrumSubscriptionLimits | undefined; + /** + * The largest generation a successful `seek`, `seekSource` or `submitSource` of this object has + * passed to the engine. Every source and the timeline start at generation 1. It is the only + * state `seek` adds: the core refuses a stale generation, so this only picks the next one. + */ + #highestGeneration = 1n; private constructor( asset: MisoEngineAsset, @@ -423,7 +429,9 @@ export class OfflineEngine { readonly planes: readonly Float32Array[]; readonly endOfRegion: boolean; }): EngineCallResult { - return this.#boundary.submitSource(request); + const result = this.#boundary.submitSource(request); + if (result.ok) this.#noteGeneration(request.generation); + return result; } seekSource(request: { @@ -431,7 +439,33 @@ export class OfflineEngine { readonly generation: bigint; readonly sourceFrame: bigint; }): EngineCallResult { - return this.#boundary.seekSource(request); + const result = this.#boundary.seekSource(request); + if (result.ok) this.#noteGeneration(request.generation); + return result; + } + + /** + * Seek the timeline and every source to `frame` under one new generation, all or nothing, and + * resolve with the generation used. Every consumer applies the seek at the next block; the + * caller then submits each source's PCM from `min(frame, source frames)` under that generation. + * Until it does, a source underruns. The generation is `options.generation`, else one above the + * largest this object has passed through `seek`, `seekSource` or `submitSource`. A stale + * generation is refused (`source.generation.stale`, result `invalidArgument`) and nothing + * changes. The engine declares the discontinuity in the same call (the floors a rebuild kept + * reset), and a refused seek declares nothing, as on the C ABI. + */ + seek( + frame: bigint, + options?: { readonly generation?: bigint }, + ): { readonly generation: bigint } & EngineCallResult { + const generation = options?.generation ?? this.#highestGeneration + 1n; + const result = this.#boundary.sessionSeek({ generation, frame }); + if (result.ok) this.#noteGeneration(generation); + return Object.freeze({ generation, ...result }); + } + + #noteGeneration(generation: bigint): void { + if (generation > this.#highestGeneration) this.#highestGeneration = generation; } /** Take or release the decimated meter lease. */ diff --git a/sdk/test/capability-evals.mjs b/sdk/test/capability-evals.mjs index 1115f6e63..bf0e75078 100644 --- a/sdk/test/capability-evals.mjs +++ b/sdk/test/capability-evals.mjs @@ -1,6 +1,7 @@ /** Issue #321: direct-Wasm source seek, status/session map, and meter capability parity. */ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { before, describe, test } from "node:test"; import { MisoEngineAsset } from "../src/core/asset.ts"; @@ -8,8 +9,16 @@ import { MisoControlRefusal, MisoUsageError } from "../src/core/errors.ts"; import { LiveControlWriter } from "../src/core/writer.ts"; import { CATALOG } from "../src/generated/catalog.ts"; import { createOfflineEngine } from "../src/headless/engine.ts"; +import { decodeControlReply } from "../src/core/control-frame.ts"; +import { structuralFrame } from "./control-support.mjs"; import { effectEntry, moduleBytes, ramp, sessionDocument } from "./support.mjs"; +/** The parity session: three tracks, t3 behind a multiband compressor, a true-peak limiter and a delay. */ +const PARITY = JSON.parse(readFileSync( + new URL("../../hosts/host-web/tests/fixtures/control-list.json", import.meta.url), + "utf8", +)); + let asset; before(async () => { @@ -217,6 +226,156 @@ describe("issue 321 -- complete headless ABI capability parity", () => { } }); + test("session seek calls the session seek export alone and moves the sources in the next block", async () => { + // Issue #1643 gate 2. Red if `seek` does not call the session seek export (the generation-2 + // PCM is then refused, the twin comparison below fails), if it declares the discontinuity + // itself before the seek (root ruling B16-R9 retired B15-R7's order: the plain session seek + // declares in the same call, so a separate declaration first would reset the floors even + // when the seek is refused), or if it picks a stale generation. + const calls = []; + const instantiate = asset.instantiate.bind(asset); + asset.instantiate = async () => { + const instance = await instantiate(); + const exports = {}; + for (const [name, value] of Object.entries(instance.exports)) { + exports[name] = typeof value === "function" && /declare_discontinuity$|session_seek$/.test(name) + ? (...args) => { calls.push(name.replace("miso_engine_web_v1_", "")); return value(...args); } + : value; + } + return { exports }; + }; + let engine; + let twin; + try { + engine = await createOfflineEngine(sessionDocument(), { asset }); + twin = await createOfflineEngine(sessionDocument(), { asset }); + } finally { + delete asset.instantiate; + } + try { + assert.throws( + () => engine.seek(-1n), + (error) => error instanceof MisoUsageError && /non-negative bigint/.test(error.message), + ); + assert.throws( + () => engine.seek(0n, { generation: 0n }), + (error) => error instanceof MisoUsageError && /positive bigint/.test(error.message), + ); + assert.deepEqual(calls, [], "a usage error reaches no export"); + + const seek = engine.seek(4_096n); + assert.deepEqual(seek, { generation: 2n, ok: true, result: 0, code: "ok" }); + assert.deepEqual(calls, ["session_seek"], "no separate declaration before the seek"); + feed(engine, 2n, 4_096n, 17); + const block = engine.render(); + + // The per-source seek reads the same frame in its first block, so the two outputs match. + assert.deepEqual(twin.seekSource({ sourceId: "s", generation: 2n, sourceFrame: 4_096n }), + { ok: true, result: 0, code: "ok" }); + feed(twin, 2n, 4_096n, 17); + const expected = twin.render(); + assert.ok(block.left.some((sample) => sample !== 0), "the sought frame renders audio"); + assert.deepEqual(block.left, expected.left); + assert.deepEqual(block.right, expected.right); + + // The default generation is one above the largest passed, and a stale one changes nothing. + // One block between seeks drains each consumer's command slot. + const settle = (generation, frame) => { + feed(engine, generation, frame); + engine.render(); + }; + assert.equal(engine.seek(0n).generation, 3n); + settle(3n, 0n); + const stale = engine.seek(0n, { generation: 3n }); + assert.equal(stale.generation, 3n); + assert.equal(stale.ok, false); + assert.equal(stale.code, "invalidArgument"); + assert.equal(engine.seek(0n, { generation: 9n }).ok, true); + settle(9n, 0n); + assert.equal(engine.seek(0n).generation, 10n, "an explicit generation moves the counter"); + settle(10n, 0n); + assert.equal(engine.seekSource({ sourceId: "s", generation: 12n, sourceFrame: 0n }).ok, true); + settle(12n, 0n); + assert.equal(engine.seek(0n).generation, 13n, "seekSource moves the counter"); + settle(13n, 0n); + assert.equal(engine.seek(0n).generation, 14n); + } finally { + engine?.dispose(); + twin?.dispose(); + } + }); + + test("a plain session seek declares the discontinuity itself, and a refused one declares nothing", async () => { + // Root ruling B16-R9 gate 4, on the headless path (which has no anchored session seek). The + // parity session holds a true-peak limiter on t3; removing it rebuilds the plan, and #1285 + // keeps the old latency as floors. Red if the core's plain seek does not + // declare (the latency stays floored after an accepted seek), if it declares before its check + // (the refused seek resets the floors), or if the SDK declares separately (the export calls). + const calls = []; + let exports; + let handle; + const instantiate = asset.instantiate.bind(asset); + asset.instantiate = async () => { + const instance = await instantiate(); + exports = {}; + for (const [name, value] of Object.entries(instance.exports)) { + exports[name] = typeof value === "function" && /declare_discontinuity$|session_seek$|_render$/.test(name) + ? (...args) => { + handle = args[0]; + if (!name.endsWith("_render")) calls.push(name.replace("miso_engine_web_v1_", "")); + return value(...args); + } + : value; + } + return { exports }; + }; + let engine; + try { + engine = await createOfflineEngine(PARITY.session, { + asset, + liveControls: { meterBlocks: 1, observationTaps: 8 }, + }); + } finally { + delete asset.instantiate; + } + const quantum = BigInt(engine.shape().quantumFrames); + const latency = () => { + assert.equal(Number(exports.miso_engine_web_v1_plan_latency_read(handle)), 0); + const record = Number(exports.miso_engine_web_v1_plan_latency_ptr(handle)); + return new DataView(exports.memory.buffer).getBigUint64(record + 8, true); + }; + const play = (generation, from, blocks, start) => { + for (let block = 0n; block < blocks; block += 1n) { + feed(engine, generation, start + block * quantum, 3); + engine.render(); + } + return from + blocks; + }; + try { + let block = play(1n, 0n, 4n, 0n); + const boot = latency(); + assert.ok(boot > 0n, "the limiter delays the plan"); + const reply = decodeControlReply(engine.controlFrame(structuralFrame("remove_t3_limiter", engine))); + assert.equal(reply.status, "ok"); + assert.equal(reply.applied.editPath, "rebuild", "removing the limiter rebuilds the plan"); + block = play(1n, block, 2n, block * quantum); + assert.equal(latency(), boot, "the floors keep the old latency (#1285)"); + + const refused = engine.seek(0n, { generation: 1n }); + assert.equal(refused.code, "invalidArgument", "generation 1 is stale"); + block = play(1n, block, 2n, block * quantum); + assert.equal(latency(), boot, "a refused plain seek declares nothing"); + + const accepted = engine.seek(0n, { generation: 2n }); + assert.equal(accepted.ok, true, accepted.code); + play(2n, block, 2n, 0n); + assert.ok(latency() < boot, "an accepted plain seek declares: the floors reset"); + assert.deepEqual(calls, ["session_seek", "session_seek"], "the SDK makes no separate declaration"); + } finally { + engine.dispose(); + } + }); + test("meter lease and poll return one copied, ABI-shaped sample window", async () => { const meterBlocks = 2; const engine = await createOfflineEngine(compressorDocument(), { diff --git a/sdk/test/control-support.mjs b/sdk/test/control-support.mjs index 7e9ceb207..7bb411897 100644 --- a/sdk/test/control-support.mjs +++ b/sdk/test/control-support.mjs @@ -151,7 +151,7 @@ const STRUCTURAL_FIXTURE = JSON.parse(readFileSync( /** * The structural command frames the Rust codec wrote beside the parity session * (`hosts/host-web/tests/structural_frames_fixture.rs`): `replace_last_strip`, `add_first_strip`, - * `swap_comp_for_soft_clip`, `remove_comp`, `remove_eq` and `swap_t3_multi_and_limiter`, each applied to the booted session. The returned + * `swap_comp_for_soft_clip`, `remove_comp`, `remove_eq`, `swap_t3_multi_and_limiter` and `remove_t3_limiter`, each applied to the booted session. The returned * function stamps the frame with the endpoint's next request ID and the committed revision, which * are the only header words that depend on the engine. */ diff --git a/tools/audit/src/capi.rs b/tools/audit/src/capi.rs index d35dfeede..d9d243911 100644 --- a/tools/audit/src/capi.rs +++ b/tools/audit/src/capi.rs @@ -17,6 +17,12 @@ //! The swap block's carried consumer pops the seek and holds it for the two calls before the //! anchor, and the call at the anchor applies it and plays the generation-2 quantum, so the //! held-seek paths of the render run inside the audited scope. +//! +//! After the call at the anchor (#1642 D6), the audit session-seeks the timeline and the source +//! to generation 3 at [`SESSION_SEEK_SAMPLE`], which declares the discontinuity in the same call +//! (root ruling B16-R9), then submits the generation-3 quantum for that frame, all outside the +//! render scope; the next audited call applies the seek at the timeline and the source in one +//! block. #![allow(unsafe_code)] @@ -29,7 +35,7 @@ use capi::{ RESULT_INTERNAL, RESULT_OK, SOURCE_CHUNK_SIZE, SUBMIT_REPORT_SIZE, Session, SourceChunk, SubmitReport, miso_engine_v1_compile_session, miso_engine_v1_dequeue_event, miso_engine_v1_engine_create, miso_engine_v1_engine_destroy, miso_engine_v1_plan_destroy, - miso_engine_v1_render_f32_planar, miso_engine_v1_session_destroy, + miso_engine_v1_render_f32_planar, miso_engine_v1_session_destroy, miso_engine_v1_session_seek, miso_engine_v1_source_seek_at, miso_engine_v1_source_submit_planar_f32, miso_engine_v1_submit_command, plan_carry_counts, plan_replacement_count, }; @@ -61,6 +67,13 @@ const CALLS_BEFORE_TRANSACTION: u64 = 1; const SEEK_ANCHOR_SAMPLE: u64 = 4 * QUANTUM_FRAMES as u64; /// The generation the anchored seek starts. const SEEK_GENERATION: u64 = 2; +/// The call before which the session seek runs (#1642 D6): right after the anchor call's render +/// has applied the anchored seek, so no seek is pending in any slot. +const SESSION_SEEK_CALL: u64 = SEEK_ANCHOR_SAMPLE / QUANTUM_FRAMES as u64 + 1; +/// The timeline sample, and the source frame, the session seek moves to. +const SESSION_SEEK_SAMPLE: u64 = 8 * QUANTUM_FRAMES as u64; +/// The generation the session seek starts. +const SESSION_SEEK_GENERATION: u64 = 3; /// The nine-track EQ fixture with an enabled compressor insert (`comp`) on `eq2`, so the live /// editor also reaches a non-EQ effect lane. Every track keeps its console EQ slot, whose band 1 @@ -243,6 +256,29 @@ impl AuditHandles { } } +impl AuditHandles { + /// Session-seeks the timeline and the source to generation [`SESSION_SEEK_GENERATION`] at + /// [`SESSION_SEEK_SAMPLE`], and submits that generation's quantum from the frame (#1642 D6), + /// each asserted `RESULT_OK`. The plain session seek declares the discontinuity itself + /// (B16-R9), so the audit makes no separate declaration; the declaration publishes nothing + /// here: no audit plan has a raised floor or a source-read offset. Control work, run outside + /// every render scope. + fn apply_session_seek(&mut self) -> Result<(), u32> { + // SAFETY: The session is live and used by this thread alone. + let seeked = unsafe { + miso_engine_v1_session_seek(self.session, SESSION_SEEK_GENERATION, SESSION_SEEK_SAMPLE) + }; + if seeked != RESULT_OK { + return Err(seeked); + } + match submit_source_chunk(self.session, SESSION_SEEK_GENERATION, SESSION_SEEK_SAMPLE) { + (RESULT_OK, accepted) if accepted == QUANTUM_FRAMES as u64 => Ok(()), + (RESULT_OK, _) => Err(RESULT_INTERNAL), + (refused, _) => Err(refused), + } + } +} + /// The fixture's one source. const SOURCE_ID: &[u8] = b"fixture-source"; @@ -546,6 +582,11 @@ impl PreparedAudit { .apply_structural_transaction(&mut editor) .expect("apply the audit's structural transaction"); } + if call == SESSION_SEEK_CALL { + self.handles + .apply_session_seek() + .expect("apply the audit's session seek"); + } // SAFETY: The plan is live and exclusive, the descriptor points to the same complete // writable output for every synchronous call, and exact time is bounded. let result = audit::in_render_scope(|| unsafe { diff --git a/tools/parameter-metadata/src/abi_layout.rs b/tools/parameter-metadata/src/abi_layout.rs index 4a16741a5..f6d1d44eb 100644 --- a/tools/parameter-metadata/src/abi_layout.rs +++ b/tools/parameter-metadata/src/abi_layout.rs @@ -124,7 +124,7 @@ pub const ERROR_PHASES: [&str; 6] = ["asset", "boot", "source", "render", "outpu /// Publishing the whole surface -- not just the four boot calls -- is what lets a JavaScript /// consumer name an export without typing a string. `memory` is deliberately absent: it is the /// module's linear memory, not a call, and a consumer reaches it as `instance.exports.memory`. -pub const EXPORTS: [&str; 108] = [ +pub const EXPORTS: [&str; 110] = [ "miso_engine_web_v1_abi_version", "miso_engine_web_v1_boot", "miso_engine_web_v1_boot_diagnostic_bytes", @@ -175,6 +175,8 @@ pub const EXPORTS: [&str; 108] = [ "miso_engine_web_v1_response_frame_reply_ptr", "miso_engine_web_v1_response_frame_submit", "miso_engine_web_v1_service", + "miso_engine_web_v1_session_seek", + "miso_engine_web_v1_session_seek_at", "miso_engine_web_v1_source_channels", "miso_engine_web_v1_source_count", "miso_engine_web_v1_source_frames",