From eab696d09ce90b007425310ed863a8621c34bcf1 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 12:24:13 +0000 Subject: [PATCH 1/8] Batch 16 spec check: amend #1640, #1641, #1642, #1293 and #1643 against 35ac17545 Moved anchors, landed dependencies (#1285, #1316, #1318, #1320, #1323, #1396, #1317, #1319, #1562b), stale text against the code, and the decision-17 points. No frozen decision is changed; open questions go to root as rulings. Co-Authored-By: Claude Opus 5.5 --- ...red-seek-from-the-browser-engine-module.md | 61 +++++++++++++++ ...uild-the-timeline-consumer-and-producer.md | 40 ++++++++++ ...n-a-timeline-that-carries-like-a-source.md | 75 ++++++++++++++++++ .../1642-seek-the-session-from-the-c-abi.md | 76 +++++++++++++++++++ ...ek-the-session-from-the-headless-engine.md | 51 +++++++++++++ 5 files changed, 303 insertions(+) diff --git a/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md b/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md index 6ee8c7b0b..521bc2cf2 100644 --- a/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md +++ b/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md @@ -282,3 +282,64 @@ Done under B15-R8: #1386 and #1643 each carry an amendment of 2026-10-15. `seek_at` on both hosts at 48 and 96 kHz; after each block, the browser record and the C ABI struct are equal field by field, from the seek call to the block that applies it and after. Red if either host reports at the call, names a different block, or drops the lateness. + +## Amendment (controls coordinator, batch 16 check, 2026-10-16) + +Checked against `35ac17545`. + +**Landed dependencies.** #1309, #1316, #1319, #1323, #1562 and #1688 are closed and on main. +#1332 and #1387 stay open and ordering-only (B15-R8). The scope is D5, D6 and D7 (three exports). + +**Moved anchors.** +- `hosts/host-web/src/ffi.rs`: `miso_engine_web_v1_source_seek` `:3251-3276`; `service` + `:3336`; `declare_discontinuity` `:3354`; `control_request_ptr` `:3365`; `control_command` + `:3384`; `control_response_ptr` `:3417`; `control_response_bytes` `:3430`; + `plan_latency_read` `:3474`; `plan_latency_ptr` `:3482`. +- `hosts/host-web/src/lib.rs`: `seek_source` `:3533-3565`. It still checks + `plan.can_prepare_source_seek` (`:3548`), calls `ready.control.seek_unserviced` (`:3551-3553`), + then `plan.prepare_source_seek` (`:3556`). `source_result` `:5181`, `source_failure_result` + `:5197`. `RESULT_REFUSED_LIFECYCLE` equals `RESULT_WRONG_STATE` (`:146`, `:117`). +- `PcmSourceConsumer::prepare_seek` is `crates/source/src/lib.rs:1241` (doc `:1233-1240`). The D4 + trap still holds: it returns `false` while an anchored seek is held. +- `SessionState` (`crates/control-plane/src/control.rs`): `seek_at` `:4073` and `seek_report` + `:4090` both run the service step first, so both are message-handler calls, never `process()`. + `SourceControlSet::seek_report` is `crates/host-core/src/source.rs:258`. +- Mirrors: `expected_exports` `scripts/check-web-audioworklet.sh:274` (its call of + `check-abi-layout-v1.py` `:638`); `EXPORTS` `tools/parameter-metadata/src/abi_layout.rs:126` + (105 names; 108 after this slice) and `scripts/check-abi-layout-v1.py:120`; the self-test + fixture `scripts/fixtures/abi-layout-v1-self-test.json`; `sdk/assets/miso-engine-v1-abi-layout.json`; + `sdk/src/generated/abi.ts`. `scripts/check-sdk-generated.sh` holds no list; it needs no edit. +- The hermetic mock in `scripts/test-web-audioworklet.mjs` (`:2741`) lists only the exports the + worklet script calls (68). No worklet script calls the new exports, so it needs no edit. +- #1688 is the model for D6: `WebPlanLatency` (`lib.rs:1229-1246`), its read and pointer exports, + `plan_latency_fields` (`abi_layout.rs:564`) and its parity rows (commit `6c1f8c2c1`). + +**Stale text and the code's truth.** +- D5 "with the plain seek's argument checks": the plain seek's `can_prepare_source_seek` check + and its own source lookup (`lib.rs:3540-3550`) belong to the `prepare_source_seek` path. D5 + takes the staged-ID checks only and lets the core report an unknown source + (`source.id.unknown`, `RESULT_INVALID_ARGUMENT`). +- D6's record is 48 bytes (`struct_size`, `reserved0`, five `u64`). The C struct + `miso_engine_v1_seek_report` is 64 bytes, with `reserved[2]` after the five words + (`crates/capi/include/miso_engine_v1.h:601-610`; `crates/capi/src/abi.rs:585`). The field names + there are `cumulative_underrun_frames` and `cumulative_held_frames`. #1688's browser record + copied the C size and reserved tail. Root decides whether D6 does too (see the coordinator's + report). +- Gate 3 "renders silence until the anchor": while a seek is held, queued PCM of the playing + generation plays first (header "Held", `:312-316`). The gate holds only with no PCM of the old + generation queued. Set the gate up that way. +- D6's error mapping is already the core's: `ReportBusy` is backpressure and `UnknownSource` is + invalid in `source_result`. No new mapping is needed. + +**Paths.** The parity row (root ruling, batch 15c) is in `hosts/host-web/tests/control_parity.rs`, +which is not in the authorized paths. That ruling authorizes it. + +**Hazard.** The browser's plain seek prepares the consumer at the call (`prepare_source_seek`); +the C ABI's applies at the next block's start. The parity row's plain-seek case compares the +reports. If the browser publishes a different `first_sample` there, the gate is red and the fix is +in the plain seek, outside this slice. Stop and report it; do not change the plain seek here. + +**Decision 17.** D5 and D6 call the same core functions as the C ABI (`SessionState::seek_at`, +`SessionState::seek_report`); host-web keeps staging, pointer checks and the record copy. The +widened C13 list names the C seek report only; B15-R8 and the batch 15c ruling approved D5 and D6. +Root may add the three browser names to the list. diff --git a/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md index 3a6fb0d9c..5b5295f37 100644 --- a/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md +++ b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md @@ -179,3 +179,43 @@ Checked against `12d4a4f9d`. - **Dependencies.** #1316, #1318 and #1320 are still open; the ordering rule stands. The source crate's seek anchors have moved; re-read them at filing. - No decision 17 change: the timeline is source transport, not a control. + +## Amendment (controls coordinator, batch 16 check, 2026-10-16) + +Checked against `35ac17545`. + +- **Landed deps.** #1316, #1318 and #1320 are closed and on `main`. The ordering rule is met. +- **Moved anchors (`crates/source/src/lib.rs`).** `SourceCommand` `:56-87`. `SourceSeekError` + `:394-405` (still not `#[non_exhaustive]`). `PcmSourceProducer` `:819-835`; `try_seek` + `:888-919` (anchor check `:905-910`, push `:911-918`). `HostChunkProvider::try_seek` `:1039-1041` + forwards to it. `PcmSourceConsumer` `:1154-1194`; `held_seek` `:1181`; `HeldSeek` `:1199-1205`; + `SeekClock` `:1208-1222`. `observe_seek_at_block_boundary` `:1591-1633`; `apply_seek` + `:1638-1654`. Underrun advance: `begin_block_play` `:1372-1404`. `prepare_graph_source_set` + `:2421-2497`. Gate 2's four tests are at `:2654-2770`. +- **`SeekClock` has three cases now:** `Now`, `At { source_read_sample }` and `Hold` (#1320 + preparation and prime). D1's shared function takes all three. +- **The source observes a command at two points.** `begin_block_observe` and the acquire's one + re-observe (`acquire_current_block`, `:1772-1838`, #1274 MINOR-3). Both call the D1 function. The + timeline has no acquire, so it observes once per block. +- **The seek state is read outside the observe step too:** `settle_block`, + `is_unobserved_generation`, `prime_ready` and the held/underrun split in `begin_block_play`. The + D1 type gives read access to the held seek; no rule moves out of it. +- **Producer factoring.** `try_seek`'s post-push work (`next_write_frame`, `end_of_region_submitted`) + is PCM-only and stays in `PcmSourceProducer`. The shared seek producer needs the quantum for the + anchor check. +- **`check_seek` on the PCM side (for #1642 D1 step 2).** `PcmSourceProducer` and + `HostChunkProvider` (the type `SourceControlSet` holds) both expose `check_seek(command)`, + forwarding to the shared seek producer, as `try_seek` already does. +- **The seek report (#1316) is per ring, not a table.** One seqlock record + (`PublishedWordsWriter`, 5 words, `crates/engine/src/realtime/seqlock.rs`) + per ring. The consumer publishes it (`publish_seek_report`, `:1575-1589`) and the producer reads + it (`seek_report`, `:924-929`). If #1641 D6 stands, the timeline gets the same record through + the same writer and reader, unchanged. D5 then also needs a seek-report row + (`published_words_retained_bytes::<5>()`, as `SourceResourceReport::seek_report_bytes`). +- **Diagnostic name.** The Non-goals say "the diagnostic name (#1641)". #1641 D2 overrides that: + this slice adds the arm as `source.seek.frame_out_of_range`. Host-core anchors moved: + `SourceControlError::diagnostic` `crates/host-core/src/source.rs:86-118`; + `crates/host-core/tests/source_diagnostics.rs` `TABLE` `:27`, `variant_index` `:182-205` + (the new row is index 20). No other crate matches on `SourceSeekError`. +- **No `crates/engine/src/realtime/` edit.** The SPSC and seqlock types are used as they are. +- **Decision 17.** No change. C13 (ruling 2026-10-15) calls seeks and the seek report transport. diff --git a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md index ae1388a5e..d0c89638c 100644 --- a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md +++ b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md @@ -233,3 +233,78 @@ Checked against `12d4a4f9d`. the browser half of gate 2 waits for #1562b. - **Realtime policy** as in #1640's amendment (`./target/release/realtime_policy`). - **Dependencies.** #1285, #1316, #1320, #1323 and #1396 are still open. + +## Amendment (controls coordinator, batch 16 check, 2026-10-16) + +Checked against `35ac17545`. + +- **Landed deps.** #1285, #1309, #1316, #1318, #1320, #1323, #1396 and #1562 (with #1562b) are + closed and on `main`. #1355 (warm adoption) and #1647 are open. +- **The browser runs successors now** (#1562b-W, `2b79cec44`), through the same control plane, + each one counted as a blocking rebuild. The note "the browser half of gate 2 waits for #1562b" + is void. +- **Moved anchors, `crates/source/src/lib.rs`.** `SourceGraphSource` `:1978-2021` (`vacant` + `:2013`). `GraphSourceEntry` `:2041-2046`. Driver `SourceGraphSourceSetDriver` `:2048-2054`. + Its `begin_block` `:2138-2174` (the call `:2159`). `adopt_sources` `:2289-2325`. + `prepare_graph_source_set` `:2421-2497`. +- **Moved anchors, `crates/graph/src/lib.rs`.** Driver trait `:2565-2667`. `GraphCarryProgram` + `:3310-3324`. `install_carry_program` `:3699`. `carry_program_retained_bytes` `:4349`. + `adopt_predecessor` `:4728-4830`; it calls `adopt_sources` only `if !program.sources.is_empty()` + (`:4794-4803`), so D3's flag goes into that condition. Block entry: `:4964-4978`, which reads + `time.absolute_sample + source_read_offset` (#1396 D1). `PreparedRenderPlan::executor_any_mut` + is `crates/engine/src/realtime/plan.rs:1252`, `executor_any` `:1261`. +- **Moved anchors, `crates/host-core/src/prepare.rs`.** Source loop with the carry `:2234-2295` + (vacant push `:2273`, carried overhead `:2253-2263`). Region `0..frames`: `builder.push` + `:2264-2271` and `:2281-2288`. Carry program `:2441-2457`. Floored arrivals + `artifact.report().input_arrivals` `:2846-2851`. `prepare_graph_source_set` and the overhead cap + `:2916-2932`. Offset set `:3439-3440`. `at_discontinuity` `:1032-1056`. +- **Moved anchors, host-core and control plane.** `SourceControlSet` `crates/host-core/src/source.rs:137-140`, + `adopt_persisting` `:321-336`, `diagnostic` `:86-118`. The control plane calls `adopt_persisting` + at three places: `crates/control-plane/src/control.rs:2705` (across a withdrawn candidate), + `:2732` (an ordinary commit) and `:2816` (#1310 D5 step 5, the hand-back to P0). D2's timeline + producer moves at all three, with no new call. +- **`A_max` is readable in time.** `input_arrivals` (floored, nonzero rows only) exists before + `prepare_graph_source_set` is called. So K is `⌈max / q⌉ + 1`, or 1 when it is empty. The + inventory keeps the same rows (`PlanStateInventory::input_floors()`, `:1000`). +- **Stale: the empty-source refusal.** `prepare_graph_source_set` no longer refuses an empty source + list. Host-core still always builds a source set (`:2916`), so a session with no sources also + gets a timeline. The Non-goal "a plan with no source set" stands. +- **Discontinuity (#1323).** `at_discontinuity` keeps only the source rows and sets + `source_read_offset` to 0, so the step back is #1396 D3 as written. D4 holds. The carry program + exists at a discontinuity only when something carries; D3's flag makes it exist on every + successor. +- **Donation (#1344).** `swap_sources` and `donate_sources` (`graph` `:4431-4475`) exchange + *fresh* entries between two unpublished plans. Every control-plane candidate is a successor, so + its timeline is vacant and is never donated. The driver's `swap_sources` leaves the timeline + where it is. +- **D3 also moves the seek report.** If D6 stands, the adopt step also swaps the timeline's + seek-report writer and its last published copy, as a source's report moves with its consumer + (#1316 D5). +- **D6 is per ring, not a row in a table.** There is no shared report table. Each ring has a + 5-word seqlock record (`crates/engine/src/realtime/seqlock.rs`). Host-core reads it per source + ID (`SourceControlSet::seek_report`, `:258`); the C ABI reads it with + `miso_engine_v1_source_seek_report` (`crates/capi/src/ffi.rs:652`). The timeline's "row" is its + own record of the same words. #1642 lists it as a non-goal, so no host reads it after this + slice. +- **D5: no prime path exists above the consumer.** #1320 shipped `PcmSourceConsumer::prime_ready` + (`:1676`) and `prime_block_at` (`:1742`) and the `prime_required` flag. `GraphExecutor` does not + implement `PreparedPlanExecutor::prime_ready` (the default `false`, + `crates/engine/src/realtime/plan.rs:356`), and the driver has no prime call. #1355 adds them, + and its spec does not name the timeline yet. Until then, gate 3's prime half can only compare + the two consumers directly. +- **D7 byte pins (measured on `35ac17545`, x86-64).** `hosts/host-web/tests/retained_ceilings.rs` + `source_overhead_bytes` is 2,658 of 2,688, and + `crates/capi/tests/resource_lifecycle.rs` is 4,174 of 4,352. The timeline's bytes will very + likely cross both ceilings. The browser's wasm `sourceOverheadBytes` ceiling (2,496, + `hosts/host-web/tests/browser-v1/expected.json:73`) is likely to cross too. Re-pin each one with + the reason. Keep the identity `source_overhead + carried_source_overhead == fresh` + (`crates/host-core/tests/successor_swap.rs:197-200`). +- **Gate 1 hazard, plain seeks pushed while render runs.** One driver call cannot put a timeline + and a source in the same block on its own. The two queues are popped at different instants, so + a plain seek pushed during a block can land one block apart. Gate 1 pushes between blocks, so + it is deterministic. Only anchored seeks are exact under concurrency (#1642's concern). +- **Realtime policy.** The floors are now `tools/realtime-policy/src/policy.rs:228-229`. New + regions only add to them. +- **Decision 17.** No change. C13 (rulings 2026-10-15 and 2026-10-16) calls seeks and the seek + report transport. Exposing a timeline report to a host would need a new direct export or a + protocol message, and a new direct export needs its own root ruling. 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 2f424c6c3..b70c45a3f 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 @@ -246,3 +246,79 @@ Checked against `12d4a4f9d`. family. Root confirms this reading at filing; the alternative is a protocol command whose reply carries the generation. - **Dependencies.** #1317, #1319 and #1323 are still open. + +## Amendment (controls coordinator, batch 16 check, 2026-10-16) + +Checked against `35ac17545`. + +**Landed dependencies.** #1309, #1316, #1317, #1319 and #1323 are closed and on main. #1640 and +#1641 are open, in this batch, and land first (in that order). + +**Moved anchors.** +- `SessionState` (`crates/control-plane/src/control.rs`): `seek` `:4048`, `seek_unserviced` + `:4059`, `seek_at` `:4073`, `seek_report` `:4090`, `newest_providers` `:3547`, + `newest_providers_mut` `:3580`, `declare_discontinuity` `:2154`, `SourceFailure` `:4106`. +- `SourceFailure::report` is the trait method in `crates/capi/src/runtime/error.rs:124-128`. +- `crates/capi/src/ffi.rs`: `miso_engine_v1_source_seek` `:525`, `_seek_at` `:556`, + `source_seek_entry` `:582-639`, `_source_seek_report` `:652`, `_declare_discontinuity` `:774`. +- `crates/host-core/src/source.rs`: `SourceControlSet` `:137-140`, `ControlSource` `:21-31`, + `queue_seek` `:271-296`, `seek_report` `:258`, `diagnostic` `:86-112`, `is_internal` + `:127-129`. The producer table is filled at `crates/host-core/src/prepare.rs:2265-2286`. +- `ControlSource` now holds `region_start..region_end`, not `0..frames`. host-core always sets + `region_start` to 0 (`prepare.rs:2269`, `:2283`), so D1's `min(timeline_sample, region_end)` is + always inside the region. +- `scripts/check-capi-abi.sh`: the frozen list is `:248-268`, 19 names. It grows to 21. +- `audit capi` (`tools/audit/src/capi.rs`): `run` `:518-599`; the liveness witness + `replacements == 1` is `:579-582`; `apply_structural_transaction` `:193-249`, with #1319 D4's + anchored seek at `:225-237`; `SEEK_ANCHOR_SAMPLE` `:61`. The qualification job checks only the + digest's shape (`.github/workflows/qualification.yml:834-854`). The digest on main is + `4f52089e4a8cf83c`. +- `docs/C_ABI_V1_QUALIFICATION.md`: the #1275 paragraph is still `:106-122`; the session-call + list `:168-169`; the seek report `:194-219` (frozen count "19" at `:215`); #1317's seek + contract `:221-230`; ABI growth `:231`. + +**Feature bits.** The last bit is `MISO_ENGINE_V1_FEATURE_SOURCE_SEEK_REPORT` (512), mask 1023 +(`crates/capi/include/miso_engine_v1.h:418-428`; `crates/capi/src/abi.rs:53-87`). The next free +bit today is 1024 (mask 2047). D4 still takes the next free bit at merge. The new bit joins the +`features` array of `masks_and_result_codes_are_frozen` (`abi.rs:588-625`), which checks the mask +is the OR of every bit. + +**Header text (#1317 shipped).** "Starting an added stem in time" is gone. The seek paragraphs +are "The source-read clock" `:272`, "Seek." `:281`, "Anchored seek." `:293-299`, "Choosing the +anchor." `:301-310`, then Held, Replacement, One seek in flight, "Seeks across a structural +transaction", "Far past anchors", Rate and "Where a seek landed" (`:350-370`). D5's paragraph +goes right after "Anchored seek." The prototypes are at `:629-643`. "Declared discontinuity" +(`:95-117`) already says the host declares "before a seek of every source". Two more lists name +every session call and must gain the two new symbols: the thread table (`:19-20`) and "Control +service" (`:181-182`). + +**Stale text.** +- `crates/capi/Cargo.toml` needs no edit: the `graph` dev-dependency already has + `features = ["test-support"]` (`:32`, #1639). Drop that path and `Cargo.lock`. +- `crates/capi/src/lib.rs` needs no edit: it re-exports `ffi::*` (`:11`). +- D1 "synchronizes the epochs": today `seek` and `seek_at` run the service step + (`self.service()`), which includes the epoch sync. `session_seek` does the same. +- **Gap for D1 step 2.** `SourceControlSet` holds `HostChunkProvider`, which exposes only + `try_seek` (`crates/source/src/lib.rs:1039`). #1640 D1 puts `check_seek` on the seek-producer + type, but neither #1640 nor #1641 names a `HostChunkProvider::check_seek`. Either #1640 adds the + forward (its paths cover `crates/source/src/lib.rs`), or this slice needs that path. + +**D6 still holds.** #1323 shipped the condition D6 relies on: +`PlanStateInventory::runs_behind_natural_timing` is `raised_arrivals` non-empty or +`source_read_offset > 0` (`crates/host-core/src/prepare.rs:1017-1019`). When neither holds, +`declare_discontinuity` returns `Ok` and publishes nothing (`control.rs:2154-2162`). The audit's +transaction adds a new muted track (no carried floor on a new node) and its route. No audit plan +has a source-read offset. So the declaration publishes nothing and `replacements == 1` stays true. +Code reading only; gate 7's run is the proof. Declare and seek after call 4's render, outside +every render scope, as written. + +**Decision 17.** C13 calls seeks transport under C1, with one core function behind both hosts +(`docs/rulings/one-control-abstraction-2026-10-13.md:287-309`). But the root's widened list of +2026-10-16 does not name `miso_engine_v1_session_seek` or `_session_seek_at`, and says a new +direct export not in the list needs its own root ruling. Root rules before this slice merges. +`SessionState::session_seek` is the one core function; capi keeps only the pointer checks and +the result mapping. + +**Hazard.** A new `impl` block in a render-path crate can move the impl index that +`scripts/check-web-audioworklet-callgraph.py` pins (see `c48cc3875`). Add `session_seek` inside +the existing `impl SessionState`. 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 a8a3ce14c..593cb69b7 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 @@ -189,3 +189,54 @@ Checked against `12d4a4f9d`. export, as a C ABI host declares before its session seek (#1642). So browser latency floors reset as they do on the C ABI. `WasmBoundary` gets the call. #1323 adds the export and its mirrors and is a dependency. Gate 2 asserts the declaration comes first. + +## Amendment (controls coordinator, batch 16 check, 2026-10-16) + +Checked against `35ac17545`. + +**Landed dependencies.** #1309, #1316, #1323 (with `miso_engine_web_v1_declare_discontinuity`, +`hosts/host-web/src/ffi.rs:3354`) and #1562 are closed and on main. #1642 and #1293 are open and in +this batch; both land first. #1387 and #1332 stay open: `single` mode only, as the amendment of +2026-10-14 says. #1644 is open and not in this batch. + +**Moved anchors.** +- `miso_engine_web_v1_source_seek` is `hosts/host-web/src/ffi.rs:3251-3276`. `seek_source` is + `hosts/host-web/src/lib.rs:3533-3565`; it calls `ready.control.seek_unserviced` (`:3551-3553`), + then `plan.prepare_source_seek` (`:3556`). `ready.control` is the `SessionState` (#1381a). +- D1 "maps an error through `source_result`": `SessionState` returns `SourceFailure`, so the map + is `source_failure_result` (`lib.rs:5197`), which calls `source_result` (`:5181`). +- Headless: `OfflineEngine` is `sdk/src/headless/engine.ts:125`; `submitSource` `:419-427`; + `seekSource` `:429-435`. `WasmBoundary.seekSource` is `sdk/src/core/boundary.ts:1144-1167`. The + headless seek eval is `sdk/test/capability-evals.mjs:197`. +- Mirrors: `expected_exports` `scripts/check-web-audioworklet.sh:274`; `EXPORTS` + `tools/parameter-metadata/src/abi_layout.rs:126` (105 today, 108 after #1293, 110 after this + slice) and `scripts/check-abi-layout-v1.py:120`. The list is sorted: `session_seek*` goes after + `service`, #1293's `source_seek_*` after `source_seek`. Merge #1293 first. +- The mock in `scripts/test-web-audioworklet.mjs` (`:2741`) lists only exports the worklet script + calls. This slice adds no worklet call, so the mock needs no edit. + +**The code's truth.** +- **No generation tracking today.** The headless SDK passes the caller's generation straight + through (`seekSource`, `submitSource`); it keeps no generation state. D2's default adds the + first such state to `OfflineEngine`. +- **No discontinuity call today.** No SDK file calls `miso_engine_web_v1_declare_discontinuity` + (only `sdk/src/generated/abi.ts` names it). B15-R7's `WasmBoundary` call is new here. +- **Prepare.** The browser's plain seek prepares the consumer at the call, so new PCM enters at + once. D1's session seek does not (C ABI parity): a ring full of old-generation PCM refuses the + first submission with backpressure until one block renders. Gates 1 and 2 feed rings with room. +- **Thread.** In `single` mode the worklet instance is the control half, so only the caller keeps + the export out of `process()` (the SDK calls it from the message handler, as for + `declare_discontinuity`). Gate 3a's "not ready" case cannot show a render-half call. +- **Paths.** `crates/control-plane/src/` and `hosts/host-web/web/` need no edit: + `SessionState::session_seek` is #1642's, and no worklet script changes. +- Gate 3a's structural transaction goes through #1562's control entry, as amended on 2026-10-15. + +**Hazard (#1644 not in this batch).** After this slice, the headless `seek` is the export's only +SDK caller. The browser SDK and the PCM feed do not call it until #1644, so the stale-ring stall +in the hazards cannot happen in this batch. The headless default generation counts `seekSource` +and `submitSource` too, so a headless caller that mixes them stays ahead. + +**Decision 17.** D1 calls the one core function (`SessionState::session_seek`); host-web keeps +pointer checks and the result map. Open points are in the coordinator's report: the two new +exports and the C13 list, the SDK-chosen generation (D2), and a parity row in +`hosts/host-web/tests/control_parity.rs` (browser session seek against the C ABI session seek). From ee5322ef614c8fece045b5e54dcc417321e7a792 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 12:26:49 +0000 Subject: [PATCH 2/8] Record root rulings B16-R1 to B16-R6 for batch 16 R1: seven seek exports join decision 17 C13's approved list. R2: the session seek's publish fence (#1642 paths and concurrency gate 1b). R3: the timeline's readiness is the non-PCM test (#1641, #1355). R4: #1641 D6 deferred to placeholder TBD45. R5: the SDK chooses the generation (#1643). R6: #1643 parity row. R7 is recorded in #1293's decision record. Co-Authored-By: Claude Opus 5.5 --- ...aw-frame-prime-at-the-first-ready-block.md | 7 ++++ ...n-a-timeline-that-carries-like-a-source.md | 15 ++++++++ .../1642-seek-the-session-from-the-c-abi.md | 36 +++++++++++++++++++ ...ek-the-session-from-the-headless-engine.md | 16 +++++++++ ...ort-the-timeline-seek-position-to-hosts.md | 29 +++++++++++++++ .../one-control-abstraction-2026-10-13.md | 9 +++++ 6 files changed, 112 insertions(+) create mode 100644 .github/ISSUE_SPECS/TBD45-report-the-timeline-seek-position-to-hosts.md diff --git a/.github/ISSUE_SPECS/1355-adopt-a-warm-successor-with-a-raw-frame-prime-at-the-first-ready-block.md b/.github/ISSUE_SPECS/1355-adopt-a-warm-successor-with-a-raw-frame-prime-at-the-first-ready-block.md index 5f9b87dd7..65be3bc6f 100644 --- a/.github/ISSUE_SPECS/1355-adopt-a-warm-successor-with-a-raw-frame-prime-at-the-first-ready-block.md +++ b/.github/ISSUE_SPECS/1355-adopt-a-warm-successor-with-a-raw-frame-prime-at-the-first-ready-block.md @@ -240,3 +240,10 @@ successor makes the source-read offset nonzero, this slice amends that sentence first seek paragraph, in `docs/C_ABI_V1_QUALIFICATION.md` and in the `SourceControlSet::seek_at` doc. It also calls #1320's `prime_ready` with the source-read sample (S + O), not the render sample, and wires the prime on both hosts. + +**Amendment (root ruling B16-R3, batch 16, 2026-10-16).** The plan's timeline consumer (#1640, +#1641) is a carried consumer for this slice. The warm adoption calls its readiness check and its +prime with the same source-read sample and `k` as the sources. Its readiness is the non-PCM test +(#1641's amendment: command queue empty and no held anchored seek before the end of the prime +window), and its prime advances it by `k` blocks. So the timeline moves exactly as the sources' +frames move, and a held anchored seek is never skipped. diff --git a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md index d0c89638c..403e79e0b 100644 --- a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md +++ b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md @@ -308,3 +308,18 @@ Checked against `35ac17545`. - **Decision 17.** No change. C13 (rulings 2026-10-15 and 2026-10-16) calls seeks and the seek report transport. Exposing a timeline report to a host would need a new direct export or a protocol message, and a new direct export needs its own root ruling. + +## Amendment (root rulings, batch 16, 2026-10-16) + +- **B16-R3, D5 readiness.** The timeline is not "always ready". Its readiness is the source + consumer's non-PCM test, written once (#1640 D1's shared seek state): its command queue is empty + and it holds no anchored seek whose anchor falls before the end of the prime window. So a prime + never skips a held anchored seek. Its prime still advances it by `k` blocks, as `k` calls of + `begin_block_at` that observe no command. #1355 calls the readiness check and the prime on the + timeline as on each carried consumer (amended there). Gate 3's prime half compares the timeline + and a source consumer directly until #1355 wires the prime above the consumer. +- **B16-R4, D6 deferred.** No host reads a timeline seek report after this batch. D6 (the + timeline's seek-report record), its bytes in D7 and its move in D3 are taken out of this slice. + Placeholder TBD45 *Report the timeline seek position to hosts* keeps them, for the first slice + that needs a host reader. Gate 1 compares `test_only_timeline_at` with the frame each block's + source read starts at; its seek-report clause is dropped. 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 b70c45a3f..5b0496572 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 @@ -322,3 +322,39 @@ the result mapping. **Hazard.** A new `impl` block in a render-path crate can move the impl index that `scripts/check-web-audioworklet-callgraph.py` pins (see `c48cc3875`). Add `session_seek` inside the existing `impl SessionState`. + +## Amendment (root rulings, batch 16, 2026-10-16) + +- **B16-R1.** `miso_engine_v1_session_seek` and `_session_seek_at` are in decision 17 C13's list + of approved direct exports (seek transport behind `SessionState::session_seek`). +- **B16-R2, the publish fence.** A session seek is one committed act, so every consumer sees it in + the same render block, on both hosts. D1 as filed does not give that for a plain seek: render + pops the timeline's queue and each source's queue at different instants while the session + thread pushes, so a plain seek pushed during a block can split across two blocks. The fix: + - One atomic published-generation word per session, shared by every source producer, the + timeline producer and the source-set driver of every plan of that session. It is carried to a + successor with the producers, so a fresh and a carried consumer of one plan read the same + word. It is allocated on the control thread, never on render. + - The driver loads it once per block entry (acquire), before the timeline and the sources, and + passes the value to each consumer's observe step (#1640 D1's shared function). + - A consumer whose next command (read with the queue's `peek`, which #1320 added) has a + generation above the loaded value leaves it in the queue for a later block. Nothing is + dropped; the slot stays full, so a new push is still backpressure. + - Every seek push ends with a release store of `max(word, g)`: the per-source seek after its + one push, the session seek after its last push (D1 step 3). So all of a session seek's + commands become visible to render at one block entry. + - The session seek also refuses a generation not above the word (`source.generation.stale`), + so a source removed with a higher generation cannot let a command through early. This matches + D3's rule for the host (one above the largest generation it has used on the session). + - One atomic load per block. No allocation, lock or syscall on render; no `engine/realtime` + edit is expected. If one is needed, one attempt; on a refusal stop and report. +- **Paths added for B16-R2.** `crates/source/src/lib.rs` (the word in the driver, the observe + step and the producers' push), `crates/host-core/src/prepare.rs` and + `crates/host-core/src/source.rs` (the word shared into each plan's source set and carried with + the producers), and `crates/control-plane/src/control.rs` only if the carry needs it. +- **New gate 1b (concurrency).** A native test runs a session thread that pushes plain and anchored + session seeks (each one strictly newer) while a render thread renders, for many iterations + (at least 10,000 seeks). In every block, either the timeline and every source apply the seek or + 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). 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 593cb69b7..e57780b7f 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 @@ -240,3 +240,19 @@ and `submitSource` too, so a headless caller that mixes them stays ahead. pointer checks and the result map. Open points are in the coordinator's report: the two new exports and the C13 list, the SDK-chosen generation (D2), and a parity row in `hosts/host-web/tests/control_parity.rs` (browser session seek against the C ABI session seek). + +## Amendment (root rulings, batch 16, 2026-10-16) + +- **B16-R1.** The two exports `miso_engine_web_v1_session_seek` and `_session_seek_at` are in + decision 17 C13's list of approved direct exports (seek transport behind + `SessionState::session_seek`). +- **B16-R5, D2 stands.** The SDK chooses the generation, one above the largest it has used. It is a + host transport token, as on the C ABI (#1642 D3). The core refuses a stale one (and, under + #1642's B16-R2 fence, a generation not above every generation published on the session). +- **B16-R6, new gate 6 (parity row).** In `hosts/host-web/tests/control_parity.rs` (now an + authorized path): a plain session seek and a late anchored session seek on both hosts at 48 and + 96 kHz, two sources of different lengths. After each block, from the call through the applying + block and after, the browser and C ABI seek reports of both sources are equal field by field, + 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. diff --git a/.github/ISSUE_SPECS/TBD45-report-the-timeline-seek-position-to-hosts.md b/.github/ISSUE_SPECS/TBD45-report-the-timeline-seek-position-to-hosts.md new file mode 100644 index 000000000..7175b0ade --- /dev/null +++ b/.github/ISSUE_SPECS/TBD45-report-the-timeline-seek-position-to-hosts.md @@ -0,0 +1,29 @@ +# Report the timeline seek position to hosts + +Placeholder from root ruling B16-R4 (batch 16, 2026-10-16). A draft for root to file. + +## Product outcome + +A host can read where the session's timeline (#1640, #1641) last took a seek, in the same form as +a source's seek report (#1316): the generation in effect, the source-read sample of the first +block it played in, and the timeline value that block started at. The underrun and held counts are +0. Both hosts read it through one core call. + +## Context + +- #1641 D6 planned this row. Root ruling B16-R4 deferred it, with its bytes (#1641 D7) and its + move at a carry (#1641 D3), because no host had a reader. +- A source's report is one seqlock record per ring (`crates/engine/src/realtime/seqlock.rs`), + read by `SourceControlSet::seek_report` and by `miso_engine_v1_source_seek_report` and + `miso_engine_web_v1_source_seek_report` (#1293). + +## Decisions to make at filing + +- The host form: a reserved source ID in the existing report call, or a new call. A new direct + export needs a root ruling under decision 17 C13. +- The record and its writer move with the timeline consumer at every carry (#1641 D3, D4). + +## Dependencies + +- #1641 and #1642. The first slice that needs a host reader of the timeline position (for example + the browser SDK `seek` of #1644, or an automation reader such as #1647). diff --git a/docs/rulings/one-control-abstraction-2026-10-13.md b/docs/rulings/one-control-abstraction-2026-10-13.md index 5b0dc379b..04afbfc5c 100644 --- a/docs/rulings/one-control-abstraction-2026-10-13.md +++ b/docs/rulings/one-control-abstraction-2026-10-13.md @@ -306,6 +306,15 @@ discontinuity declaration; the two #1386 document exports, `miso_engine_web_v1_r plan latency read, `miso_engine_web_v1_plan_latency_read` with its record pointer `miso_engine_web_v1_plan_latency_ptr`, a copy of the core's `PlanQueries::resources()` row. A new direct export that is not in this list needs its own root ruling. +Widened again (root, B16-R1, 2026-10-16): seven seek exports are transport behind one core +function each, so none is a C13 exception. They are the session seek of #1642 and #1643, +`miso_engine_v1_session_seek` and `miso_engine_v1_session_seek_at` (C ABI) and +`miso_engine_web_v1_session_seek` and `miso_engine_web_v1_session_seek_at` (browser), all behind +`SessionState::session_seek`; and #1293's browser forms of the anchored source seek and the seek +report, `miso_engine_web_v1_source_seek_at` (behind `SessionState::seek_at`) and +`miso_engine_web_v1_source_seek_report` with its record pointer +`miso_engine_web_v1_source_seek_report_ptr` (behind `SessionState::seek_report`, the same call as +`miso_engine_v1_source_seek_report`). ### How to add a control From 2f99a344afc671ae21ce95fa292cfe4c6253e712 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 12:41:36 +0000 Subject: [PATCH 3/8] Export the anchored source seek and the seek report from the browser engine module (#1293) D5 miso_engine_web_v1_source_seek_at calls SessionState::seek_at, never prepare_source_seek. D6 miso_engine_web_v1_source_seek_report and its record pointer copy SessionState::seek_report into a 64-byte record with the C miso_engine_v1_seek_report layout (root ruling R7). Both refuse a host with no control half. D7 mirrors: EXPORTS 105 -> 108, the ABI layout document gains the seekReport structure, the SDK mirrors are regenerated, and the self-tests catch an omitted export. The parity row compares the browser record with the C struct per block for plain, late and future seeks at 48 and 96 kHz. check-session-map-shape.py skips the record pointer by name: it takes only the handle like a source-map count query but is not one. Co-Authored-By: Claude Opus 5.5 --- ...red-seek-from-the-browser-engine-module.md | 69 +++++ hosts/host-web/src/ffi.rs | 91 ++++++ hosts/host-web/src/lib.rs | 111 +++++++ hosts/host-web/src/tests.rs | 202 ++++++++++++ hosts/host-web/tests/control_parity.rs | 293 +++++++++++++++++- scripts/check-abi-layout-v1.py | 28 ++ scripts/check-session-map-shape.py | 5 + scripts/check-web-audioworklet.sh | 3 + scripts/fixtures/abi-layout-v1-self-test.json | 16 + sdk/assets/miso-engine-v1-abi-layout.json | 16 + sdk/src/generated/abi.ts | 48 +++ tools/parameter-metadata/src/abi_layout.rs | 73 ++++- 12 files changed, 938 insertions(+), 17 deletions(-) diff --git a/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md b/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md index 521bc2cf2..b00c65333 100644 --- a/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md +++ b/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md @@ -343,3 +343,72 @@ in the plain seek, outside this slice. Stop and report it; do not change the pla `SessionState::seek_report`); host-web keeps staging, pointer checks and the record copy. The widened C13 list names the C seek report only; B15-R8 and the batch 15c ruling approved D5 and D6. Root may add the three browser names to the list. + +## Decision record: #1293 (batch 16) + +Root ruling R7 (2026-10-16): the record is 64 bytes, the C struct's layout, reserved words zero. + +**Shipped (D5, D6, D7; gates 3 to 6 and the batch 15c parity row).** +- `miso_engine_web_v1_source_seek_at(handle, source_id_bytes, generation, source_frame, + anchor_sample)`: the staged-ID checks (count inside the staging) only, then + `AudioWorkletEngineHost::seek_source_at`, which calls `SessionState::seek_at` and maps a refusal + through `source_failure_result`. It never calls `plan.prepare_source_seek` (the D4 trap) and is not + `render_locked`: it runs the service step, so the page calls it from the message handler only. +- `miso_engine_web_v1_source_seek_report(handle, source_id_bytes)` and + `miso_engine_web_v1_source_seek_report_ptr(handle)`: `read_seek_report` calls + `SessionState::seek_report` (the C ABI's call) and copies its five words into `WebSeekReport`, 64 + bytes: `struct_size u32`, `reserved0 u32`, `generation`, `first_sample`, `source_frame`, + `cumulative_underrun_frames`, `cumulative_held_frames` (`u64` each), `reserved [u64; 2]`. Busy is + `RESULT_BACKPRESSURE`, an unknown source `RESULT_INVALID_ARGUMENT`. A refusal leaves the record + alone. host-web holds no seek or report logic. +- A host that is not ready (no control half today) refuses both with `RESULT_REFUSED_LIFECYCLE` and + changes nothing; the pointer export returns 0. +- Mirrors: exports 105 -> 108 in `check-web-audioworklet.sh`, `abi_layout.rs` `EXPORTS`, + `check-abi-layout-v1.py` and its self-test fixture; the `seekReport` structure (64 bytes, eight + fields) in `abi_layout.rs`, the checker's `STRUCTURES`/`SEEK_REPORT_FIELDS` and the fixture; + `sdk/assets/miso-engine-v1-abi-layout.json` and `sdk/src/generated/abi.ts` regenerated with + `cargo run -p parameter-metadata -- --print-abi-layout`, `node codegen/assets.mjs` and + `node codegen/generate.mjs`. + +**Departures from the text.** +- `scripts/check-session-map-shape.py` (not in the authorized paths) treated every + `source_*(handle) -> u32` export as a source-map query, so it flagged `source_seek_report_ptr`. + Mechanical fix: it skips that one name, with a comment. No other behavior changed. +- The plain seek is unchanged. The amendment's hazard did not materialize: the parity row shows the + browser's report after a plain seek equals the C ABI's at the call (the old generation) and at + every later block (`first_sample` = the first block that begins after the call), at 48 and 96 kHz. + `prepare_source_seek` at the call changes what the consumer reads, not the published report. +- Gate 5 for the pointer export is partly unobservable natively: `pointer_u32` truncates a 64-bit + native address to 0, so a native test cannot tell "not ready" from "ready" for + `source_seek_report_ptr`. The not-ready refusal of both call exports is tested; the pointer's + not-ready 0 follows from the same `status().state` check. +- Gate 3's "silence until the anchor" is set up with no PCM of the old generation queued. + +**Gates -> tests.** +- Gate 3 and 4: `tests::the_anchored_seek_exports_play_at_the_anchor_and_report_where_it_landed` + (raw exports: an unaligned anchor and an unknown source are `RESULT_INVALID_ARGUMENT`; an anchor two + quanta ahead is `RESULT_OK`; two silent blocks then the sought frame; the record reads + `(2, 256, 10000)`, 256 held frames, 0 underrun frames, reserved zero). +- Gate 5: `tests::the_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing`. +- Parity row (`hosts/host-web/tests/control_parity.rs`): + `the_seek_report_is_alike_on_both_hosts_for_plain_late_and_future_seeks` (plain seek, late + `seek_at` whose anchor is behind the render clock, and `seek_at` two quanta ahead; 48 and 96 kHz; + record equals the C struct field by field at the call and after every block; output bits equal). +- Gate 6: `python3 -B scripts/check-abi-layout-v1.py --self-test` (36 mutations caught, including + dropping each of the three exports and a record field), `parameter-metadata --check sdk/assets`, + `check-web-audioworklet.sh` (compares the wasm's exports with `expected_exports`), + `check-sdk-generated.sh target/ci/h1293/a`. + +**Commands run.** `cargo test --locked -p host-web --features host-web/test-support` (all pass); +`cargo clippy --locked -p host-web -p parameter-metadata --all-targets --all-features -- -D +warnings`; `cargo fmt --all`; `build-web-audioworklet.sh --named-twin target/ci/h1293/n +target/ci/h1293/a`; `check-web-audioworklet.sh`; `check-sdk-generated.sh target/ci/h1293/a`; +`test-web-audioworklet.sh`; `check-workspace-policy.sh`; `cargo run -p realtime-policy`. + +**Artifact.** `miso-engine-v1-audio-worklet.simd128.wasm`: 4,198,551 bytes, sha256 +`20b08c9049f20b898676f17ea1fda74ea18eda4eabe8a94526c1e80eeb07b5be` (named twin 4,818,838 bytes, +`965b71c5265c0fa8cfa10579856e3f0274b1fba634423edf8e6fd69aba8ec5d7`). + +**Successors.** The SDK binding for the three exports and the message-handler-only rule for +`source_seek_at` (the feed worklet's render-side seek must keep the plain seek) are #1294 and #1297. +A `seek_at_unserviced` for a render-side caller stays unbuilt until a caller needs it. diff --git a/hosts/host-web/src/ffi.rs b/hosts/host-web/src/ffi.rs index 21fda2868..6160e5d90 100644 --- a/hosts/host-web/src/ffi.rs +++ b/hosts/host-web/src/ffi.rs @@ -3275,6 +3275,91 @@ pub extern "C" fn miso_engine_web_v1_source_seek( }) } +/// Run `operation` on the first `id_count` bytes of the source ID staging, or answer +/// [`RESULT_INVALID_ARGUMENT`] for a count past the staging. The prefix is only read during the +/// synchronous call. +fn with_staged_source_id( + host: &mut AudioWorkletEngineHost, + id_count: usize, + operation: impl FnOnce(&mut AudioWorkletEngineHost, &[u8]) -> u32, +) -> u32 { + let Some(ids) = host.source_id_mut() else { + return host.record_boundary_result(RESULT_INTERNAL); + }; + let Some(source_id) = ids.get(..id_count) else { + return host.record_boundary_result(RESULT_INVALID_ARGUMENT); + }; + let source_id_pointer = source_id.as_ptr(); + // SAFETY: The checked ID prefix lies in stable staging and is only read during the synchronous + // safe-host call; neither anchored-seek operation mutates staging. + let source_id = unsafe { slice::from_raw_parts(source_id_pointer, id_count) }; + operation(host, source_id) +} + +/// Queue one generation-tagged anchored source seek for the staged source ID (issue #1293 D5): +/// `source_frame` enters the graph in the block that starts at absolute render sample +/// `anchor_sample`. It runs the control plane's `SessionState::seek_at`, the call the C ABI's +/// `miso_engine_v1_source_seek_at` 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, which the anchor block applies. +/// +/// Results: [`RESULT_OK`]; [`RESULT_INVALID_ARGUMENT`] for a wrong handle, an ID count past the +/// staging, an unknown source, an anchor that is not a quantum multiple or any other malformed +/// seek; [`RESULT_BACKPRESSURE`] for a full control queue; [`RESULT_REFUSED_LIFECYCLE`] for a host +/// that is not ready (nothing changes); [`RESULT_INTERNAL`]. +#[unsafe(no_mangle)] +pub extern "C" fn miso_engine_web_v1_source_seek_at( + handle: u32, + source_id_bytes: u32, + generation: u64, + source_frame: u64, + anchor_sample: u64, +) -> u32 { + with_host_mut(handle, RESULT_INVALID_ARGUMENT, |host| { + if host.status().state != STATE_READY { + return host.seek_source_at(&[], 0, 0, 0); + } + with_staged_source_id(host, source_id_bytes as usize, |host, source_id| { + host.seek_source_at(source_id, generation, source_frame, anchor_sample) + }) + }) +} + +/// 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 +/// frames. It runs the control plane's `SessionState::seek_report`, the call the C ABI's +/// `miso_engine_v1_source_seek_report` runs, so it is control side only. +/// +/// Results: [`RESULT_OK`]; [`RESULT_INVALID_ARGUMENT`] for a wrong handle, an ID count past the +/// staging or an unknown source; [`RESULT_BACKPRESSURE`] for a read that landed inside render's +/// publication (read again); [`RESULT_REFUSED_LIFECYCLE`] for a host that is not ready; +/// [`RESULT_INTERNAL`]. A refusal leaves the record alone. +#[unsafe(no_mangle)] +pub extern "C" fn miso_engine_web_v1_source_seek_report(handle: u32, source_id_bytes: u32) -> u32 { + with_host_mut(handle, RESULT_INVALID_ARGUMENT, |host| { + if host.status().state != STATE_READY { + return host.read_seek_report(&[]); + } + with_staged_source_id(host, source_id_bytes as usize, |host, source_id| { + host.read_seek_report(source_id) + }) + }) +} + +/// Return the stable address of the 64-byte seek report record, or zero for an invalid handle or a +/// host that is not ready (issue #1293 D6). +#[unsafe(no_mangle)] +pub extern "C" fn miso_engine_web_v1_source_seek_report_ptr(handle: u32) -> u32 { + with_host(handle, 0, |host| { + if host.status().state == STATE_READY { + pointer_u32(ptr::from_ref(host.seek_report())) + } else { + 0 + } + }) +} + /// Render one exact prepared quantum after validating the browser's actual frame count. #[unsafe(no_mangle)] pub extern "C" fn miso_engine_web_v1_render(handle: u32, actual_frames: u32) -> u32 { @@ -4159,6 +4244,12 @@ pub mod native_staging { super::with_host(handle, None, |host| Some(*host.watermark())) } + /// The seek report record the host `handle` names last copied (#1293 D6), as the worklet reads + /// it through `miso_engine_web_v1_source_seek_report_ptr`; `None` for an unknown handle. + pub fn seek_report(handle: u32) -> Option { + super::with_host(handle, None, |host| Some(*host.seek_report())) + } + /// The plan latency record the host `handle` names last copied (#1688), as the worklet reads /// it through `miso_engine_web_v1_plan_latency_ptr`; `None` for an unknown handle. pub fn plan_latency(handle: u32) -> Option { diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index 9348d23b0..94d099702 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -1245,6 +1245,39 @@ pub struct WebPlanLatency { /// Exact byte size of [`WebPlanLatency`]. pub const PLAN_LATENCY_BYTES: u32 = size_of::() as u32; +/// Where a source landed after its newest seek (#1293 D6), in the order and sizes of the C ABI's +/// `miso_engine_v1_seek_report`. +/// +/// The host keeps one at a stable address, written by +/// [`AudioWorkletEngineHost::read_seek_report`] and found through +/// `miso_engine_web_v1_source_seek_report_ptr`. The words are the control plane's +/// `SessionState::seek_report`, copied: the browser computes nothing (root ruling R7: 64 bytes, +/// the C struct's layout, reserved words zero). +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct WebSeekReport { + /// Exact structure byte size ([`SEEK_REPORT_BYTES`]); zero until the first successful read. + pub struct_size: u32, + /// Required zero. + pub reserved0: u32, + /// The generation in effect; `0` until the source's ring has rendered a block. + pub generation: u64, + /// The source-read sample of the first block `generation` played in: the clock + /// `miso_engine_web_v1_source_seek_at`'s anchor is on. + pub first_sample: u64, + /// The source frame that block started at; a late anchored seek's includes its lateness. + pub source_frame: u64, + /// Saturating cumulative missing in-region frames (true underruns). + pub cumulative_underrun_frames: u64, + /// Saturating cumulative in-region frames held while an anchored seek waited. + pub cumulative_held_frames: u64, + /// Required-zero expansion words. + pub reserved: [u64; 2], +} + +/// Exact byte size of [`WebSeekReport`], equal to the C ABI seek report's. +pub const SEEK_REPORT_BYTES: u32 = size_of::() as u32; + /// Exact retained-resource projection for the browser bridge and production artifacts. #[repr(C)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -2150,6 +2183,9 @@ pub struct AudioWorkletEngineHost { /// The plan latency `read_plan_latency` last copied (#1688 D2), at a stable address in the /// control half: all zero until the first successful read. plan_latency: WebPlanLatency, + /// The seek report `read_seek_report` last copied (#1293 D6), at a stable address in the + /// control half: all zero until the first successful read. + seek_report: WebSeekReport, /// First sample eligible for the current lease. A snapshot that opens before this boundary /// is an old/partial interval and is dropped rather than presented as a clean measurement. meter_activation_sample: u64, @@ -2350,6 +2386,7 @@ impl AudioWorkletEngineHost { ..WebPlanWatermark::default() }, plan_latency: WebPlanLatency::default(), + seek_report: WebSeekReport::default(), meter_lease: false, meter_activation_sample: 0, buffers: Some(buffers), @@ -2754,6 +2791,46 @@ impl AudioWorkletEngineHost { &self.plan_latency } + /// Copy where the staged source landed after its newest seek into the host's record (#1293 + /// D6): the control plane's `SessionState::seek_report`, the call the C ABI's + /// `miso_engine_v1_source_seek_report` runs, after its service step. Control side only: the + /// message handler, never `process()`. + /// + /// A host that is not ready refuses with [`RESULT_REFUSED_LIFECYCLE`]; a read that landed + /// inside render's publication on every bounded attempt is [`RESULT_BACKPRESSURE`] (read + /// again); a source the session does not declare is [`RESULT_INVALID_ARGUMENT`]. Every refusal + /// leaves the record alone. Like [`Self::service`] it leaves the status's last result alone. + pub fn read_seek_report(&mut self, source_id: &[u8]) -> u32 { + if self.status.state != STATE_READY { + return RESULT_REFUSED_LIFECYCLE; + } + let Some(ready) = self.ready.as_mut() else { + return RESULT_REFUSED_LIFECYCLE; + }; + match ready.control.seek_report(source_id) { + Ok(report) => { + self.seek_report = WebSeekReport { + struct_size: SEEK_REPORT_BYTES, + reserved0: 0, + generation: report.generation, + first_sample: report.first_sample, + source_frame: report.source_frame, + cumulative_underrun_frames: report.cumulative_underrun_frames, + cumulative_held_frames: report.cumulative_held_frames, + reserved: [0; 2], + }; + RESULT_OK + } + Err(failure) => source_failure_result(failure), + } + } + + /// The record [`Self::read_seek_report`] fills. + #[must_use] + pub const fn seek_report(&self) -> &WebSeekReport { + &self.seek_report + } + /// The record [`Self::read_watermark`] fills. #[must_use] pub const fn watermark(&self) -> &WebPlanWatermark { @@ -3564,6 +3641,40 @@ impl AudioWorkletEngineHost { self.record(code) } + /// Queue one anchored source seek (#1293 D5): `source_frame` enters the graph in the block that + /// starts at absolute render sample `anchor_sample`. + /// + /// It runs the control plane's `SessionState::seek_at`, the call the C ABI's + /// `miso_engine_v1_source_seek_at` runs, after its service step, so it is a control-side call + /// (the message handler, never `process()`). It never prepares the plan's consumer: the + /// anchored seek is held by the ring and the consumer applies it at its anchor block, where + /// `prepare_source_seek` would refuse a held seek. A host that is not ready refuses with + /// [`RESULT_REFUSED_LIFECYCLE`] and changes nothing; the core's refusals map through + /// [`source_failure_result`] (an unknown source or an anchor that is not a quantum multiple is + /// [`RESULT_INVALID_ARGUMENT`]). + pub fn seek_source_at( + &mut self, + source_id: &[u8], + generation: u64, + source_frame: u64, + anchor_sample: u64, + ) -> u32 { + if self.status.state != STATE_READY { + return self.record(RESULT_REFUSED_LIFECYCLE); + } + let Some(ready) = self.ready.as_mut() else { + return self.record(RESULT_REFUSED_LIFECYCLE); + }; + let code = match ready + .control + .seek_at(source_id, generation, source_frame, anchor_sample) + { + Ok(()) => RESULT_OK, + Err(failure) => source_failure_result(failure), + }; + self.record(code) + } + // 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 d321bdfd6..35d27ef82 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -1277,6 +1277,208 @@ fn paused_seek_recycles_full_internal_queue_before_first_target_quantum() { assert_eq!(host.status().next_absolute_sample, 128); } +/// The exports of an identity session at one quantum, driven as the page's message handler does: +/// the source ID staged, then `seek_at`, `source_submit`, `render` and `seek_report` as raw calls. +struct SeekExports { + handle: u32, + quantum: u32, +} + +impl SeekExports { + const SOURCE: &'static [u8] = b"fixture-source"; + + fn boot() -> Self { + let quantum = 128; + let document = identity_session(quantum, 480_000); + let options = WebBootOptions { + source_ring_frames: 512, + ..boot_options(quantum) + }; + let handle = crate::ffi::test_boot(document.as_bytes(), options); + assert_ne!(handle, 0); + let rig = Self { handle, quantum }; + rig.stage(Self::SOURCE); + assert_eq!(crate::ffi::test_fill_source_pcm(handle, 0.25), RESULT_OK); + rig + } + + fn stage(&self, id: &[u8]) { + assert_eq!( + crate::ffi::test_copy_staging(self.handle, BUFFER_SOURCE_ID, id), + RESULT_OK + ); + } + + fn seek_at(&self, generation: u64, frame: u64, anchor: u64) -> u32 { + miso_engine_web_v1_source_seek_at( + self.handle, + Self::SOURCE.len() as u32, + generation, + frame, + anchor, + ) + } + + fn submit(&self, generation: u64, start_frame: u64) { + assert_eq!( + miso_engine_web_v1_source_submit( + self.handle, + Self::SOURCE.len() as u32, + generation, + start_frame, + 2, + self.quantum, + 0, + ), + RESULT_OK + ); + } + + fn render(&self) -> Vec { + assert_eq!( + miso_engine_web_v1_render(self.handle, self.quantum), + RESULT_OK + ); + crate::ffi::native_staging::output_pcm(self.handle).expect("output") + } + + fn report(&self) -> (u32, WebSeekReport) { + let code = miso_engine_web_v1_source_seek_report(self.handle, Self::SOURCE.len() as u32); + ( + code, + crate::ffi::native_staging::seek_report(self.handle).expect("record"), + ) + } +} + +/// Issue #1293 gates 3 and 4 through the raw exports: an anchored seek two quanta ahead is +/// accepted (`RESULT_OK`, not the `prepare_source_seek` path's `RESULT_INTERNAL` for a held seek), +/// the source plays silence until the anchor block and the sought frame from it on, an unaligned +/// anchor is invalid, and the 64-byte record reads the generation, the anchor as `first_sample`, +/// and the sought frame. +/// +/// Test value: red if `source_seek_at` is wired through `plan.prepare_source_seek` (the D4 trap: +/// the future anchor would be `RESULT_INTERNAL`), if it queues a plain seek (sound from the next +/// block), or if the report export reads another source's ring, another word order or a stale +/// record. +#[test] +fn the_anchored_seek_exports_play_at_the_anchor_and_report_where_it_landed() { + let rig = SeekExports::boot(); + assert_eq!(SEEK_REPORT_BYTES, 64); + // Before any seek: a zero-filled record, then generation 1's. + assert_eq!( + crate::ffi::native_staging::seek_report(rig.handle), + Some(WebSeekReport::default()) + ); + + // An anchor that is not a quantum multiple, and an unknown source, are invalid. + assert_eq!(rig.seek_at(2, 10_000, 130), RESULT_INVALID_ARGUMENT); + rig.stage(b"no-such-source"); + assert_eq!( + miso_engine_web_v1_source_seek_at(rig.handle, 14, 2, 10_000, 256), + RESULT_INVALID_ARGUMENT + ); + assert_eq!( + miso_engine_web_v1_source_seek_report(rig.handle, 14), + RESULT_INVALID_ARGUMENT + ); + rig.stage(SeekExports::SOURCE); + + // Two quanta ahead: accepted, with the new generation's PCM queued behind the anchor. + assert_eq!(rig.seek_at(2, 10_000, 256), RESULT_OK); + rig.submit(2, 10_000); + rig.submit(2, 10_128); + for block in 0..2 { + let output = rig.render(); + assert!( + output.iter().all(|sample| *sample == 0.0), + "block {block}: silence until the anchor" + ); + } + let output = rig.render(); + assert!( + output.iter().all(|sample| *sample == 0.25), + "the anchor block plays the sought frame" + ); + let (code, record) = rig.report(); + assert_eq!(code, RESULT_OK); + assert_eq!( + ( + record.struct_size, + record.reserved0, + record.generation, + record.first_sample, + record.source_frame, + record.reserved + ), + (SEEK_REPORT_BYTES, 0, 2, 256, 10_000, [0; 2]) + ); + assert_eq!( + record.cumulative_underrun_frames, 0, + "no frame was missing in-region" + ); + assert_eq!( + record.cumulative_held_frames, 256, + "two quanta were held until the anchor" + ); +} + +/// Issue #1293 gate 5: on a host that is not ready (no control half), `source_seek_at` and +/// `source_seek_report` refuse with `RESULT_REFUSED_LIFECYCLE`, the report record keeps its last +/// words, and a wrong handle or an ID count past the staging is `RESULT_INVALID_ARGUMENT`. +/// +/// Test value: red if either export runs on a host with no control half (it would reach for +/// control state the instance does not hold) or overwrites the record on a refusal. +#[test] +fn the_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing() { + let rig = SeekExports::boot(); + assert_eq!(rig.seek_at(2, 10_000, 256), RESULT_OK); + rig.submit(2, 10_000); + let _ = rig.render(); + let (code, before) = rig.report(); + assert_eq!(code, RESULT_OK); + + // Argument checks on a live host. + assert_eq!( + miso_engine_web_v1_source_seek_at(0, 14, 3, 0, 512), + RESULT_INVALID_ARGUMENT + ); + assert_eq!( + miso_engine_web_v1_source_seek_report(0, 14), + RESULT_INVALID_ARGUMENT + ); + assert_eq!( + miso_engine_web_v1_source_seek_at(rig.handle, u32::MAX, 3, 0, 512), + RESULT_INVALID_ARGUMENT + ); + assert_eq!( + miso_engine_web_v1_source_seek_report(rig.handle, u32::MAX), + RESULT_INVALID_ARGUMENT + ); + assert_eq!( + crate::ffi::native_staging::seek_report(rig.handle), + Some(before) + ); + + // 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 + ); + assert_eq!(rig.seek_at(3, 0, 1024), RESULT_REFUSED_LIFECYCLE); + assert_eq!(rig.report().0, RESULT_REFUSED_LIFECYCLE); + assert_eq!( + crate::ffi::native_staging::seek_report(rig.handle), + Some(before), + "a refusal leaves the record alone" + ); + assert_eq!(miso_engine_web_v1_source_seek_report_ptr(0), 0); +} + #[test] fn output_mismatch_and_disposal_are_sticky_and_idempotent() { let mut host = prepared_host(64); diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index de2e8e4bb..337d92d06 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -44,7 +44,8 @@ use capi::{ ABI_VERSION as C_ABI_VERSION, BYTES_OUT_SIZE, BytesOut, COMPILE_LIMITS_SIZE, CompileLimits, ENGINE_CONFIG_SIZE, EVENT_LANE_RELIABLE, EngineConfig, PLAN_RESOURCE_REPORT_SIZE, PLANAR_OUTPUT_SIZE, Plan, PlanResourceReport, PlanarOutput, RESULT_OK as C_RESULT_OK, - SOURCE_CHUNK_SIZE, SUBMIT_REPORT_SIZE, Session, SourceChunk, SubmitReport, + SOURCE_CHUNK_SIZE, SOURCE_SEEK_REPORT_SIZE, SUBMIT_REPORT_SIZE, Session, SourceChunk, + SourceSeekReport, SubmitReport, }; use controls::{ControlAddress, Lane, Owner, Section}; use protocol::{ @@ -3655,3 +3656,293 @@ fn a_rebuild_that_adds_latency_moves_the_plan_latency_alike_on_both_hosts() { ); } } + +// --------------------------------------------------------------------------------------------- +// Source seek reports (#1293 D5, D6; batch 15c root ruling) +// --------------------------------------------------------------------------------------------- + +impl CAbi { + fn seek(&self, name: &str, generation: u64, source_frame: u64) { + // SAFETY: The session is live and used by this thread alone; the name outlives the call. + let code = unsafe { + capi::miso_engine_v1_source_seek( + self.session, + name.as_ptr(), + name.len() as u64, + generation, + source_frame, + ) + }; + assert_eq!(code, C_RESULT_OK, "C ABI seek"); + } + + fn seek_at(&self, name: &str, generation: u64, source_frame: u64, anchor: u64) { + // SAFETY: As `seek`. + let code = unsafe { + capi::miso_engine_v1_source_seek_at( + self.session, + name.as_ptr(), + name.len() as u64, + generation, + source_frame, + anchor, + ) + }; + assert_eq!(code, C_RESULT_OK, "C ABI seek_at"); + } + + /// The source's seek report through `miso_engine_v1_source_seek_report`. + fn seek_report(&self, name: &str) -> SourceSeekReport { + let mut out = SourceSeekReport { + struct_size: SOURCE_SEEK_REPORT_SIZE, + reserved0: 0, + generation: 0, + first_sample: 0, + source_frame: 0, + cumulative_underrun_frames: 0, + cumulative_held_frames: 0, + reserved: [0; 2], + }; + // SAFETY: As `seek`; `out` is owned storage of the exact size. + let code = unsafe { + capi::miso_engine_v1_source_seek_report( + self.session, + name.as_ptr(), + name.len() as u64, + &mut out, + ) + }; + assert_eq!(code, C_RESULT_OK, "C ABI seek report"); + out + } +} + +/// Both hosts' seek report for `name`, equal field by field. Returns +/// `(generation, first_sample, source_frame, underrun, held)`. +fn same_seek_report( + browser: &mut Rig, + c_abi: &CAbi, + name: &str, + label: &str, +) -> (u64, u64, u64, u64, u64) { + assert_eq!( + browser.host.read_seek_report(name.as_bytes()), + host_web::RESULT_OK, + "{label}: the browser seek report read" + ); + let b = *browser.host.seek_report(); + let c = c_abi.seek_report(name); + assert_eq!(b.struct_size, c.struct_size, "{label}: struct_size"); + assert_eq!(b.reserved0, c.reserved0, "{label}: reserved0"); + assert_eq!(b.generation, c.generation, "{label}: generation"); + assert_eq!(b.first_sample, c.first_sample, "{label}: first_sample"); + assert_eq!(b.source_frame, c.source_frame, "{label}: source_frame"); + assert_eq!( + b.cumulative_underrun_frames, c.cumulative_underrun_frames, + "{label}: cumulative underrun frames" + ); + assert_eq!( + b.cumulative_held_frames, c.cumulative_held_frames, + "{label}: cumulative held frames" + ); + assert_eq!(b.reserved, c.reserved, "{label}: reserved"); + ( + b.generation, + b.first_sample, + b.source_frame, + b.cumulative_underrun_frames, + b.cumulative_held_frames, + ) +} + +/// Block `block` on both hosts with `src-t1` fed generation `generation` from source block +/// `t1_start` (and the other two sources fed at the render position); `None` feeds no `src-t1` +/// chunk this block. The output bits equal. +fn seek_block( + browser: &mut Rig, + c_abi: &mut CAbi, + block: u64, + generation: u64, + t1_start: Option, + label: &str, +) { + let rate = browser.rate; + for (feed, strip) in [T1, T2, T3].into_iter().enumerate() { + let source = format!("src-{}", strip.id); + let (chunk_generation, start) = if strip.id == T1.id { + let Some(t1_start) = t1_start else { + continue; + }; + (generation, t1_start) + } else { + (1, block) + }; + let [left, right] = planes(feed as u64, block); + assert_eq!( + browser.host.submit_source( + source.as_bytes(), + chunk_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, chunk_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"); +} + +/// #1293 D6 with the batch 15c ruling: after a plain seek, a late anchored seek and an anchored +/// seek ahead, the browser's 64-byte seek report record equals the C ABI's struct field by field, +/// at the call, at every block through the one that applies it, and after, at 48 and 96 kHz. +/// +/// Test value: red if either host reports the new generation at the call (before a block has +/// played it), names a different `first_sample` or `source_frame` for the applying block, drops a +/// late anchored seek's lateness from `source_frame` (the C ABI's `first_sample` is the block that +/// played, not the anchor), counts held or underrun frames differently, or leaves a reserved word +/// nonzero. The seeks' own result codes are asserted, and the rows are not vacuous (the new +/// generation is reported with a nonzero source frame, and the late seek lands after its anchor). +#[test] +fn the_seek_report_is_alike_on_both_hosts_for_plain_late_and_future_seeks() { + const NAME: &str = "src-t1"; + for rate in [48_000, 96_000] { + let model = model_at(rate); + let mut browser = Rig::new(&model); + let mut c_abi = CAbi::new(&model); + let label = format!("{rate} Hz"); + for block in 0..4 { + seek_block(&mut browser, &mut c_abi, block, 1, Some(block), &label); + } + let boot = same_seek_report(&mut browser, &c_abi, NAME, &label); + assert_eq!(boot.0, 1, "{label}: generation 1 has rendered"); + + // 1. A plain seek: reported at the call as before, then as the block that starts after it. + let plain_frame = 40_u64; + let call_block = 4_u64; + assert_eq!( + browser + .host + .seek_source(NAME.as_bytes(), 2, plain_frame * u64::from(QUANTUM)), + host_web::RESULT_OK + ); + c_abi.seek(NAME, 2, plain_frame * u64::from(QUANTUM)); + let at_call = same_seek_report(&mut browser, &c_abi, NAME, &format!("{label} plain: call")); + assert_eq!( + at_call.0, 1, + "{label} plain: the call alone reports generation 1" + ); + for block in call_block..call_block + 4 { + seek_block( + &mut browser, + &mut c_abi, + block, + 2, + Some(plain_frame + (block - call_block)), + &format!("{label} plain"), + ); + let report = same_seek_report( + &mut browser, + &c_abi, + NAME, + &format!("{label} plain: after block {block}"), + ); + assert_eq!(report.0, 2, "{label} plain: generation 2 in effect"); + } + let plain = same_seek_report(&mut browser, &c_abi, NAME, &format!("{label} plain: end")); + assert_eq!( + plain.1, + call_block * u64::from(QUANTUM), + "{label} plain: first block that began after the call" + ); + assert_eq!(plain.2, plain_frame * u64::from(QUANTUM)); + + // 2. A late anchored seek: the anchor (block 3) is behind the render clock (block 8). + let late_call = call_block + 4; + let late_frame = 50_u64; + let anchor = 3 * u64::from(QUANTUM); + assert_eq!( + browser.host.seek_source_at( + NAME.as_bytes(), + 3, + late_frame * u64::from(QUANTUM), + anchor + ), + host_web::RESULT_OK + ); + c_abi.seek_at(NAME, 3, late_frame * u64::from(QUANTUM), anchor); + same_seek_report(&mut browser, &c_abi, NAME, &format!("{label} late: call")); + for block in late_call..late_call + 4 { + seek_block( + &mut browser, + &mut c_abi, + block, + 3, + Some(late_frame + (block - late_call)), + &format!("{label} late"), + ); + same_seek_report( + &mut browser, + &c_abi, + NAME, + &format!("{label} late: after block {block}"), + ); + } + let late = same_seek_report(&mut browser, &c_abi, NAME, &format!("{label} late: end")); + assert_eq!(late.0, 3, "{label} late: generation 3 in effect"); + assert!( + late.1 > anchor, + "{label} late: it played after its anchor ({} vs {anchor})", + late.1 + ); + + // 3. An anchored seek two quanta ahead: held until the anchor block. + let ahead_call = late_call + 4; + let ahead_frame = 60_u64; + let ahead_anchor = (ahead_call + 2) * u64::from(QUANTUM); + assert_eq!( + browser.host.seek_source_at( + NAME.as_bytes(), + 4, + ahead_frame * u64::from(QUANTUM), + ahead_anchor + ), + host_web::RESULT_OK + ); + c_abi.seek_at(NAME, 4, ahead_frame * u64::from(QUANTUM), ahead_anchor); + same_seek_report(&mut browser, &c_abi, NAME, &format!("{label} ahead: call")); + for block in ahead_call..ahead_call + 5 { + seek_block( + &mut browser, + &mut c_abi, + block, + 4, + // The ring holds two quanta: feed two, wait for the anchor block to take one. + match block - ahead_call { + 0 | 1 => Some(ahead_frame + (block - ahead_call)), + 2 => None, + later => Some(ahead_frame + later - 1), + }, + &format!("{label} ahead"), + ); + same_seek_report( + &mut browser, + &c_abi, + NAME, + &format!("{label} ahead: after block {block}"), + ); + } + let ahead = same_seek_report(&mut browser, &c_abi, NAME, &format!("{label} ahead: end")); + assert_eq!( + (ahead.0, ahead.1), + (4, ahead_anchor), + "{label} ahead: lands at its anchor" + ); + assert!(ahead.4 > 0, "{label} ahead: the wait held frames"); + } +} diff --git a/scripts/check-abi-layout-v1.py b/scripts/check-abi-layout-v1.py index ec1c3ff38..65d4a1b3c 100644 --- a/scripts/check-abi-layout-v1.py +++ b/scripts/check-abi-layout-v1.py @@ -173,6 +173,9 @@ "miso_engine_web_v1_source_frames", "miso_engine_web_v1_source_id", "miso_engine_web_v1_source_seek", + "miso_engine_web_v1_source_seek_at", + "miso_engine_web_v1_source_seek_report", + "miso_engine_web_v1_source_seek_report_ptr", "miso_engine_web_v1_source_submit", "miso_engine_web_v1_spectrum_analysis", "miso_engine_web_v1_spectrum_arm", @@ -237,6 +240,7 @@ "commandReport": 48, "planWatermark": 96, "planLatency": 64, + "seekReport": 64, "observationSelection": 32, "observationResult": 96, "spectrumRequest": 40, @@ -278,6 +282,11 @@ PLAN_LATENCY_FIELDS = [ "structSize", "reserved0", "latencySamples", "tailKind", "tailSamples", "reserved", ] +# The source seek report record (#1293 D6): the C ABI seek report's layout. +SEEK_REPORT_FIELDS = [ + "structSize", "reserved0", "generation", "firstSample", "sourceFrame", + "cumulativeUnderrunFrames", "cumulativeHeldFrames", "reserved", +] # The status block (#1349 D2): offset 48 `liveValuesSuperseded` (#1399), then the applied-revision # watermark words at 56, 64 and 72, and no reserved word. STATUS_FIELDS = [ @@ -474,6 +483,7 @@ def validate(document: object) -> None: for name, expected_fields in ( ("planWatermark", PLAN_WATERMARK_FIELDS), ("planLatency", PLAN_LATENCY_FIELDS), + ("seekReport", SEEK_REPORT_FIELDS), ("observationSelection", OBSERVATION_SELECTION_FIELDS), ("observationResult", OBSERVATION_RESULT_FIELDS), ): @@ -632,6 +642,17 @@ def drop_plan_latency_export(document: dict) -> None: def drop_plan_latency_field(document: dict) -> None: document["structures"]["planLatency"]["fields"].pop(2) + def drop_seek_export(export: str): + def drop(document: dict) -> None: + try: + document["exports"].remove(export) + except ValueError as error: + raise AssertionError(f"{export} exists in the valid fixture") from error + return drop + + def drop_seek_report_field(document: dict) -> None: + document["structures"]["seekReport"]["fields"].pop(3) + def drop_status_watermark_word(document: dict) -> None: document["structures"]["status"]["fields"].pop() @@ -716,6 +737,13 @@ def boolean_named_value(document: dict) -> None: ("the watermark record omits a field", drop_watermark_field), ("the plan latency record omits a field", drop_plan_latency_field), ("the plan latency read export is dropped", drop_plan_latency_export), + ("the seek report record omits a field", drop_seek_report_field), + ("the anchored seek export is dropped", + drop_seek_export("miso_engine_web_v1_source_seek_at")), + ("the seek report export is dropped", + drop_seek_export("miso_engine_web_v1_source_seek_report")), + ("the seek report pointer export is dropped", + drop_seek_export("miso_engine_web_v1_source_seek_report_ptr")), ("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-session-map-shape.py b/scripts/check-session-map-shape.py index f80050a53..2dd29e6ae 100644 --- a/scripts/check-session-map-shape.py +++ b/scripts/check-session-map-shape.py @@ -54,6 +54,7 @@ REPO = pathlib.Path(__file__).resolve().parent.parent RUST_FFI = pathlib.Path("hosts/host-web/src/ffi.rs") +SEEK_REPORT_RECORD_POINTER = "miso_engine_web_v1_source_seek_report_ptr" EXPORT_GATE = pathlib.Path("scripts/check-web-audioworklet.sh") WORKLET_JS = pathlib.Path("hosts/host-web/web/miso-engine-v1-audio-worklet.js") HOST_JS = pathlib.Path("hosts/host-web/web/miso-engine-v1-audio-worklet-host.js") @@ -139,6 +140,10 @@ def rust_introspection_exports(text: str) -> dict[str, str]: for name, params, width in re.findall( r'pub extern "C" fn (miso_engine_web_v1_source_[a-z_]+)\(([^)]*)\) -> (u32|u64)', text ): + if name == SEEK_REPORT_RECORD_POINTER: + # The seek report's record address (#1293 D6) takes only the handle, like a count + # query, but it is the control half's record pointer, not a source-map query. + continue signature = [part.strip() for part in params.split(",") if part.strip()] if signature in (["handle: u32"], ["handle: u32", "index: u32"]): found[name] = width diff --git a/scripts/check-web-audioworklet.sh b/scripts/check-web-audioworklet.sh index a5bb979e5..5fa795e2e 100755 --- a/scripts/check-web-audioworklet.sh +++ b/scripts/check-web-audioworklet.sh @@ -328,6 +328,9 @@ expected_exports=$(printf '%s\n' \ miso_engine_web_v1_source_frames \ miso_engine_web_v1_source_id \ miso_engine_web_v1_source_seek \ + miso_engine_web_v1_source_seek_at \ + miso_engine_web_v1_source_seek_report \ + miso_engine_web_v1_source_seek_report_ptr \ miso_engine_web_v1_source_submit \ miso_engine_web_v1_status_ptr \ miso_engine_web_v1_spectrum_analysis \ diff --git a/scripts/fixtures/abi-layout-v1-self-test.json b/scripts/fixtures/abi-layout-v1-self-test.json index c13164449..89596f547 100644 --- a/scripts/fixtures/abi-layout-v1-self-test.json +++ b/scripts/fixtures/abi-layout-v1-self-test.json @@ -59,6 +59,9 @@ "miso_engine_web_v1_source_frames", "miso_engine_web_v1_source_id", "miso_engine_web_v1_source_seek", + "miso_engine_web_v1_source_seek_at", + "miso_engine_web_v1_source_seek_report", + "miso_engine_web_v1_source_seek_report_ptr", "miso_engine_web_v1_source_submit", "miso_engine_web_v1_spectrum_analysis", "miso_engine_web_v1_spectrum_arm", @@ -237,6 +240,19 @@ { "name": "reserved", "offset": 32, "type": "u64[4]" } ] }, + "seekReport": { + "bytes": 64, + "fields": [ + { "name": "structSize", "offset": 0, "type": "u32" }, + { "name": "reserved0", "offset": 4, "type": "u32" }, + { "name": "generation", "offset": 8, "type": "u64" }, + { "name": "firstSample", "offset": 16, "type": "u64" }, + { "name": "sourceFrame", "offset": 24, "type": "u64" }, + { "name": "cumulativeUnderrunFrames", "offset": 32, "type": "u64" }, + { "name": "cumulativeHeldFrames", "offset": 40, "type": "u64" }, + { "name": "reserved", "offset": 48, "type": "u64[2]" } + ] + }, "observationSelection": { "bytes": 32, "fields": [ diff --git a/sdk/assets/miso-engine-v1-abi-layout.json b/sdk/assets/miso-engine-v1-abi-layout.json index c13164449..89596f547 100644 --- a/sdk/assets/miso-engine-v1-abi-layout.json +++ b/sdk/assets/miso-engine-v1-abi-layout.json @@ -59,6 +59,9 @@ "miso_engine_web_v1_source_frames", "miso_engine_web_v1_source_id", "miso_engine_web_v1_source_seek", + "miso_engine_web_v1_source_seek_at", + "miso_engine_web_v1_source_seek_report", + "miso_engine_web_v1_source_seek_report_ptr", "miso_engine_web_v1_source_submit", "miso_engine_web_v1_spectrum_analysis", "miso_engine_web_v1_spectrum_arm", @@ -237,6 +240,19 @@ { "name": "reserved", "offset": 32, "type": "u64[4]" } ] }, + "seekReport": { + "bytes": 64, + "fields": [ + { "name": "structSize", "offset": 0, "type": "u32" }, + { "name": "reserved0", "offset": 4, "type": "u32" }, + { "name": "generation", "offset": 8, "type": "u64" }, + { "name": "firstSample", "offset": 16, "type": "u64" }, + { "name": "sourceFrame", "offset": 24, "type": "u64" }, + { "name": "cumulativeUnderrunFrames", "offset": 32, "type": "u64" }, + { "name": "cumulativeHeldFrames", "offset": 40, "type": "u64" }, + { "name": "reserved", "offset": 48, "type": "u64[2]" } + ] + }, "observationSelection": { "bytes": 32, "fields": [ diff --git a/sdk/src/generated/abi.ts b/sdk/src/generated/abi.ts index 3b68aa71c..0e5d432b9 100644 --- a/sdk/src/generated/abi.ts +++ b/sdk/src/generated/abi.ts @@ -86,6 +86,9 @@ export const ABI_LAYOUT = deepFreeze( "miso_engine_web_v1_source_frames", "miso_engine_web_v1_source_id", "miso_engine_web_v1_source_seek", + "miso_engine_web_v1_source_seek_at", + "miso_engine_web_v1_source_seek_report", + "miso_engine_web_v1_source_seek_report_ptr", "miso_engine_web_v1_source_submit", "miso_engine_web_v1_spectrum_analysis", "miso_engine_web_v1_spectrum_arm", @@ -628,6 +631,51 @@ export const ABI_LAYOUT = deepFreeze( } ] }, + "seekReport": { + "bytes": 64, + "fields": [ + { + "name": "structSize", + "offset": 0, + "type": "u32" + }, + { + "name": "reserved0", + "offset": 4, + "type": "u32" + }, + { + "name": "generation", + "offset": 8, + "type": "u64" + }, + { + "name": "firstSample", + "offset": 16, + "type": "u64" + }, + { + "name": "sourceFrame", + "offset": 24, + "type": "u64" + }, + { + "name": "cumulativeUnderrunFrames", + "offset": 32, + "type": "u64" + }, + { + "name": "cumulativeHeldFrames", + "offset": 40, + "type": "u64" + }, + { + "name": "reserved", + "offset": 48, + "type": "u64[2]" + } + ] + }, "observationSelection": { "bytes": 32, "fields": [ diff --git a/tools/parameter-metadata/src/abi_layout.rs b/tools/parameter-metadata/src/abi_layout.rs index 4bbaf6b19..4a16741a5 100644 --- a/tools/parameter-metadata/src/abi_layout.rs +++ b/tools/parameter-metadata/src/abi_layout.rs @@ -75,21 +75,22 @@ use host_web::{ RESULT_INTERNAL, RESULT_INVALID_ARGUMENT, RESULT_OK, RESULT_REFUSED_BUDGET, RESULT_REFUSED_DOCUMENT, RESULT_REFUSED_LIFECYCLE, RESULT_REFUSED_OPTIONS, RESULT_RENDER_REJECTED, RESULT_REPREPARE_REQUIRED, RESULT_UNSUPPORTED, RESULT_WRONG_STATE, - SOURCE_STALL_TOLERANCE_MS, SPECTRUM_BIN_COUNT, SPECTRUM_CAPTURE_BYTES, SPECTRUM_CHANNEL_BOTH, - SPECTRUM_CHANNEL_LEFT, SPECTRUM_CHANNEL_RIGHT, SPECTRUM_COLLECTION_ENTRY_BYTES, - SPECTRUM_COLLECTION_ENTRY_CAPACITY, SPECTRUM_COLLECTION_REQUEST_BYTES, - SPECTRUM_COLLECTION_TARGET_IDS_BYTES, SPECTRUM_MAXIMUM_ID_BYTES, - SPECTRUM_MAXIMUM_PREPARED_TARGETS, SPECTRUM_REQUEST_BYTES, SPECTRUM_RESULT_HEADER_BYTES, - SPECTRUM_STREAM_METADATA_BYTES, SPECTRUM_STREAM_STATUS_FAILED, SPECTRUM_STREAM_STATUS_GAP, - SPECTRUM_STREAM_STATUS_INACTIVE, SPECTRUM_STREAM_STATUS_PENDING, SPECTRUM_STREAM_STATUS_READY, - SPECTRUM_STREAM_STATUS_STOPPED, SPECTRUM_STREAM_STATUS_WARMING, SPECTRUM_TARGET_OUTPUT, - SPECTRUM_TARGET_TRACK_POST_INPUT, SPECTRUM_TARGET_TRACK_POST_PAN, SPECTRUM_WINDOW_FRAMES, - SPECTRUM_WINDOW_HEADER_BYTES, STATE_DISPOSED, STATE_FAILED, STATE_READY, STATUS_BYTES, - WATERMARK_BYTES, WebBootOptions, WebCommandReport, WebLiveResponseOwner, - WebLiveResponseRequest, WebLiveResponseResult, WebLiveResponseSection, WebMeterHeader, - WebObservationResult, WebObservationSelection, WebPlanLatency, WebPlanWatermark, - WebResourceReport, WebSpectrumCollectionEntry, WebSpectrumCollectionRequest, - WebSpectrumRequest, WebSpectrumResult, WebSpectrumStreamMetadata, WebSpectrumWindow, WebStatus, + SEEK_REPORT_BYTES, SOURCE_STALL_TOLERANCE_MS, SPECTRUM_BIN_COUNT, SPECTRUM_CAPTURE_BYTES, + SPECTRUM_CHANNEL_BOTH, SPECTRUM_CHANNEL_LEFT, SPECTRUM_CHANNEL_RIGHT, + SPECTRUM_COLLECTION_ENTRY_BYTES, SPECTRUM_COLLECTION_ENTRY_CAPACITY, + SPECTRUM_COLLECTION_REQUEST_BYTES, SPECTRUM_COLLECTION_TARGET_IDS_BYTES, + SPECTRUM_MAXIMUM_ID_BYTES, SPECTRUM_MAXIMUM_PREPARED_TARGETS, SPECTRUM_REQUEST_BYTES, + SPECTRUM_RESULT_HEADER_BYTES, SPECTRUM_STREAM_METADATA_BYTES, SPECTRUM_STREAM_STATUS_FAILED, + SPECTRUM_STREAM_STATUS_GAP, SPECTRUM_STREAM_STATUS_INACTIVE, SPECTRUM_STREAM_STATUS_PENDING, + SPECTRUM_STREAM_STATUS_READY, SPECTRUM_STREAM_STATUS_STOPPED, SPECTRUM_STREAM_STATUS_WARMING, + SPECTRUM_TARGET_OUTPUT, SPECTRUM_TARGET_TRACK_POST_INPUT, SPECTRUM_TARGET_TRACK_POST_PAN, + SPECTRUM_WINDOW_FRAMES, SPECTRUM_WINDOW_HEADER_BYTES, STATE_DISPOSED, STATE_FAILED, + STATE_READY, STATUS_BYTES, WATERMARK_BYTES, WebBootOptions, WebCommandReport, + WebLiveResponseOwner, WebLiveResponseRequest, WebLiveResponseResult, WebLiveResponseSection, + WebMeterHeader, WebObservationResult, WebObservationSelection, WebPlanLatency, + WebPlanWatermark, WebResourceReport, WebSeekReport, WebSpectrumCollectionEntry, + WebSpectrumCollectionRequest, WebSpectrumRequest, WebSpectrumResult, WebSpectrumStreamMetadata, + WebSpectrumWindow, WebStatus, }; /// The emitted file name, shipped beside the Wasm artifact and the parameter metadata. @@ -123,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; 105] = [ +pub const EXPORTS: [&str; 108] = [ "miso_engine_web_v1_abi_version", "miso_engine_web_v1_boot", "miso_engine_web_v1_boot_diagnostic_bytes", @@ -179,6 +180,9 @@ pub const EXPORTS: [&str; 105] = [ "miso_engine_web_v1_source_frames", "miso_engine_web_v1_source_id", "miso_engine_web_v1_source_seek", + "miso_engine_web_v1_source_seek_at", + "miso_engine_web_v1_source_seek_report", + "miso_engine_web_v1_source_seek_report_ptr", "miso_engine_web_v1_source_submit", "miso_engine_web_v1_spectrum_analysis", "miso_engine_web_v1_spectrum_arm", @@ -580,6 +584,36 @@ fn plan_latency_fields() -> [Field; 6] { ] } +/// The source seek report record (issue #1293 D6): the C ABI seek report's layout. +fn seek_report_fields() -> [Field; 8] { + [ + ("structSize", offset_of!(WebSeekReport, struct_size), "u32"), + ("reserved0", offset_of!(WebSeekReport, reserved0), "u32"), + ("generation", offset_of!(WebSeekReport, generation), "u64"), + ( + "firstSample", + offset_of!(WebSeekReport, first_sample), + "u64", + ), + ( + "sourceFrame", + offset_of!(WebSeekReport, source_frame), + "u64", + ), + ( + "cumulativeUnderrunFrames", + offset_of!(WebSeekReport, cumulative_underrun_frames), + "u64", + ), + ( + "cumulativeHeldFrames", + offset_of!(WebSeekReport, cumulative_held_frames), + "u64", + ), + ("reserved", offset_of!(WebSeekReport, reserved), "u64[2]"), + ] +} + fn command_report_fields() -> [Field; 8] { [ ( @@ -1454,6 +1488,13 @@ pub fn render() -> String { &plan_latency_fields(), true, ); + render_structure( + &mut out, + "seekReport", + SEEK_REPORT_BYTES, + &seek_report_fields(), + true, + ); render_structure( &mut out, "observationSelection", From 3f53c07550b0314a5f2df1febcfb71402103d582 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 12:43:43 +0000 Subject: [PATCH 4/8] Build the timeline consumer and producer in the source crate (#1640) D1 factors the seek rules once: a private SeekState (active generation, held seek, one observe for the Now, At and Hold clocks) shared by the PCM source consumer and the timeline consumer, and a private SeekProducer (check_seek, try_seek) shared by the PCM producer and the timeline producer. PcmSourceProducer and HostChunkProvider gain check_seek. D2-D5: TimelineConsumer (begin_block_at, timeline_at, timeline_block, copy_history_from), TimelineProducer, TIMELINE_SAMPLE_LIMIT = 2^53 with SourceSeekError::FrameOutOfRange (source.seek.frame_out_of_range), and TimelineResourceReport. No plan holds the timeline yet (#1641); no rendered bit moves. Realtime-policy region floor 151 -> 153 for the two new marked regions. Co-Authored-By: Claude Opus 5.5 --- ...uild-the-timeline-consumer-and-producer.md | 81 ++ crates/host-core/src/source.rs | 1 + crates/host-core/tests/source_diagnostics.rs | 7 + crates/source/src/lib.rs | 1117 +++++++++++++++-- crates/source/tests/randomized.rs | 3 + tools/realtime-policy/src/policy.rs | 6 +- 6 files changed, 1092 insertions(+), 123 deletions(-) diff --git a/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md index 5b5295f37..451df9e86 100644 --- a/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md +++ b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md @@ -219,3 +219,84 @@ Checked against `35ac17545`. (the new row is index 20). No other crate matches on `SourceSeekError`. - **No `crates/engine/src/realtime/` edit.** The SPSC and seqlock types are used as they are. - **Decision 17.** No change. C13 (ruling 2026-10-15) calls seeks and the seek report transport. + +## Decision record: #1640 (batch 16) + +Built on `eab696d09` (worktree `codex/controls-batch-16a`). + +**What shipped** (`crates/source/src/lib.rs`): + +- **D1.** `SeekState` (owns `active_generation` and `held_seek`) with one `observe(command, + clock)` over all three `SeekClock` cases, plus the shared reads `holds_anchor_before`, + `holds_generation` and `is_unobserved`. The PCM consumer calls `observe` at both of its points + (`begin_block_observe` and the acquire's re-observe, both through + `observe_seek_at_block_boundary`) and runs only source work on the result: the stale pending + block discard on a newly held seek, and `apply_seek`. `prime_ready`, `settle_block` and + `is_unobserved_generation` read the held seek through the shared methods, so B16-R3's readiness + rule exists once for #1641. `SeekProducer` (command producer, generation, quantum, optional frame + limit) has `check_seek` and `try_seek`. `PcmSourceProducer::try_seek` is the shared `try_seek` + plus its PCM-only work (`next_write_frame`, `end_of_region_submitted`). + `PcmSourceProducer::check_seek` and `HostChunkProvider::check_seek` forward to the shared check. +- **D2-D3.** `TimelineConsumer` (`begin_block_at`, `timeline_at`, `timeline_block`, + `copy_history_from`, `position`, `active_generation`, `history_capacity`), with history entries + `(u64, u64)` in a fixed `K`-entry ring. `SourceTimeline::prepare(quantum, K)` builds it at + generation 1, position 0. `TimelineSpan { start, step: Option }`. +- **D4.** `TimelineProducer` (`try_seek`, `check_seek`, `active_generation`), + `TIMELINE_SAMPLE_LIMIT = 1 << 53`, `SourceSeekError::FrameOutOfRange`. +- **D5.** `TimelineResourceReport`: `history_blocks`, `command_queue_bytes` (the ring formula), + `history_bytes` (`16·K`), `consumer_bytes` (`size_of::()`), `overhead_bytes` + (the sum of the three), `largest_allocation_bytes`. No seek-report row (B16-R4). +- **Host-core.** The `FrameOutOfRange` arm, `source.seek.frame_out_of_range`, and its row 20 in + `crates/host-core/tests/source_diagnostics.rs`. + +**Choices the text left open:** + +- `check_seek` reports a full slot as `Backpressure` with the current `full_count`, through + `available_capacity`. It does not count a refusal. `try_seek` still gets backpressure from its own + push, so the PCM producer's full count moves exactly as before. +- The frame limit applies to the timeline only (`frame_limit: None` for a PCM source). The rule is + written once in the shared check. +- A seek in the history's oldest entry is still a step: the consumer keeps one `u64`, + `oldest_expected_timeline`, which is the evicted entry's timeline plus `q`, or the starting + position. `copy_history_from` carries it. +- `timeline_at` with no entry treats the position as standing at source-read sample 0. A sample in + a gap or after the newest entry extrapolates forward from the newest entry that starts at or + before it. A step is reported only when its entry is the one that resolves its own first sample + (newest first), so the step and `timeline_at` always agree. +- `timeline_block(first, frames)` takes the node block's length as `frames`. + +**Departures:** + +- Gate 2 "unchanged": the assertions are unchanged. Two of the four named tests and six others + read the moved fields, so they now read `consumer.seek.active_generation` and + `consumer.seek.held_seek` (a path rewrite only). `crates/source/tests/seek_schedule_model.rs` is + textually unchanged. `crates/source/tests/randomized.rs` gains one `unreachable!` arm for the new + variant (exhaustive match). +- `tools/realtime-policy/src/policy.rs`: `marked_regions_floor` 151 -> 153, for the two new regions + (mechanical, outside the authorized paths). + +**For #1641:** `consumer_bytes` is the inline value. If #1641 stores the consumer inside an +allocation it already counts (the driver), it must not charge that row twice. + +**Gates:** + +- Gate 1, `cargo test --locked -p source`, `tests::timeline::*` (11 new): + `a_fresh_timeline_starts_at_zero_and_advances_a_quantum_every_block`, + `a_plain_seek_reads_its_frame_at_the_next_blocks_first_sample`, + `an_anchored_seek_is_held_until_its_anchor_and_applies_with_its_lateness`, + `a_newer_seek_plain_or_anchored_replaces_a_held_one`, + `the_producer_refuses_without_changing_anything`, + `the_history_reads_both_sides_of_a_seek_and_extrapolates_below_its_oldest_entry`, + `a_node_block_reports_the_step_of_a_seek_inside_it_or_at_its_first_sample`, + `a_seek_in_the_oldest_entry_is_still_a_step`, + `copying_a_history_keeps_its_newest_entries_in_order`, + `the_resource_report_states_each_row` and + `a_source_check_seek_agrees_with_its_try_seek_and_pushes_nothing`. PASS. +- Gate 2: all 68 lib tests (the four named seek tests among them), `seek_schedule_model`, + `randomized` and `prime`. PASS. +- Gate 3: no render arithmetic changed. The source tests, plus host-core `successor_swap` (14) + and `prepare` (139), PASS. +- Commands, all PASS: `realtime_policy` (153 regions in 33 files), `check-cross-targets.sh`, + `check-workspace-policy.sh`, `cargo test -p realtime-policy`, `source_diagnostics` (2), + clippy `-p source -p host-core --all-targets --all-features -D warnings`, and + `cargo fmt --all -- --check`. The workspace-wide clippy was not run (worker rules). diff --git a/crates/host-core/src/source.rs b/crates/host-core/src/source.rs index a93666584..d8acdd652 100644 --- a/crates/host-core/src/source.rs +++ b/crates/host-core/src/source.rs @@ -108,6 +108,7 @@ impl SourceControlError { Self::Vacated => "source.ring.vacated", Self::Seek(SourceSeekError::AnchorUnaligned) => "source.seek.anchor_unaligned", Self::ReportBusy => "source.report.busy", + Self::Seek(SourceSeekError::FrameOutOfRange) => "source.seek.frame_out_of_range", } } diff --git a/crates/host-core/tests/source_diagnostics.rs b/crates/host-core/tests/source_diagnostics.rs index 744a54689..4a7771a2d 100644 --- a/crates/host-core/tests/source_diagnostics.rs +++ b/crates/host-core/tests/source_diagnostics.rs @@ -166,6 +166,12 @@ const TABLE: &[(SourceControlError, &str, bool, bool)] = &[ true, false, ), + ( + SourceControlError::Seek(SourceSeekError::FrameOutOfRange), + "source.seek.frame_out_of_range", + false, + false, + ), ]; /// The two codes a host must see from more than one variant, and why. Anything else sharing a @@ -201,6 +207,7 @@ const fn variant_index(error: SourceControlError) -> usize { SourceControlError::Vacated => 17, SourceControlError::Seek(SourceSeekError::AnchorUnaligned) => 18, SourceControlError::ReportBusy => 19, + SourceControlError::Seek(SourceSeekError::FrameOutOfRange) => 20, } } diff --git a/crates/source/src/lib.rs b/crates/source/src/lib.rs index b8b82fa9d..0960d91ba 100644 --- a/crates/source/src/lib.rs +++ b/crates/source/src/lib.rs @@ -403,6 +403,9 @@ pub enum SourceSeekError { /// A [`SourceCommand::SeekAt`] anchor that is not a multiple of the render quantum: no block /// starts there. AnchorUnaligned, + /// A timeline seek to a sample above [`TIMELINE_SAMPLE_LIMIT`] (#1640 D4): past it a + /// timeline value would stop being an exact `i64` for every reader. + FrameOutOfRange, } /// Failure to copy one prepared source quantum into caller-owned source planes. @@ -695,7 +698,7 @@ impl PcmSourceRing { channel_count: config.channel_count, quantum_frames: config.quantum_frames.0, transfer_block_count: shape.transfer_block_count.get(), - active_generation: config.initial_generation, + seek: SeekState::new(config.initial_generation), next_frame: initial_frame, end_frame: None, end_of_region: false, @@ -712,7 +715,6 @@ impl PcmSourceRing { held_blocks: 0, native_decoder_sanitized_samples: 0, generation_changed: false, - held_seek: None, begun_block: false, seek_report: seek_report_writer, published_seek_report: SourceSeekReport::default(), @@ -732,11 +734,15 @@ impl PcmSourceRing { PcmSourceProducer { data_producer, recycle_consumer, - command_producer, + seek: SeekProducer { + command_producer, + active_generation: config.initial_generation, + quantum_frames: config.quantum_frames.0, + frame_limit: None, + }, shape: consumer.shape, channel_count: config.channel_count, quantum_frames: config.quantum_frames.0, - active_generation: config.initial_generation, next_write_frame: initial_frame, end_of_region_submitted: false, cumulative_written_frames: 0, @@ -819,11 +825,12 @@ impl PreparedShape { pub struct PcmSourceProducer { data_producer: Producer>, recycle_consumer: Consumer>, - command_producer: Producer, + /// The command producer and the generation in effect: the seek producer every + /// source-transport producer shares (#1640 D1). + seek: SeekProducer, shape: PcmSourceShape, channel_count: u32, quantum_frames: u32, - active_generation: SourceGeneration, next_write_frame: SourceFrame, end_of_region_submitted: bool, cumulative_written_frames: u64, @@ -886,37 +893,20 @@ 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> { - let (generation, frame, anchor_sample) = match command { - SourceCommand::Seek { generation, frame } => (generation, frame, None), - SourceCommand::SeekAt { - generation, - frame, - anchor_sample, - } => (generation, frame, Some(anchor_sample)), - }; - if !generation.is_valid() { - return Err(SourceSeekError::GenerationZero); - } - if generation <= self.active_generation { - return Err(SourceSeekError::GenerationNotStrictlyIncreasing { - active: self.active_generation, - requested: generation, - }); - } - if anchor_sample - .is_some_and(|anchor| !anchor.is_multiple_of(u64::from(self.quantum_frames))) - { - return Err(SourceSeekError::AnchorUnaligned); - } - match self.command_producer.try_push(command) { - Ok(()) => { - self.active_generation = generation; - self.next_write_frame = frame; - self.end_of_region_submitted = false; - Ok(()) - } - Err(QueueFull { full_count, .. }) => Err(SourceSeekError::Backpressure { full_count }), - } + self.seek.try_seek(command)?; + let (SourceCommand::Seek { frame, .. } | SourceCommand::SeekAt { frame, .. }) = command; + self.next_write_frame = frame; + self.end_of_region_submitted = false; + Ok(()) + } + + /// Whether [`Self::try_seek`] would accept `command` now, pushing nothing and changing + /// nothing: every rule of [`Self::try_seek`], with a full command slot reported as the + /// backpressure its push would return (#1640 D1). An all-or-nothing caller checks every + /// producer before it pushes to any. Only this producer pushes to its slot and the consumer + /// only empties it, so an accepted check stays accepted until this producer pushes. + pub fn check_seek(&self, command: SourceCommand) -> Result<(), SourceSeekError> { + self.seek.check_seek(command) } /// The ring's seek report as render last published it (#1316 D2): bounded retries, then @@ -938,7 +928,7 @@ impl PcmSourceProducer { #[must_use] pub fn telemetry(&self) -> SourceProducerTelemetry { SourceProducerTelemetry { - active_generation: self.active_generation, + active_generation: self.seek.active_generation, cumulative_written_frames: self.cumulative_written_frames, data_full_count: self.data_producer.full_count(), recycle_empty_count: self.recycle_consumer.empty_count(), @@ -1004,7 +994,7 @@ impl PcmSourceProducer { Ok(SubmitReport { accepted_frames: frames, cumulative_written_frames: self.cumulative_written_frames, - active_generation: self.active_generation, + active_generation: self.seek.active_generation, }) } Err(QueueFull { @@ -1040,6 +1030,11 @@ impl HostChunkProvider { self.producer.try_seek(command) } + /// Check a seek request without pushing it; see [`PcmSourceProducer::check_seek`]. + pub fn check_seek(&self, command: SourceCommand) -> Result<(), SourceSeekError> { + self.producer.check_seek(command) + } + /// Copy producer telemetry outside render. #[must_use] pub fn telemetry(&self) -> SourceProducerTelemetry { @@ -1090,9 +1085,9 @@ fn validate_submission_metadata( end_of_region: bool, channel_count: u32, ) -> Result<(), HostChunkError> { - if generation != producer.active_generation { + if generation != producer.seek.active_generation { return Err(HostChunkError::StaleGeneration { - active: producer.active_generation, + active: producer.seek.active_generation, submitted: generation, }); } @@ -1159,7 +1154,9 @@ pub struct PcmSourceConsumer { channel_count: u32, quantum_frames: u32, transfer_block_count: usize, - active_generation: SourceGeneration, + /// The generation in effect and the held anchored seek: the seek state every source-transport + /// consumer shares (#1640 D1). + seek: SeekState, next_frame: SourceFrame, end_frame: Option, end_of_region: bool, @@ -1177,8 +1174,6 @@ pub struct PcmSourceConsumer { held_blocks: u64, native_decoder_sanitized_samples: u64, generation_changed: bool, - /// An observed [`SourceCommand::SeekAt`] whose anchor block has not begun yet (issue #1274). - held_seek: Option, /// Whether any block has begun on this consumer (#1344 D5): render-owned, set at block entry /// and never cleared. begun_block: bool, @@ -1219,6 +1214,209 @@ enum SeekClock { Hold, } +/// The seek state machine every source-transport consumer shares (#1640 D1): the +/// [`PcmSourceConsumer`] and the [`TimelineConsumer`] each own one, so no seek rule is written +/// twice. It owns the generation in effect and the held anchored seek; [`Self::observe`] takes one +/// popped command against a [`SeekClock`] and says what to do now. Render-owned, `Copy`, with no +/// allocation. +#[derive(Clone, Copy, Debug)] +struct SeekState { + /// The generation in effect. + active_generation: SourceGeneration, + /// An observed [`SourceCommand::SeekAt`] whose anchor block has not begun yet (issue #1274). + held_seek: Option, +} + +/// What one [`SeekState::observe`] decided. +#[derive(Clone, Copy, Debug)] +struct SeekObservation { + /// An anchored seek this observation started to hold, replacing any held before it. + newly_held: Option, + /// The seek to apply in this block, lateness included. The seek state has already made its + /// generation the one in effect. + apply: Option, +} + +/// An anchored seek [`SeekState::observe`] started to hold. +#[derive(Clone, Copy, Debug)] +struct NewlyHeldSeek { + /// The generation in effect when the command was popped, before any seek of this observation + /// applied. + active: SourceGeneration, + /// The held seek's generation. + generation: SourceGeneration, +} + +/// A seek to apply now: its generation and the frame the block starts at. +#[derive(Clone, Copy, Debug)] +struct AppliedSeek { + generation: SourceGeneration, + frame: SourceFrame, +} + +impl SeekState { + const fn new(initial_generation: SourceGeneration) -> Self { + Self { + active_generation: initial_generation, + held_seek: None, + } + } + // REALTIME_POLICY_BEGIN + /// Observe one popped command (or none) at a block boundary, then apply a held anchored seek + /// whose block has come. A plain seek clears any held seek and applies at once; an anchored + /// seek replaces the held one; a held seek applies in the first block whose + /// [`SeekClock::At`] sample is at or past its anchor, at `frame + lateness` (saturating), at + /// once on [`SeekClock::Now`], and never on [`SeekClock::Hold`]. Allocation-free and + /// wait-free. + fn observe(&mut self, command: Option, clock: SeekClock) -> SeekObservation { + let mut newly_held = None; + match command { + Some(SourceCommand::Seek { generation, frame }) => { + self.held_seek = None; + self.active_generation = generation; + return SeekObservation { + newly_held, + apply: Some(AppliedSeek { generation, frame }), + }; + } + Some(SourceCommand::SeekAt { + generation, + frame, + anchor_sample, + }) => { + newly_held = Some(NewlyHeldSeek { + active: self.active_generation, + generation, + }); + self.held_seek = Some(HeldSeek { + generation, + frame, + anchor_sample, + }); + } + None => {} + } + let unapplied = SeekObservation { + newly_held, + apply: None, + }; + let Some(held) = self.held_seek else { + return unapplied; + }; + let late_by = match clock { + SeekClock::Now => 0, + SeekClock::At { source_read_sample } if source_read_sample >= held.anchor_sample => { + source_read_sample - held.anchor_sample + } + SeekClock::At { .. } | SeekClock::Hold => return unapplied, + }; + self.held_seek = None; + self.active_generation = held.generation; + SeekObservation { + newly_held, + apply: Some(AppliedSeek { + generation: held.generation, + frame: SourceFrame(held.frame.0.saturating_add(late_by)), + }), + } + } + + /// Whether a held anchored seek is anchored before `sample`: it would apply in a block that + /// starts before `sample`, a late one included. + fn holds_anchor_before(&self, sample: u64) -> bool { + self.held_seek + .is_some_and(|held| held.anchor_sample < sample) + } + + /// Whether the held anchored seek, if any, is of `generation`. + fn holds_generation(&self, generation: SourceGeneration) -> bool { + self.held_seek + .is_some_and(|held| held.generation == generation) + } + + /// Whether `generation` is newer than every seek this state has observed. + fn is_unobserved(&self, generation: SourceGeneration) -> bool { + generation > self.active_generation + && self + .held_seek + .is_none_or(|held| generation > held.generation) + } + // REALTIME_POLICY_END +} + +/// The seek producer every source-transport producer shares (#1640 D1): the +/// [`PcmSourceProducer`] and the [`TimelineProducer`] each own one, so no seek admission rule is +/// written twice. It owns the one-slot command producer and the generation in effect. +struct SeekProducer { + command_producer: Producer, + /// The newest generation this producer accepted. + active_generation: SourceGeneration, + /// The render quantum an anchor must be a multiple of. + quantum_frames: u32, + /// The largest frame a seek may name, if any: [`TIMELINE_SAMPLE_LIMIT`] for a timeline, + /// `None` for a PCM source. + frame_limit: Option, +} + +impl SeekProducer { + /// Every admission rule but the slot: a nonzero, strictly newer generation, an anchor that is + /// a quantum multiple and a frame within the limit. Returns the command's generation. + fn check_rules(&self, command: SourceCommand) -> Result { + let (generation, frame, anchor_sample) = match command { + SourceCommand::Seek { generation, frame } => (generation, frame, None), + SourceCommand::SeekAt { + generation, + frame, + anchor_sample, + } => (generation, frame, Some(anchor_sample)), + }; + if !generation.is_valid() { + return Err(SourceSeekError::GenerationZero); + } + if generation <= self.active_generation { + return Err(SourceSeekError::GenerationNotStrictlyIncreasing { + active: self.active_generation, + requested: generation, + }); + } + if anchor_sample + .is_some_and(|anchor| !anchor.is_multiple_of(u64::from(self.quantum_frames))) + { + return Err(SourceSeekError::AnchorUnaligned); + } + if self.frame_limit.is_some_and(|limit| frame.0 > limit) { + return Err(SourceSeekError::FrameOutOfRange); + } + Ok(generation) + } + + /// Every rule of [`Self::try_seek`] with nothing pushed or changed. A full slot is the + /// [`SourceSeekError::Backpressure`] the push would return, carrying the current full count + /// (a check counts no refusal). + fn check_seek(&self, command: SourceCommand) -> Result<(), SourceSeekError> { + self.check_rules(command)?; + if self.command_producer.available_capacity() == 0 { + return Err(SourceSeekError::Backpressure { + full_count: self.command_producer.full_count(), + }); + } + 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> { + let generation = self.check_rules(command)?; + match self.command_producer.try_push(command) { + Ok(()) => { + self.active_generation = generation; + Ok(()) + } + Err(QueueFull { full_count, .. }) => Err(SourceSeekError::Backpressure { full_count }), + } + } +} + /// Whether an acquire may observe the admitted command when it meets a block of an unobserved /// generation (#1320 D3). #[derive(Clone, Copy, Eq, PartialEq)] @@ -1246,8 +1444,8 @@ impl PcmSourceConsumer { self.observe_seek_at_block_boundary(SeekClock::Hold); // The held-seek test is not redundant: a caller may prepare the playing generation again // while a newer anchored seek is held, and that is not ready. - if self.held_seek.is_some() - || self.active_generation != generation + if self.seek.held_seek.is_some() + || self.seek.active_generation != generation || self.next_frame != frame { return false; @@ -1256,7 +1454,7 @@ impl PcmSourceConsumer { // A producer running concurrently may push a newer seek and its block between the command // pop above and the data pop in the acquire; the acquire's re-observe takes that seek. // Readiness is granted for `generation` alone, so check again. - self.held_seek.is_none() && self.active_generation == generation + self.seek.held_seek.is_none() && self.seek.active_generation == generation } /// Immutable prepared ring shape shared with the producer endpoint. @@ -1382,7 +1580,7 @@ impl PcmSourceConsumer { ); // After the acquire step, which can set or apply a held seek (`acquire_current_block`): // missing PCM while an anchored seek is still held is a planned wait, not starvation. - if self.held_seek.is_some() { + if self.seek.held_seek.is_some() { held_frames = missing_frames; if held_frames != 0 { self.held_frames = self.held_frames.saturating_add(u64::from(held_frames)); @@ -1409,7 +1607,7 @@ impl PcmSourceConsumer { held_frames, held: held_frames != 0, end_of_region: self.end_of_region, - active_generation: self.active_generation, + active_generation: self.seek.active_generation, generation_changed: self.generation_changed, cumulative_read_frames: self.cumulative_read_frames, cumulative_underrun_frames: self.underrun_frames, @@ -1536,7 +1734,7 @@ impl PcmSourceConsumer { #[must_use] pub fn telemetry(&self) -> SourceConsumerTelemetry { SourceConsumerTelemetry { - active_generation: self.active_generation, + active_generation: self.seek.active_generation, cumulative_read_frames: self.cumulative_read_frames, stale_generation_discard_count: self.stale_generation_discard_count, underrun_frames: self.underrun_frames, @@ -1574,8 +1772,8 @@ impl PcmSourceConsumer { /// alone. Otherwise nothing is written. Wait-free, allocation-free. fn publish_seek_report(&mut self, source_read_sample: u64, source_frame: SourceFrame) { let mut report = self.published_seek_report; - if report.generation != self.active_generation.0 { - report.generation = self.active_generation.0; + if report.generation != self.seek.active_generation.0 { + report.generation = self.seek.active_generation.0; report.first_sample = source_read_sample; report.source_frame = source_frame.0; } @@ -1588,56 +1786,32 @@ impl PcmSourceConsumer { } /// Pop the admitted command, if any, then apply a held anchored seek whose block has come. + /// + /// 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) { - match self.command_consumer.try_pop() { - Ok(SourceCommand::Seek { generation, frame }) => { - self.held_seek = None; - self.apply_seek(generation, frame); - } - Ok(SourceCommand::SeekAt { - generation, - frame, - anchor_sample, - }) => { - // A newer command replaces a held one; the replaced seek's pending block is stale. - self.held_seek = Some(HeldSeek { - generation, - frame, - anchor_sample, - }); - let active = self.active_generation; - if let Some(block) = self - .current - .take_if(|block| block.generation != active && block.generation != generation) - { - self.discard_block(block); - } + let command = self.command_consumer.try_pop().ok(); + 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. + if let Some(block) = self.current.take_if(|block| { + block.generation != held.active && block.generation != held.generation + }) { + self.discard_block(block); } - Err(_) => {} } - let Some(held) = self.held_seek else { - return; - }; - let late_by = match clock { - SeekClock::Now => 0, - SeekClock::At { source_read_sample } if source_read_sample >= held.anchor_sample => { - source_read_sample - held.anchor_sample - } - SeekClock::At { .. } | SeekClock::Hold => return, - }; - self.held_seek = None; - self.apply_seek( - held.generation, - SourceFrame(held.frame.0.saturating_add(late_by)), - ); + if let Some(seek) = observation.apply { + self.apply_seek(seek.generation, seek.frame); + } } // REALTIME_POLICY_END - /// Make `generation` audible from `frame`. A pending `current` block of that generation that - /// does not start behind `frame` is kept -- an anchored seek's primed block -- and any other is - /// discarded, noting the region end it carries. + /// Make `generation` audible from `frame`; the seek state has already made it the generation + /// in effect. A pending `current` block of that generation that does not start behind `frame` + /// is kept -- an anchored seek's primed block -- and any other is discarded, noting the region + /// end it carries. fn apply_seek(&mut self, generation: SourceGeneration, frame: SourceFrame) { self.generation_changed = true; - self.active_generation = generation; self.next_frame = frame; self.end_frame = None; self.end_of_region = false; @@ -1687,10 +1861,7 @@ impl PcmSourceConsumer { }; // A held anchor is always past the last block's sample, so this is every held seek that // would apply at one of the blocks, a late one at `first_sample` included. - if self - .held_seek - .is_some_and(|held| held.anchor_sample < window_end) - { + if self.seek.holds_anchor_before(window_end) { return false; } let mut frame = self.next_frame.0; @@ -1711,7 +1882,7 @@ impl PcmSourceConsumer { queued += 1; &**block }; - if block.generation != self.active_generation + if block.generation != self.seek.active_generation || block.start_frame.0 != frame || (block.frames < self.quantum_frames && !block.end_of_region) { @@ -1838,17 +2009,16 @@ impl PcmSourceConsumer { /// Keep `block` as the pending block (`true`) when it is the active generation at or after /// `next_frame`, or a held seek's first block; otherwise discard it (`false`). Pops nothing. fn settle_block(&mut self, block: Box) -> bool { - if block.generation == self.active_generation && block.start_frame.0 >= self.next_frame.0 { + if block.generation == self.seek.active_generation + && block.start_frame.0 >= self.next_frame.0 + { if block.end_of_region { self.note_end_frame(&block); } self.current = Some(block); return true; } - if self - .held_seek - .is_some_and(|held| held.generation == block.generation) - { + if self.seek.holds_generation(block.generation) { // A held anchored seek's first block: kept unexamined as the pending block until // the seek applies (its end, if it carries one, is noted then), and the blocks // behind it stay queued. @@ -1868,20 +2038,17 @@ impl PcmSourceConsumer { /// Whether `generation` is newer than every seek this consumer has observed. fn is_unobserved_generation(&self, generation: SourceGeneration) -> bool { - generation > self.active_generation - && self - .held_seek - .is_none_or(|held| generation > held.generation) + self.seek.is_unobserved(generation) } fn current_matches_next_frame(&self) -> bool { self.current.as_ref().is_some_and(|block| { - block.generation == self.active_generation && block.start_frame == self.next_frame + block.generation == self.seek.active_generation && block.start_frame == self.next_frame }) } fn note_end_and_discard(&mut self, block: Box) { - if block.end_of_region && block.generation == self.active_generation { + if block.end_of_region && block.generation == self.seek.active_generation { self.note_end_frame(&block); } self.discard_block(block); @@ -1942,6 +2109,377 @@ impl PcmSourceConsumer { } } +/// The largest timeline sample a seek may name (#1640 D4): `2^53`, about 2,970 years at 96 kHz. +/// It leaves `2^63 - 2^53` samples of headroom, so every timeline value and every node time +/// derived from it stays an exact `i64` for any run a host can render. +pub const TIMELINE_SAMPLE_LIMIT: u64 = 1 << 53; + +/// Exact fixed bytes of one prepared timeline (#1640 D5), row by row. #1641 adds them to the +/// source set's overhead. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TimelineResourceReport { + /// The history's entries, `K`. + pub history_blocks: u64, + /// One-slot seek-command SPSC header plus slot: a ring's `command_queue_bytes` formula. + pub command_queue_bytes: u64, + /// The history allocation: `16 * K` bytes. + pub history_bytes: u64, + /// The [`TimelineConsumer`] value itself, wherever its owner stores it. + pub consumer_bytes: u64, + /// The sum of the three byte rows above. + pub overhead_bytes: u64, + /// The largest of the three byte rows above. + pub largest_allocation_bytes: u64, +} + +/// Prepared timeline constructor: the session's playhead as a source consumer with no PCM and no +/// ring (#1640). +pub struct SourceTimeline; + +impl SourceTimeline { + /// Compute the exact timeline bytes without creating anything. + pub fn resource_report( + quantum_frames: QuantumFrames, + history_blocks: NonZeroUsize, + ) -> Result { + if quantum_frames.0 == 0 { + return Err(PcmSourceRingError::ZeroQuantumFrames); + } + let command_queue = queue_bytes::(NonZeroUsize::MIN)?; + let history = u64::try_from( + Layout::array::(history_blocks.get()) + .map_err(|_| PcmSourceRingError::ArithmeticOverflow)? + .size(), + ) + .map_err(|_| PcmSourceRingError::PlatformSizeLimit)?; + let consumer = u64::try_from(size_of::()) + .map_err(|_| PcmSourceRingError::PlatformSizeLimit)?; + let overhead = checked_u64_add(checked_u64_add(command_queue, history)?, consumer)?; + let largest = largest_queue_allocation::(NonZeroUsize::MIN)? + .max(history) + .max(consumer); + Ok(TimelineResourceReport { + history_blocks: u64::try_from(history_blocks.get()) + .map_err(|_| PcmSourceRingError::PlatformSizeLimit)?, + command_queue_bytes: command_queue, + history_bytes: history, + consumer_bytes: consumer, + overhead_bytes: overhead, + largest_allocation_bytes: largest, + }) + } + + /// 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. + pub fn prepare( + quantum_frames: QuantumFrames, + history_blocks: NonZeroUsize, + ) -> Result<(TimelineProducer, TimelineConsumer, TimelineResourceReport), PcmSourceRingError> + { + let report = Self::resource_report(quantum_frames, history_blocks)?; + let initial_generation = SourceGeneration(1); + let (command_producer, command_consumer) = + bounded_spsc(NonZeroUsize::MIN, QueueGeneration(initial_generation.0)) + .map_err(map_spsc_error)?; + let mut history = Vec::new(); + history + .try_reserve_exact(history_blocks.get()) + .map_err(|_| PcmSourceRingError::AllocationFailure)?; + history.resize(history_blocks.get(), TimelineEntry::default()); + Ok(( + TimelineProducer { + seek: SeekProducer { + command_producer, + active_generation: initial_generation, + quantum_frames: quantum_frames.0, + frame_limit: Some(TIMELINE_SAMPLE_LIMIT), + }, + }, + TimelineConsumer { + seek: SeekState::new(initial_generation), + command_consumer, + quantum_frames: quantum_frames.0, + position: 0, + history: history.into_boxed_slice(), + capacity: history_blocks, + oldest: 0, + len: 0, + oldest_expected_timeline: 0, + }, + report, + )) + } +} + +/// The timeline's seek producer (#1640 D4): the shared seek producer and nothing else. Plain and +/// anchored seeks, a generation strictly above the current one, an anchor that is a quantum +/// multiple, a frame at most [`TIMELINE_SAMPLE_LIMIT`], one slot. Control thread only. +pub struct TimelineProducer { + seek: SeekProducer, +} + +impl TimelineProducer { + /// Request a strictly newer generation at `frame`, a timeline sample. It applies on the next + /// block, or for [`SourceCommand::SeekAt`] on the block its anchor names. A full slot is + /// [`SourceSeekError::Backpressure`] and changes nothing; an accepted seek is never dropped. + pub fn try_seek(&mut self, command: SourceCommand) -> Result<(), SourceSeekError> { + self.seek.try_seek(command) + } + + /// Whether [`Self::try_seek`] would accept `command` now, pushing nothing and changing nothing + /// (#1640 D1), so an all-or-nothing caller checks every producer before it pushes to any. + pub fn check_seek(&self, command: SourceCommand) -> Result<(), SourceSeekError> { + self.seek.check_seek(command) + } + + /// The newest generation this producer accepted. + #[must_use] + pub const fn active_generation(&self) -> SourceGeneration { + self.seek.active_generation + } +} + +/// One history entry: the block that read source time from `source_read_sample` started at +/// timeline sample `timeline`. 16 bytes. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct TimelineEntry { + source_read_sample: u64, + timeline: u64, +} + +/// The node-time mapping of one node block (#1640 D3). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TimelineSpan { + /// The timeline value at the node block's first sample. + pub start: i64, + /// The seek that reaches the node block, if any. + pub step: Option, +} + +/// A seek inside a node block: from sample `offset` of the block on, the timeline reads +/// `timeline + (i - offset)`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TimelineStep { + /// The node block's sample the seek takes effect at, from `0` to the block length minus one. + pub offset: u32, + /// The timeline value at that sample. + pub timeline: i64, +} + +/// The session's playhead (#1640 D2): a source consumer with no PCM, no data queue and no +/// recycle queue. It starts at generation 1, timeline sample 0, takes plain and anchored seeks +/// through the shared [`SeekState`], advances one quantum on every block and keeps a fixed +/// history of `K` blocks, newest last, so a reader can map any source-read sample a node of the +/// plan can still be processing to its timeline sample. Every value is an exact integer. +pub struct TimelineConsumer { + seek: SeekState, + command_consumer: Consumer, + quantum_frames: u32, + /// The timeline sample of the next block. + position: u64, + /// `capacity` entries; the `len` valid ones are a ring that starts at index `oldest`. + history: Box<[TimelineEntry]>, + capacity: NonZeroUsize, + oldest: usize, + len: usize, + /// The timeline the oldest entry holds if no seek applied at it: the evicted entry's timeline + /// 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, +} + +impl TimelineConsumer { + /// The quantum every block advances the timeline by. + #[must_use] + pub const fn quantum_frames(&self) -> u32 { + self.quantum_frames + } + + /// The history's entries, `K`. + #[must_use] + pub const fn history_capacity(&self) -> usize { + self.capacity.get() + } + + /// The timeline sample the next block starts at. + #[must_use] + pub const fn position(&self) -> u64 { + self.position + } + + /// The generation in effect. + #[must_use] + pub const fn active_generation(&self) -> SourceGeneration { + self.seek.active_generation + } + // REALTIME_POLICY_BEGIN + /// Begin the block that reads source time from `source_read_sample`: observe one command + /// through the shared seek state ([`SeekClock::At`]), set the position to a seek's frame if + /// one applies, record `(source_read_sample, position)` in the history, then advance one + /// 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 command = self.command_consumer.try_pop().ok(); + let observation = self + .seek + .observe(command, SeekClock::At { source_read_sample }); + if let Some(seek) = observation.apply { + self.position = seek.frame.0; + } + self.record(TimelineEntry { + source_read_sample, + timeline: self.position, + }); + self.position = self.position.saturating_add(u64::from(self.quantum_frames)); + } + + /// The timeline sample at `source_read_sample` (#1640 D3). The entries are searched newest + /// first for the block that contains the sample. With none, the newest entry that starts + /// before the sample extrapolates forward, and a sample before every entry extrapolates back + /// from the oldest one (negative before a fresh plan's first block). With no entry yet, the + /// position stands at source-read sample 0. Exact; saturates at the `i64` range. + #[must_use] + pub fn timeline_at(&self, source_read_sample: u64) -> i64 { + self.resolve(source_read_sample).1 + } + + /// The node-time mapping of the node block of `frames` samples from + /// `first_source_read_sample` (#1640 D3): its `start`, and a step at the first sample of a + /// seek's history entry inside it. When the node block starts exactly at that entry the step + /// is `(0, start)`. A node block of at most one quantum covers at most two source-read + /// blocks, so it has at most one step; of several, the newest is reported. Read-only, with at + /// most `K * (K + 2)` entry reads. + #[must_use] + pub fn timeline_block(&self, first_source_read_sample: u64, frames: u32) -> TimelineSpan { + let start = self.timeline_at(first_source_read_sample); + let mut step = None; + for age in 0..self.len { + let Some(entry) = self.entry(age) else { + break; + }; + let Some(offset) = entry + .source_read_sample + .checked_sub(first_source_read_sample) + .and_then(|offset| u32::try_from(offset).ok()) + .filter(|&offset| offset < frames) + else { + continue; + }; + if !self.is_seek_entry(age) { + continue; + } + // The step is what the reader sees: the entry must be the one that resolves its own + // first sample, not one that a newer, overlapping entry hides. + let (resolving, timeline) = self.resolve(entry.source_read_sample); + if resolving == Some(age) { + step = Some(TimelineStep { offset, timeline }); + break; + } + } + TimelineSpan { start, step } + } + + /// Replace this history with the newest `min(len_other, K_self)` entries of `other`, oldest + /// first (#1641's carry): a bounded copy with no allocation. Both timelines have the same + /// quantum. + pub fn copy_history_from(&mut self, other: &Self) { + let count = other.len.min(self.capacity.get()); + for index in 0..count { + let age = count - 1 - index; + if let (Some(entry), Some(slot)) = (other.entry(age), self.history.get_mut(index)) { + *slot = entry; + } + } + self.oldest = 0; + self.len = count; + self.oldest_expected_timeline = match other.entry(count) { + Some(older) => older + .timeline + .saturating_add(u64::from(other.quantum_frames)), + None => other.oldest_expected_timeline, + }; + } + + /// Append `entry` as the newest, evicting the oldest when the history is full. + fn record(&mut self, entry: TimelineEntry) { + if self.len < self.capacity.get() { + let index = self.oldest.saturating_add(self.len) % self.capacity; + if let Some(slot) = self.history.get_mut(index) { + *slot = entry; + } + self.len += 1; + return; + } + if let Some(slot) = self.history.get_mut(self.oldest) { + self.oldest_expected_timeline = + slot.timeline.saturating_add(u64::from(self.quantum_frames)); + *slot = entry; + } + self.oldest = self.oldest.saturating_add(1) % self.capacity; + } + + /// The entry `age` blocks before the newest (age 0 is the newest), if held. + fn entry(&self, age: usize) -> Option { + let back = self.len.checked_sub(1)?.checked_sub(age)?; + let index = self.oldest.checked_add(back)? % self.capacity; + self.history.get(index).copied() + } + + /// Whether the entry of `age` is a seek: its timeline is not its previous entry's plus a + /// quantum (for the oldest entry, not `oldest_expected_timeline`). + fn is_seek_entry(&self, age: usize) -> bool { + let Some(entry) = self.entry(age) else { + return false; + }; + let expected = match age.checked_add(1).and_then(|older| self.entry(older)) { + Some(previous) => previous + .timeline + .saturating_add(u64::from(self.quantum_frames)), + None => self.oldest_expected_timeline, + }; + entry.timeline != expected + } + + /// The age of the entry whose block contains `sample` (`None` when the value is + /// extrapolated), and the timeline value at `sample`. + fn resolve(&self, sample: u64) -> (Option, i64) { + let quantum = u64::from(self.quantum_frames); + let mut before = None; + for age in 0..self.len { + let Some(entry) = self.entry(age) else { + break; + }; + if let Some(offset) = sample.checked_sub(entry.source_read_sample) { + if offset < quantum { + return (Some(age), offset_timeline(entry, sample)); + } + if before.is_none() { + before = Some(entry); + } + } + } + let anchor = before + .or_else(|| { + self.len + .checked_sub(1) + .and_then(|oldest| self.entry(oldest)) + }) + .unwrap_or(TimelineEntry { + source_read_sample: 0, + timeline: self.position, + }); + (None, offset_timeline(anchor, sample)) + } + // REALTIME_POLICY_END +} + +/// `entry.timeline + (sample - entry.source_read_sample)`, exact, saturated to the `i64` range. +fn offset_timeline(entry: TimelineEntry, sample: u64) -> i64 { + let value = + i128::from(entry.timeline) + i128::from(sample) - i128::from(entry.source_read_sample); + i64::try_from(value).unwrap_or(if value < 0 { i64::MIN } else { i64::MAX }) +} + fn queue_bytes(capacity: NonZeroUsize) -> Result { let payload = bounded_spsc_retained_payload::(capacity).map_err(map_spsc_error)?; u64::try_from( @@ -2710,7 +3248,7 @@ mod tests { } let (output, _) = render_at(&mut consumer, 24); assert_eq!(output, [0.0; 4], "the replaced seek never applies"); - assert_eq!(consumer.active_generation, SourceGeneration(3)); + assert_eq!(consumer.seek.active_generation, SourceGeneration(3)); } /// #1274 D3. Red if the held generation's primed block plays before its anchor because it @@ -2748,7 +3286,7 @@ mod tests { host.submit(chunk(1, 0, &[&[1.0; 4]], 4, false)).unwrap(); host.try_seek(seek_at(2, 100, 8)).unwrap(); assert!(!consumer.prepare_seek(SourceGeneration(2), SourceFrame(100))); - assert_eq!(consumer.active_generation, SourceGeneration(1)); + assert_eq!(consumer.seek.active_generation, SourceGeneration(1)); host.submit(chunk(2, 100, &[&[2.0; 4]], 4, false)).unwrap(); let (output, report) = render_at(&mut consumer, 4); assert_eq!( @@ -2864,7 +3402,7 @@ mod tests { assert_eq!(output, [2.0; 4], "plain: the acked block plays at once"); assert!(report.generation_changed); } - assert_eq!(consumer.active_generation, SourceGeneration(2)); + assert_eq!(consumer.seek.active_generation, SourceGeneration(2)); assert_eq!(consumer.stale_generation_discard_count, 0); // The anchored run waited one block for its anchor: held, never an underrun. assert_eq!(consumer.underrun_events, 0); @@ -2894,7 +3432,7 @@ mod tests { "late by one block: frame 104 plays at block 8" ); assert!(report.generation_changed); - assert_eq!(consumer.active_generation, SourceGeneration(2)); + assert_eq!(consumer.seek.active_generation, SourceGeneration(2)); assert_eq!( consumer.stale_generation_discard_count, 1, "the late-skipped block" @@ -2952,11 +3490,11 @@ mod tests { let mut host = producer.into_host_chunk_provider(RATE); host.try_seek(seek_at(2, 100, 64)).unwrap(); render_at(&mut consumer, 0); - assert!(consumer.held_seek.is_some()); + assert!(consumer.seek.held_seek.is_some()); let next = consumer.next_frame; assert!(!consumer.prepare_seek(SourceGeneration(1), next)); assert!( - consumer.held_seek.is_some(), + consumer.seek.held_seek.is_some(), "the held seek survives preparation" ); } @@ -3310,6 +3848,7 @@ mod tests { .consumer .as_ref() .unwrap() + .seek .held_seek .is_none() ); @@ -5361,6 +5900,7 @@ mod tests { commands_popped: consumer.command_consumer.success_count(), data_popped: consumer.data_consumer.success_count(), held: consumer + .seek .held_seek .map(|held| (held.generation, held.frame, held.anchor_sample)), current: consumer @@ -5522,7 +6062,7 @@ mod tests { } let first = ordinary(&mut consumer, 0); assert!(!first.generation_changed && first.copied_frames == Q); - assert!(consumer.held_seek.is_some()); + assert!(consumer.seek.held_seek.is_some()); assert!( !prime_ready_checked(&consumer, u64::from(Q), K), "held, anchored at the next block" @@ -5542,7 +6082,7 @@ mod tests { .expect("seek"); assert!(!prime_ready_checked(&consumer, 0, K), "queued"); ordinary(&mut consumer, 0); - assert!(consumer.held_seek.is_some()); + assert!(consumer.seek.held_seek.is_some()); assert!( prime_ready_checked(&consumer, u64::from(Q), K), "held past the window" @@ -5560,7 +6100,7 @@ mod tests { let window_end = first_sample + u64::from(K + 1) * u64::from(Q); assert!(prime_ready_checked(&consumer, first_sample, K)); let hold = |consumer: &mut PcmSourceConsumer, anchor_sample| { - consumer.held_seek = Some(HeldSeek { + consumer.seek.held_seek = Some(HeldSeek { generation: SourceGeneration(2), frame: SourceFrame(9_000), anchor_sample, @@ -5587,7 +6127,7 @@ mod tests { let (mut host, mut consumer) = ring(); // The consumer reads generation 2 from frame 0; two generation-1 blocks are queued // ahead of its PCM. - consumer.active_generation = SourceGeneration(2); + consumer.seek.active_generation = SourceGeneration(2); push_raw(&mut host, 1, 0); push_raw(&mut host, 1, u64::from(Q)); for block in 0..5 { @@ -5616,4 +6156,339 @@ mod tests { assert!(prime_ready_checked(&consumer, 2 * u64::from(Q), K - 1)); } } + + /// #1640 gate 1: the timeline consumer and producer, at quantum 128. + mod timeline { + use super::*; + + const Q: u32 = 128; + const QU: u64 = Q as u64; + + fn timeline(history_blocks: usize) -> (TimelineProducer, TimelineConsumer) { + let (producer, consumer, _) = SourceTimeline::prepare( + QuantumFrames(Q), + NonZeroUsize::new(history_blocks).unwrap(), + ) + .unwrap(); + (producer, consumer) + } + + fn seek(generation: u64, frame: u64) -> SourceCommand { + SourceCommand::Seek { + generation: SourceGeneration(generation), + frame: SourceFrame(frame), + } + } + + /// Run blocks `from..to` (block indices) on the source-read clock; return the timeline + /// value each block started at. + fn run(consumer: &mut TimelineConsumer, from: u64, to: u64) -> Vec { + (from..to) + .map(|block| { + consumer.begin_block_at(block * QU); + let start = consumer.timeline_at(block * QU); + assert_eq!(consumer.position(), u64::try_from(start).unwrap() + QU); + u64::try_from(start).unwrap() + }) + .collect() + } + + #[test] + fn a_fresh_timeline_starts_at_zero_and_advances_a_quantum_every_block() { + let (producer, mut consumer) = timeline(4); + assert_eq!(consumer.position(), 0); + assert_eq!(consumer.active_generation(), SourceGeneration(1)); + assert_eq!(producer.active_generation(), SourceGeneration(1)); + let starts = run(&mut consumer, 0, 6); + assert_eq!(starts, (0..6).map(|block| block * QU).collect::>()); + assert_eq!(consumer.timeline_at(5 * QU + 17), 5 * 128 + 17); + } + + #[test] + fn a_plain_seek_reads_its_frame_at_the_next_blocks_first_sample() { + let (mut producer, mut consumer) = timeline(4); + run(&mut consumer, 0, 3); + producer.try_seek(seek(2, 10_000)).unwrap(); + assert_eq!(producer.active_generation(), SourceGeneration(2)); + consumer.begin_block_at(3 * QU); + assert_eq!(consumer.timeline_at(3 * QU), 10_000); + assert_eq!(consumer.timeline_at(3 * QU + 127), 10_127); + assert_eq!(consumer.active_generation(), SourceGeneration(2)); + assert_eq!(run(&mut consumer, 4, 6), vec![10_128, 10_256]); + } + + #[test] + fn an_anchored_seek_is_held_until_its_anchor_and_applies_with_its_lateness() { + let anchor = 4 * QU; + // On time: held while the block sample is below the anchor, the timeline advancing. + let (mut producer, mut consumer) = timeline(4); + producer + .try_seek(SourceCommand::SeekAt { + generation: SourceGeneration(2), + frame: SourceFrame(50_000), + anchor_sample: anchor, + }) + .unwrap(); + assert_eq!(run(&mut consumer, 0, 4), vec![0, 128, 256, 384]); + assert_eq!(consumer.active_generation(), SourceGeneration(1)); + assert!(consumer.seek.held_seek.is_some()); + assert_eq!(run(&mut consumer, 4, 6), vec![50_000, 50_128]); + assert_eq!(consumer.active_generation(), SourceGeneration(2)); + + // Observed late at anchor + 256: it applies at frame + 256. + let (mut producer, mut consumer) = timeline(4); + producer + .try_seek(SourceCommand::SeekAt { + generation: SourceGeneration(2), + frame: SourceFrame(50_000), + anchor_sample: anchor, + }) + .unwrap(); + consumer.begin_block_at(anchor + 256); + assert_eq!(consumer.timeline_at(anchor + 256), 50_256); + } + + #[test] + fn a_newer_seek_plain_or_anchored_replaces_a_held_one() { + let held = |generation: u64, frame: u64, anchor_sample: u64| SourceCommand::SeekAt { + generation: SourceGeneration(generation), + frame: SourceFrame(frame), + anchor_sample, + }; + // Anchored over anchored: the first anchor passes with nothing applied. + let (mut producer, mut consumer) = timeline(4); + producer.try_seek(held(2, 20_000, 2 * QU)).unwrap(); + consumer.begin_block_at(0); + producer.try_seek(held(3, 30_000, 4 * QU)).unwrap(); + assert_eq!(run(&mut consumer, 1, 4), vec![128, 256, 384]); + assert_eq!(run(&mut consumer, 4, 5), vec![30_000]); + assert_eq!(consumer.active_generation(), SourceGeneration(3)); + + // Plain over anchored: applies at once and the held seek never applies. + let (mut producer, mut consumer) = timeline(4); + producer.try_seek(held(2, 20_000, 3 * QU)).unwrap(); + consumer.begin_block_at(0); + producer.try_seek(seek(3, 7_000)).unwrap(); + assert_eq!(run(&mut consumer, 1, 5), vec![7_000, 7_128, 7_256, 7_384]); + assert!(consumer.seek.held_seek.is_none()); + assert_eq!(consumer.active_generation(), SourceGeneration(3)); + } + + #[test] + fn the_producer_refuses_without_changing_anything() { + let (mut producer, mut consumer) = timeline(4); + for (command, error) in [ + (seek(0, 5), SourceSeekError::GenerationZero), + ( + seek(1, 5), + SourceSeekError::GenerationNotStrictlyIncreasing { + active: SourceGeneration(1), + requested: SourceGeneration(1), + }, + ), + ( + SourceCommand::SeekAt { + generation: SourceGeneration(2), + frame: SourceFrame(5), + anchor_sample: QU + 1, + }, + SourceSeekError::AnchorUnaligned, + ), + ( + seek(2, TIMELINE_SAMPLE_LIMIT + 1), + SourceSeekError::FrameOutOfRange, + ), + ] { + assert_eq!(producer.check_seek(command), Err(error)); + assert_eq!(producer.try_seek(command), Err(error)); + assert_eq!(producer.active_generation(), SourceGeneration(1)); + } + producer.try_seek(seek(2, TIMELINE_SAMPLE_LIMIT)).unwrap(); + // The one slot is taken: a second seek is backpressure, checked or pushed, and the + // first one still applies. + assert!(matches!( + producer.check_seek(seek(3, 9)), + Err(SourceSeekError::Backpressure { .. }) + )); + assert!(matches!( + producer.try_seek(seek(3, 9)), + Err(SourceSeekError::Backpressure { .. }) + )); + assert_eq!(producer.active_generation(), SourceGeneration(2)); + consumer.begin_block_at(0); + assert_eq!(consumer.position(), TIMELINE_SAMPLE_LIMIT + QU); + assert_eq!(consumer.active_generation(), SourceGeneration(2)); + assert_eq!(producer.check_seek(seek(3, 9)), Ok(())); + } + + #[test] + fn the_history_reads_both_sides_of_a_seek_and_extrapolates_below_its_oldest_entry() { + let (mut producer, mut consumer) = timeline(4); + run(&mut consumer, 0, 6); + producer.try_seek(seek(2, 10_000)).unwrap(); + run(&mut consumer, 6, 8); + // The window holds blocks 4..8: 512, 640, then the seek, 10_000, 10_128. + assert_eq!(consumer.timeline_at(6 * QU - 1), 767); + assert_eq!(consumer.timeline_at(6 * QU), 10_000); + assert_eq!(consumer.timeline_at(7 * QU + 5), 10_133); + // Below the oldest entry (block 4): back from it. + assert_eq!(consumer.timeline_at(4 * QU - 1), 511); + assert_eq!(consumer.timeline_at(0), 0); + + // A fresh plan's first block reads negative node times before it. + let (_producer, mut fresh) = timeline(2); + fresh.begin_block_at(256); + assert_eq!(fresh.timeline_at(256 - 40), -40); + } + + #[test] + fn a_node_block_reports_the_step_of_a_seek_inside_it_or_at_its_first_sample() { + let (mut producer, mut consumer) = timeline(4); + run(&mut consumer, 0, 3); + producer.try_seek(seek(2, 10_000)).unwrap(); + run(&mut consumer, 3, 5); + // Straddling: the node block starts 40 samples before the seek's block. + assert_eq!( + consumer.timeline_block(3 * QU - 40, Q), + TimelineSpan { + start: 3 * 128 - 40, + step: Some(TimelineStep { + offset: 40, + timeline: 10_000, + }), + } + ); + // Exactly at the seek's block: the step is at offset 0. + assert_eq!( + consumer.timeline_block(3 * QU, Q), + TimelineSpan { + start: 10_000, + step: Some(TimelineStep { + offset: 0, + timeline: 10_000, + }), + } + ); + // A plain advance is no step, inside or at the first sample. + assert_eq!( + consumer.timeline_block(4 * QU - 40, Q), + TimelineSpan { + start: 10_088, + step: None, + } + ); + assert_eq!(consumer.timeline_block(2 * QU, Q).step, None); + } + + #[test] + fn a_seek_in_the_oldest_entry_is_still_a_step() { + let (mut producer, mut consumer) = timeline(2); + run(&mut consumer, 0, 2); + producer.try_seek(seek(2, 9_000)).unwrap(); + // Block 2 seeks; block 3 evicts block 1, so block 2 becomes the oldest entry. + run(&mut consumer, 2, 4); + assert_eq!( + consumer.timeline_block(2 * QU, Q).step, + Some(TimelineStep { + offset: 0, + timeline: 9_000, + }) + ); + assert_eq!(consumer.timeline_block(3 * QU, Q).step, None); + } + + #[test] + fn copying_a_history_keeps_its_newest_entries_in_order() { + let (mut producer, mut large) = timeline(6); + run(&mut large, 0, 4); + producer.try_seek(seek(2, 10_000)).unwrap(); + run(&mut large, 4, 8); + let snapshot = |consumer: &TimelineConsumer| { + (0..consumer.len) + .rev() + .map(|age| consumer.entry(age).unwrap()) + .map(|entry| (entry.source_read_sample, entry.timeline)) + .collect::>() + }; + // From a larger K: the newest three, oldest first, and the seek still a step. + let (_p, mut small) = timeline(3); + run(&mut small, 0, 1); + small.copy_history_from(&large); + assert_eq!( + snapshot(&small), + vec![(5 * QU, 10_128), (6 * QU, 10_256), (7 * QU, 10_384)] + ); + assert_eq!(small.timeline_at(5 * QU - 1), 10_127); + assert_eq!(small.timeline_block(5 * QU, Q).step, None); + // From a smaller K: all three, in order, then the copy keeps recording after them. + let (_p, mut larger) = timeline(5); + larger.copy_history_from(&small); + assert_eq!(snapshot(&larger), snapshot(&small)); + // The copy's oldest entry is a plain advance from the entry before it. + assert!(!larger.is_seek_entry(2)); + larger.begin_block_at(8 * QU); + assert_eq!(snapshot(&larger).len(), 4); + // A copy that keeps the seek's entry as its oldest still reports it. + let (_p, mut five) = timeline(5); + five.copy_history_from(&large); + assert_eq!(snapshot(&five).first(), Some(&(3 * QU, 384))); + let (_p, mut four) = timeline(4); + four.copy_history_from(&large); + assert_eq!(snapshot(&four).first(), Some(&(4 * QU, 10_000))); + assert_eq!( + four.timeline_block(4 * QU, Q).step, + Some(TimelineStep { + offset: 0, + timeline: 10_000, + }) + ); + } + + #[test] + fn the_resource_report_states_each_row() { + let report = + SourceTimeline::resource_report(QuantumFrames(Q), NonZeroUsize::new(5).unwrap()) + .unwrap(); + let ring = PcmSourceRing::resource_report(config(1, Q, QU)).unwrap(); + assert_eq!(report.history_blocks, 5); + assert_eq!(report.history_bytes, 80); + assert_eq!(report.command_queue_bytes, ring.command_queue_bytes); + assert_eq!( + report.consumer_bytes, + u64::try_from(size_of::()).unwrap() + ); + assert_eq!( + report.overhead_bytes, + report.command_queue_bytes + report.history_bytes + report.consumer_bytes + ); + assert_eq!( + SourceTimeline::resource_report(QuantumFrames(0), NonZeroUsize::MIN), + Err(PcmSourceRingError::ZeroQuantumFrames) + ); + } + + /// #1640 D1 on the PCM side: `check_seek` forwards to the shared seek producer, agrees + /// 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 mut host = producer.into_host_chunk_provider(RATE); + assert_eq!( + host.check_seek(seek_at(2, 100, 6)), + Err(SourceSeekError::AnchorUnaligned) + ); + assert_eq!(host.check_seek(seek(2, 100)), Ok(())); + assert_eq!(host.telemetry().active_generation, SourceGeneration(1)); + consumer.begin_block(); + assert_eq!(consumer.telemetry().active_generation, SourceGeneration(1)); + host.try_seek(seek(2, 100)).unwrap(); + assert!(matches!( + host.check_seek(seek(3, 0)), + Err(SourceSeekError::Backpressure { .. }) + )); + // A source seek has no frame limit. + consumer.begin_block(); + assert_eq!(host.check_seek(seek(3, u64::MAX)), Ok(())); + } + } } diff --git a/crates/source/tests/randomized.rs b/crates/source/tests/randomized.rs index 2cec1e846..ba467b374 100644 --- a/crates/source/tests/randomized.rs +++ b/crates/source/tests/randomized.rs @@ -437,6 +437,9 @@ fn schedule(seed: u64, reach: &mut Reach) { Err(SourceSeekError::AnchorUnaligned) => { unreachable!("{context}: a plain seek has no anchor") } + Err(SourceSeekError::FrameOutOfRange) => { + unreachable!("{context}: a source seek has no frame limit") + } }; let expected = model.seek(target, frame); assert_eq!( diff --git a/tools/realtime-policy/src/policy.rs b/tools/realtime-policy/src/policy.rs index 4054afcdb..444409087 100644 --- a/tools/realtime-policy/src/policy.rs +++ b/tools/realtime-policy/src/policy.rs @@ -224,9 +224,11 @@ impl Policy { // and its block-entry read) and #1630's one (`crates/builtins-compiler`: the cell // readers' carry marks, D15-2) and #1316's (`seqlock.rs`, the engine's one seqlock // writer, B15-R1). The floors had fallen behind the tree before #1637; they are set to - // its counts again. A change that adds a marker raises them in the same change. + // its counts again. A change that adds a marker raises them in the same change: #1640 + // adds two in `crates/source` (the shared seek state's observe and reads, and the + // timeline consumer's block entry and history readers). marked_files_floor: 33, - marked_regions_floor: 151, + marked_regions_floor: 153, render_thread_exports: table::parse(RENDER_THREAD_EXPORTS) .unwrap_or_else(|error| panic!("render-thread-exports.json: {error}")), unwrapped_exports: UNWRAPPED_EXPORTS From 769dbb2823c65699b48242847a76e33ac37d1cac Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 13:12:47 +0000 Subject: [PATCH 5/8] Give every plan a timeline that carries like a source (#1641) D1: every host-core plan's source set holds one TimelineConsumer with K = ceil(A_max / q) + 1 from the floored input arrivals; the driver advances it at block entry before the sources, from the source-read sample. D2: SourceControlSet owns the TimelineProducer and adopt_persisting moves it. D3/D4: a successor builds a vacant timeline with its own history; GraphCarryProgram gains timeline: bool, and the swap moves the seek state, command consumer and position and copies the newest history entries, allocating nothing. D5 (B16-R3): the timeline's readiness is the shared non-PCM test (SeekState::prime_window_clear), also used by PcmSourceConsumer::prime_ready; its prime advances k blocks. No production prime caller until #1355. D6 deferred to TBD45 (B16-R4). D7: the timeline's fixed bytes join the source overhead. D8: graph::test_only_timeline_at with a host-core test-support re-export. Re-pins, each for the timeline consumer's fixed bytes (#1641 D7), measured: capi source_overhead 4,174 -> 4,654 (ceiling 4,352 -> 5,120); host-web source_overhead 2,658 -> 3,138 (2,688 -> 3,456) and source_total 3,682 -> 4,162 (3,840 -> 4,608); wasm32 expected.json sourceOverheadBytes 2,474 -> 2,922 (2,496 -> 3,264) and sourceTotalBytes 3,498 -> 3,946 (3,648 -> 4,352). audit capi pcm_digest unchanged (4f52089e4a8cf83c). Co-Authored-By: Claude Opus 5.5 --- ...n-a-timeline-that-carries-like-a-source.md | 95 +++ crates/capi/tests/resource_lifecycle.rs | 12 +- crates/graph/src/lib.rs | 22 +- crates/graph/tests/rt11_swap_carry_alloc.rs | 1 + crates/host-core/Cargo.toml | 6 +- crates/host-core/src/lib.rs | 4 + crates/host-core/src/prepare.rs | 58 +- crates/host-core/src/source.rs | 30 +- crates/host-core/tests/successor_swap.rs | 121 ++- crates/host-core/tests/timeline.rs | 711 ++++++++++++++++++ crates/source/src/lib.rs | 256 +++++-- hosts/host-web/tests/browser-v1/expected.json | 4 +- hosts/host-web/tests/retained_ceilings.rs | 15 +- 13 files changed, 1265 insertions(+), 70 deletions(-) create mode 100644 crates/host-core/tests/timeline.rs diff --git a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md index 403e79e0b..2c1f591e3 100644 --- a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md +++ b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md @@ -323,3 +323,98 @@ Checked against `35ac17545`. Placeholder TBD45 *Report the timeline seek position to hosts* keeps them, for the first slice that needs a host reader. Gate 1 compares `test_only_timeline_at` with the frame each block's source read starts at; its seek-report clause is dropped. + +## Decision record: #1641 (batch 16) + +Built on `3f53c0755` (worktree `codex/controls-batch-16a`, #1640's timeline types). + +**What shipped:** + +- **D1** (`crates/source/src/lib.rs`, `crates/host-core/src/prepare.rs`). `prepare_graph_source_set` + takes a `TimelineConsumer` (a new third argument; refused with + `SourceGraphSourceSetError::TimelineQuantum` when its quantum is not the envelope's). The driver + holds it inline and calls its `begin_block_at(first_sample)` at block entry, after the quantum + check and before the sources, with the sample the sources get. Host-core sizes it + `K = ⌈A_max / q⌉ + 1` from `artifact.report().input_arrivals` (1 when empty), just before the + source set is sealed. +- **D2** (`crates/host-core/src/source.rs`). `SourceControlSet` holds `Option`: + a fresh plan's, set at preparation; `None` on a successor until `adopt_persisting` takes the + predecessor's by the same rule as a vacant source's producer. The return value still counts + source producers only (callers assert it). No public timeline seek; a `test-support` accessor + `test_only_timeline_producer` serves the gates. +- **D3.** `SourceTimeline::prepare_vacant(q, K)`: its own history, no command queue + (`command_consumer: Option<_>`). `GraphCarryProgram { timeline: bool }`; `adopt_predecessor` + calls `adopt_sources` when `!sources.is_empty() || timeline`. The driver moves the timeline when + its own is vacant: the predecessor's must be occupied (else nothing moves, `PredecessorMismatch`), + then `TimelineConsumer::adopt_from` swaps seek state, command consumer and position and calls + `copy_history_from`. Nothing is allocated or freed. A vacant timeline never adopted advances + from 0 and pops nothing. `swap_sources` (donation) leaves the timeline where it is. +- **D4.** No extra code: the discontinuity successor carries as D3 (its program now always exists). +- **D5 (B16-R3).** `TimelineConsumer::prime_ready(first, lead)` and `prime_block_at(sample)`. The + seek half of readiness is written once, `SeekState::prime_window_clear` (no queued command, no + held anchor before the window end); `PcmSourceConsumer::prime_ready` now calls it too. No + driver prime path (#1355). +- **D7.** The set's overhead gains the history (`16·K`) and, for an occupied timeline, the command + queue; its largest allocation the larger of the two (`TimelineResourceReport` gains + `command_queue_largest_allocation_bytes` for this). `consumer_bytes` is not charged again: the + consumer is inline in the driver, whose own allocation row counts it. A vacant timeline's queue + is added to `carried_source_total_bytes`, `carried_source_overhead_bytes` and the carried largest + allocation, so `source_overhead + carried_source_overhead == fresh` holds. +- **D8.** `GraphPreparedSourceSetDriver::timeline_at` (default `None`), `graph::test_only_timeline_at` + (test-support), re-exported by host-core under `test-support`, whose feature gains + `graph/test-support`. `Cargo.lock` did not change (features are not locked). + +**Departures:** + +- **The timeline flag needs the same rate and quantum.** `timeline_carries` is true for a successor + whose inventory rate and quantum equal the compiled session's. Both hosts refuse a change of + either (#1290a), so on both hosts it is every successor; a hand-prepared test successor at + another quantum gets a fresh timeline instead of a carry that would be refused at the swap and + take every other carry down with it. +- **The vacancy, not the flag, tells the driver to move the timeline**, so the trait's + `adopt_sources` signature is unchanged. The flag makes the call happen. +- **The `has_carry_program` fallback** in `prepare.rs` (a program for delay/route carries) is kept + with `timeline: false`; it is reached only by a successor whose timeline does not carry. +- **Gate 4** is in both files: `timeline.rs` on the direct `adopt_predecessor_plan` path, and the + one `successor_swap.rs` case through the real plan exchange. +- **D6** is out (B16-R4): no seek-report record, bytes or move. Gate 1 has no report clause. + +**Re-pins** (each "the timeline consumer's fixed bytes (#1641 D7)"; ceilings are the new value +plus 10 %, rounded up to 64): + +| Pin | Before | After | Ceiling | +|---|---|---|---| +| `crates/capi/tests/resource_lifecycle.rs` `source_overhead_bytes` (x86-64) | 4,174 | 4,654 | 4,352 -> 5,120 | +| `hosts/host-web/tests/retained_ceilings.rs` `source_overhead_bytes` (x86-64) | 2,658 | 3,138 | 2,688 -> 3,456 | +| `hosts/host-web/tests/retained_ceilings.rs` `source_total_bytes` (x86-64) | 3,682 | 4,162 | 3,840 -> 4,608 | +| `expected.json` `sourceOverheadBytes` (wasm32 simd128, measured) | 2,474 | 2,922 | 2,496 -> 3,264 | +| `expected.json` `sourceTotalBytes` (wasm32 simd128, measured) | 3,498 | 3,946 | 3,648 -> 4,352 | + +The native step is 480 bytes (queue 320, history 16, consumer 144); the wasm32 step is 448 +(consumer 112). capi `source_total_bytes` (12,366 -> 12,846) and `capi_retained_bytes` +(167,754 -> 168,378) stay under their ceilings. `crates/host-core/tests/successor_swap.rs`: five +`carried_source_total_bytes` checks that meant "no ring carried" (`== 0`) or "a ring carried" +(`> 0`) now compare with the timeline's carried queue (`timeline_queue_bytes()`), and +`a_changed_source_gets_its_own_ring` compares allocated plus carried with a fresh plan. + +**Gates** (`crates/host-core/tests/timeline.rs`, new, and one case in `successor_swap.rs`): + +1. `a_source_and_the_timeline_given_the_same_seeks_read_the_same_frame_in_every_block`. +2. `a_rebuild_carries_the_timeline_its_history_and_its_producer`; + `a_latent_successor_keeps_the_newest_entries_in_a_history_of_its_own_size` (K 5 -> 9). +3. `a_discontinuity_keeps_the_sources_and_the_timeline_in_step`; + `a_prime_advances_the_timeline_as_it_advances_a_source`. +4. `the_timeline_carry_and_the_blocks_after_it_allocate_and_free_nothing`; + `successor_swap::the_timeline_carry_allocates_and_frees_nothing_through_the_exchange`. +5. No render arithmetic changed. `./target/release/audit capi`: `pcm_digest` `4f52089e4a8cf83c` + at base and head, 0 violations. The browser oracle's three PCM digests agree with + `expected.json` (`scripts/check-browser-expected-resources.py`, which also measured the wasm + rows above). + +Commands, all PASS: `cargo test --locked` for `source`, `graph --features graph/test-support`, +`host-core --features host-core/test-support`, `capi`, `control-plane`, and host-web +`retained_ceilings`; `realtime_policy` (153 regions, unchanged: the new render code sits in +existing regions; the timeline's pop is a `match`, not a closure, which the policy refuses); +`check-cross-targets.sh`, `check-host-core-policy.sh`, `check-workspace-policy.sh`; clippy +`-D warnings` on the six touched crates; `cargo fmt --all -- --check`. The workspace-wide clippy +was not run (worker rules). diff --git a/crates/capi/tests/resource_lifecycle.rs b/crates/capi/tests/resource_lifecycle.rs index ba5d22e55..d14cef899 100644 --- a/crates/capi/tests/resource_lifecycle.rs +++ b/crates/capi/tests/resource_lifecycle.rs @@ -1242,6 +1242,14 @@ impl Budget { /// tables. `capi_retained_bytes` measured 167,514 before and 167,754 after (x86-64, +240: the /// two tables in the inventory the provider epochs keep), within the 175,296 ceiling, so the /// ceiling stays. +/// +/// #1641 (batch 16) gives every plan a timeline consumer: the timeline consumer's fixed bytes +/// (#1641 D7), its one-slot command queue, its one-entry history (the reference session has no +/// latency, so `K = 1`) and the consumer inline in the source driver. `source_overhead_bytes` +/// measured 4,174 before and 4,654 after (x86-64, +480; the row does not depend on the lane width), +/// so its ceiling is 4,654 plus 10 %, rounded up to 64: 5,119.4 -> 5,120 (was 4,352). +/// `source_total_bytes` moved 12,366 -> 12,846 and `capi_retained_bytes` 167,754 -> 168,378 (the +/// timeline producer in each kept source control set), both within their ceilings. const REFERENCE_BUDGETS: [Budget; 19] = [ Budget { row: "graph_session_plus_plan_bytes", @@ -1306,8 +1314,8 @@ const REFERENCE_BUDGETS: [Budget; 19] = [ Budget { row: "source_overhead_bytes", value: |report| report.source_overhead_bytes, - eight_lanes: 4_352, - four_lanes: 4_352, + eight_lanes: 5_120, + four_lanes: 5_120, }, Budget { row: "source_total_bytes", diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index b3afe5d3f..794d75d13 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -2666,6 +2666,12 @@ pub trait GraphPreparedSourceSetDriver: Send { ) -> bool { false } + /// The session timeline's sample at `source_read_sample` (#1641 D8), from the driver's + /// timeline history, or `None` (the default) for a driver with no timeline. Read-only and + /// bounded. + fn timeline_at(&self, _source_read_sample: u64) -> Option { + None + } } /// The source set as a bank's gather sees it during the unit loop (issue #918): a shared view of @@ -3312,6 +3318,9 @@ pub struct GraphCarryProgram { pub predecessor: u64, /// `(successor source index, predecessor source index)` pairs. pub sources: Box<[(u32, u32)]>, + /// Whether the source set's timeline carries (#1641 D3): set on every host successor, so the + /// driver's [`GraphPreparedSourceSetDriver::adopt_sources`] runs even when no source moves. + pub timeline: bool, } impl GraphCarryProgram { @@ -4344,6 +4353,17 @@ pub fn route_carry_retained_bytes(moves: usize) -> Option { u64::try_from(moves.checked_mul(core::mem::size_of::())?).ok() } +/// The session timeline's sample at `source_read_sample` in a graph plan's source set (#1641 D8): +/// [`GraphPreparedSourceSetDriver::timeline_at`], downcast as [`carry_program_retained_bytes`] +/// does. `None` for another plan, a plan with no source set or a driver with no timeline. +#[cfg(any(test, feature = "test-support"))] +pub fn test_only_timeline_at( + plan: &mut PreparedRenderPlan, + source_read_sample: u64, +) -> Option { + graph_source_driver_mut(plan)?.timeline_at(source_read_sample) +} + /// Bytes the plan's installed [`GraphCarryProgram`] retains; zero for a plan without one or for /// any other plan. pub fn carry_program_retained_bytes(plan: &mut PreparedRenderPlan) -> u64 { @@ -4787,7 +4807,7 @@ impl PreparedPlanExecutor for GraphExecutor { { return CarryOutcome::PredecessorMismatch; } - if !program.sources.is_empty() { + if !program.sources.is_empty() || program.timeline { let (Some(successor_set), Some(predecessor_set)) = (self.source_set.as_mut(), predecessor.source_set.as_mut()) else { diff --git a/crates/graph/tests/rt11_swap_carry_alloc.rs b/crates/graph/tests/rt11_swap_carry_alloc.rs index 4f8a4484d..12a7b6da5 100644 --- a/crates/graph/tests/rt11_swap_carry_alloc.rs +++ b/crates/graph/tests/rt11_swap_carry_alloc.rs @@ -236,6 +236,7 @@ fn the_swap_block_carry_allocates_and_frees_nothing() { GraphCarryProgram { predecessor: identity, sources: vec![(0, 0)].into_boxed_slice(), + timeline: false, }, ) .expect("install"); diff --git a/crates/host-core/Cargo.toml b/crates/host-core/Cargo.toml index 9063b4497..faf3bd85b 100644 --- a/crates/host-core/Cargo.toml +++ b/crates/host-core/Cargo.toml @@ -13,7 +13,11 @@ crate-type = ["rlib"] [features] default = [] control-provider = ["dep:protocol"] -test-support = ["effect-compiler/test-support", "builtins-compiler/test-support"] +test-support = [ + "effect-compiler/test-support", + "builtins-compiler/test-support", + "graph/test-support", +] [dependencies] builtins.workspace = true diff --git a/crates/host-core/src/lib.rs b/crates/host-core/src/lib.rs index c2036d47c..1dded9312 100644 --- a/crates/host-core/src/lib.rs +++ b/crates/host-core/src/lib.rs @@ -225,6 +225,10 @@ pub use effect_compiler::{ pub use effect_compiler::{ test_only_parametric_eq_design_call_count, test_only_reset_parametric_eq_design_calls, }; +/// The session timeline's sample in a prepared plan (#1641 D8), for tests of hosts that do not +/// name `graph` (host-web). +#[cfg(feature = "test-support")] +pub use graph::test_only_timeline_at; #[cfg(feature = "test-support")] pub use prepare::{ test_only_prepare_host_runtime_on, test_only_prepare_host_runtime_successor_on, diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 04aa6a71b..a4c5f2cc5 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -49,7 +49,7 @@ use session::{ }; use source::{ PcmSourceRing, PcmSourceRingConfig, SourceFrame, SourceGeneration, SourceGraphSource, - SourceGraphTrackMapping, prepare_graph_source_set, + SourceGraphTrackMapping, SourceTimeline, prepare_graph_source_set, }; use std::collections::BTreeMap; @@ -2433,9 +2433,18 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( } _ => false, }; + // #1641 D3: every successor carries the session timeline, so every successor has a program. + // Its history is the successor's own, of its own `K`; the move is a swap and a bounded copy. + // Both hosts refuse a rate or quantum change (#1290a), so the guard only keeps the carry + // defined for a caller that prepares one by hand: such a successor starts a fresh timeline. + let timeline_carries = successor.is_some_and(|base| { + base.inventory.sample_rate_hz == compiled.sample_rate().0 + && base.inventory.quantum_frames == compiled.quantum().0 + }); let carry_program = match successor { Some(base) - if !carried_sources.is_empty() + if timeline_carries + || !carried_sources.is_empty() || !carried_inputs.is_empty() || meters_may_carry || spectrum_may_carry @@ -2445,6 +2454,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( Some(graph::GraphCarryProgram { predecessor: base.inventory.plan_identity(), sources: carried_sources.into_boxed_slice(), + timeline: timeline_carries, }) } _ => None, @@ -2464,7 +2474,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 sources = builder.finish(); + let mut sources = builder.finish(); // Source semantics: tracks only. let mappings = model @@ -2913,8 +2923,45 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( { return Err(resource("host.resource.limit")); } - let source_set = prepare_graph_source_set(artifact.envelope(), graph_sources, mappings) - .map_err(|_| graph_failure("host.source.graph.prepare"))?; + // #1641 D1: the timeline's history covers the largest floored input arrival, `K = ⌈A_max / + // q⌉ + 1` blocks (one block for a plan with no latency). + let quantum = artifact.envelope().quantum; + let largest_arrival = artifact + .report() + .input_arrivals + .iter() + .map(|(_, arrival)| arrival.0) + .max() + .unwrap_or(0); + let history_blocks = largest_arrival + .div_ceil(u64::from(quantum.0.max(1))) + .checked_add(1) + .and_then(|blocks| usize::try_from(blocks).ok()) + .and_then(NonZeroUsize::new) + .ok_or_else(|| resource("host.resource.arithmetic"))?; + 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"))?; + carried_total_bytes = carried_total_bytes + .checked_add(report.command_queue_bytes) + .ok_or_else(|| resource("host.resource.arithmetic"))?; + carried_overhead_bytes = carried_overhead_bytes + .checked_add(report.command_queue_bytes) + .ok_or_else(|| resource("host.resource.arithmetic"))?; + carried_largest_bytes = + 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"))?; + 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_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. @@ -3398,6 +3445,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( graph::GraphCarryProgram { predecessor: base.inventory.plan_identity(), sources: Box::default(), + timeline: false, }, ) .map_err(|_| graph_failure("host.carry.install"))?; diff --git a/crates/host-core/src/source.rs b/crates/host-core/src/source.rs index d8acdd652..637e2bd65 100644 --- a/crates/host-core/src/source.rs +++ b/crates/host-core/src/source.rs @@ -12,7 +12,7 @@ use engine::realtime::PreparedRenderPlan; use session::SessionModel; use source::{ HostChunkError, HostChunkProvider, HostPlanarChunk, SourceCommand, SourceFrame, - SourceGeneration, SourceSeekError, SourceSeekReport, SubmitReport, + SourceGeneration, SourceSeekError, SourceSeekReport, SubmitReport, TimelineProducer, }; use crate::PlanStateInventory; @@ -138,11 +138,30 @@ impl SourceControlError { pub struct SourceControlSet { ids: Box<[u8]>, sources: Box<[ControlSource]>, + /// 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, } impl SourceControlSet { pub(crate) fn new(ids: Box<[u8]>, sources: Box<[ControlSource]>) -> Self { - Self { ids, sources } + Self { + ids, + sources, + timeline: None, + } + } + + /// Give the set its plan's fresh timeline producer (#1641 D2). Preparation only. + pub(crate) fn set_timeline(&mut self, producer: TimelineProducer) { + self.timeline = Some(producer); + } + + /// 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. + #[cfg(feature = "test-support")] + pub fn test_only_timeline_producer(&mut self) -> Option<&mut TimelineProducer> { + self.timeline.as_mut() } fn id(&self, source: &ControlSource) -> &[u8] { @@ -307,7 +326,9 @@ impl SourceControlSet { } /// Move into this set the producer of every source it holds vacant, from `predecessor`'s - /// entry with the same ID, leaving that entry vacant. Returns how many producers moved. + /// entry with the same ID, leaving that entry vacant. Returns how many source producers + /// moved. A vacant timeline producer moves by the same rule (#1641 D2) and is not counted: + /// every successor carries the timeline. /// /// A host calls this after preparing a successor with /// [`crate::prepare_host_runtime_successor`] (or its live-control form) and after its last @@ -333,6 +354,9 @@ impl SourceControlSet { moved += 1; } } + if self.timeline.is_none() { + self.timeline = predecessor.timeline.take(); + } moved } diff --git a/crates/host-core/tests/successor_swap.rs b/crates/host-core/tests/successor_swap.rs index e6e93541b..a9b5fda23 100644 --- a/crates/host-core/tests/successor_swap.rs +++ b/crates/host-core/tests/successor_swap.rs @@ -51,6 +51,18 @@ fn strip_moves(moves: u64) -> u64 { moves * core::mem::size_of::() as u64 } +/// The command queue a successor's carried timeline brings (#1641 D7): every successor's carried +/// source bytes hold it, as they hold a carried ring's overhead, so a successor that carries no +/// ring still carries these bytes. +fn timeline_queue_bytes() -> u64 { + source::SourceTimeline::resource_report( + engine::QuantumFrames(QUANTUM as u32), + core::num::NonZeroUsize::MIN, + ) + .expect("timeline report") + .command_queue_bytes +} + fn id(value: &str) -> StableId { StableId::parse(value).expect("stable ID") } @@ -188,7 +200,7 @@ fn added_muted_track_keeps_playing(backend: Backend) { // source bytes are exactly a fresh plan's. let fresh = b.prepare(backend).report; let report = run.successor; - assert!(report.carried_source_total_bytes > 0); + assert!(report.carried_source_total_bytes > timeline_queue_bytes()); assert_eq!( report.source_total_bytes + report.carried_source_total_bytes, fresh.source_total_bytes, @@ -293,15 +305,22 @@ fn a_changed_source_gets_its_own_ring() { .prepare_successor(&predecessor.inventory, &a.model, &caps(), backend) .expect("successor"); let fresh = b.prepare(backend).report; - assert_eq!(prepared.report.carried_source_total_bytes, 0); + // The timeline's carried command queue only (#1641 D7). + assert_eq!( + prepared.report.carried_source_total_bytes, + timeline_queue_bytes() + ); assert_eq!( prepared.report.carry_program_retained_bytes, 2 * core::mem::size_of::() as u64 + strip_moves(4), "no source move; the two unchanged input sections (#1276) and the fader and matrix \ stage of both strips (#1277)" ); + // Allocated plus carried is a fresh plan's: the ring is new, and only the timeline's + // command queue is carried (#1641 D7). assert_eq!( - prepared.report.source_total_bytes, fresh.source_total_bytes, + prepared.report.source_total_bytes + prepared.report.carried_source_total_bytes, + fresh.source_total_bytes, "{backend:?}: the successor allocates and charges a new ring" ); let mut predecessor_sources = predecessor.sources; @@ -329,7 +348,9 @@ fn a_successor_charges_allocated_and_carried_rings_to_the_source_cap() { .prepare_successor(&predecessor.inventory, &a.model, &caps(), backend) .expect("successor") .report; - assert!(roomy.source_total_bytes > 0 && roomy.carried_source_total_bytes > 0); + assert!( + roomy.source_total_bytes > 0 && roomy.carried_source_total_bytes > timeline_queue_bytes() + ); let required = roomy.source_total_bytes + roomy.carried_source_total_bytes; let mut exact = caps(); exact.maximum_source_total_bytes = required; @@ -344,7 +365,7 @@ fn a_successor_charges_allocated_and_carried_rings_to_the_source_cap() { assert_eq!(refused.as_bytes(), b"host.source.resource.limit\t$\n"); // The overhead cap counts carried overhead too. - assert!(roomy.carried_source_overhead_bytes > 0); + assert!(roomy.carried_source_overhead_bytes > timeline_queue_bytes()); let mut below = caps(); below.maximum_source_overhead_bytes = roomy.source_overhead_bytes + roomy.carried_source_overhead_bytes - 1; @@ -369,7 +390,7 @@ fn another_ring_size_or_changed_content_carries_no_ring() { .prepare_successor(&predecessor.inventory, &a.model, &caps(), backend) .expect("successor"); assert!( - same.report.carried_source_total_bytes > 0, + same.report.carried_source_total_bytes > timeline_queue_bytes(), "the control case carries" ); let mut smaller = caps(); @@ -377,7 +398,11 @@ fn another_ring_size_or_changed_content_carries_no_ring() { let resized = b .prepare_successor(&predecessor.inventory, &a.model, &smaller, backend) .expect("successor with smaller rings"); - assert_eq!(resized.report.carried_source_total_bytes, 0); + // The timeline's carried command queue only (#1641 D7). + assert_eq!( + resized.report.carried_source_total_bytes, + timeline_queue_bytes() + ); let mut c_model = session_a(); let template = c_model.sources[0].clone(); @@ -390,7 +415,10 @@ fn another_ring_size_or_changed_content_carries_no_ring() { let changed = c .prepare_successor(&predecessor.inventory, &a.model, &caps(), backend) .expect("successor with changed content"); - assert_eq!(changed.report.carried_source_total_bytes, 0); + assert_eq!( + changed.report.carried_source_total_bytes, + timeline_queue_bytes() + ); } /// #1272 follow-up MINOR 4. Red if the largest-allocation fold leaves out a carried ring: with @@ -415,7 +443,7 @@ fn the_largest_allocation_counts_a_carried_ring() { .prepare_successor(&predecessor.inventory, &a.model, &long_rings, backend) .expect("successor") .report; - assert!(successor.carried_source_total_bytes > 0); + assert!(successor.carried_source_total_bytes > timeline_queue_bytes()); assert_eq!( successor.largest_engine_allocation_bytes, fresh.largest_engine_allocation_bytes @@ -511,6 +539,80 @@ fn the_swap_block_allocates_and_frees_nothing() { } } +/// #1641 gate 4, through the plan exchange. The successor adds a latent track, so its timeline's +/// history is longer than the predecessor's, and the predecessor's timeline holds an anchored seek +/// across the swap. The swap block, which swaps the timeline's seek state, queue and position and +/// copies its history, and the ten blocks after it, make no allocator call. +/// +/// Test value: red if the timeline carry or its history write allocates or frees on the render +/// thread of the real plan exchange. +#[test] +fn the_timeline_carry_allocates_and_frees_nothing_through_the_exchange() { + 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); + engine::realtime::audit::warm_up(); + let a = Session::compile(session_a()); + let mut b_model = session_a(); + add_track(&mut b_model, "eq0", MUTED_TRACK, SOURCE, true); + add_limiter(&mut b_model, MUTED_TRACK, "timeline-limiter"); + let b = Session::compile(b_model); + const EXTRA: usize = 5; + let feed = Feed::with_blocks(SOURCE, 1, BLOCKS + EXTRA + 1); + let backend = Backend::current(); + let mut predecessor = a.prepare(backend); + predecessor + .sources + .test_only_timeline_producer() + .expect("a fresh plan's timeline producer") + .try_seek(source::SourceCommand::SeekAt { + generation: source::SourceGeneration(2), + frame: source::SourceFrame(4_096), + anchor_sample: ((SWAP_BLOCK + 2) * QUANTUM) as u64, + }) + .expect("an anchored timeline seek"); + let mut run = successor::swapped_run_with( + predecessor, + |inventory| { + b.prepare_successor(inventory, &a.model, &caps(), backend) + .unwrap_or_else(|failure| panic!("successor: {failure:?}")) + }, + &[&feed], + &[&feed], + Successor::Carry, + ); + assert_eq!(run.blocks[SWAP_BLOCK].carry, CarryOutcome::Carried); + assert!( + run.successor_sources + .test_only_timeline_producer() + .is_some(), + "the producer moved" + ); + let mut allocators: Vec<(u64, u64, u64)> = run.blocks[SWAP_BLOCK..] + .iter() + .map(|block| block.allocator) + .collect(); + for block in BLOCKS..BLOCKS + EXTRA { + feed.submit(&mut run.successor_sources, 48_000, block); + allocators.push(successor::render(&mut run.owner).allocator); + } + assert_eq!(allocators.len(), 11, "the swap block and ten after it"); + for (index, allocator) in allocators.into_iter().enumerate() { + assert_eq!( + allocator, + (0, 0, 0), + "block {index} from the swap: allocator calls" + ); + } +} + // Issue #1274 (slice 5 of #1269): an anchored seek, `SourceControlSet::seek_at`, holds a source's // new generation until the block that starts at its anchor sample on the plan's absolute render // clock, which the swap continues. @@ -2377,6 +2479,7 @@ fn a_bad_input_move_is_refused_whole() { GraphCarryProgram { predecessor: predecessor.inventory.plan_identity(), sources: Box::default(), + timeline: false, }, ) .expect("a program that moves no source"); diff --git a/crates/host-core/tests/timeline.rs b/crates/host-core/tests/timeline.rs new file mode 100644 index 000000000..e713f6cb0 --- /dev/null +++ b/crates/host-core/tests/timeline.rs @@ -0,0 +1,711 @@ +//! Issue #1641: every plan carries one timeline consumer, the session's playhead, which the +//! source driver advances at block entry before the sources, and which a successor carries as it +//! carries an unchanged source. +//! +//! * **Gate 1, source and timeline agree.** The same anchored and then plain seek, pushed to a +//! playing source and to the timeline, puts the frame each block's source read starts at equal +//! to the timeline at that block's source-read sample, for ten blocks after each. +//! * **Gate 2, carried across a rebuild.** A successor continues the timeline with no gap or +//! repeat, answers the predecessor's values over its own window, sizes its history by its own +//! latency, and takes the predecessor's producer. +//! * **Gate 3, carried at a discontinuity and a prime.** A declared discontinuity keeps sources +//! and timeline in step under later seeks; a prime of two blocks advances the timeline as it +//! advances a source consumer's frames, and both refuse the same windows (B16-R3). +//! +//! The session is the fixture reduced to one audible track `eq0` on [`SOURCE`], with no effect +//! and no filter, fed a ramp whose every sample of source frame `f` is `f`: the output is a fixed +//! gain of the frame, so each block's first output sample names the frame its source read +//! started at. + +#![cfg(feature = "test-support")] + +#[path = "support/successor.rs"] +mod successor; + +use core::num::NonZeroUsize; + +use bench_support::alloc as bench_alloc; +use engine::QuantumFrames; +use engine::realtime::{CarryOutcome, PlanarBufferMut, PreparedRenderPlan, RenderIo}; +use graph_compiler::Backend; +use host_core::{ + PlanStateInventory, PreparedHost, SourceControlSet, SourceSubmission, SuccessorBase, + test_only_prepare_host_runtime_successor_on, test_only_set_source_read_offset, + test_only_timeline_at, +}; +use session::{ + Effect, EffectIdentity, EffectParam, EffectQuality, LinkMode, ParameterChannel, ParameterUnit, + RouteSource, SessionModel, SidechainDeclaration, StableId, parse_session_json, +}; +use source::{ + PcmSourceRing, PcmSourceRingConfig, SourceCommand, SourceFrame, SourceGeneration, + SourceTimeline, TimelineProducer, +}; +use successor::{QUANTUM, SUCCESSOR_REVISION, Session, caps}; + +const FIXTURE: &str = include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); +const SOURCE: &str = "fixture-source"; +const ADDED_TRACK: &str = "added"; +const Q: u64 = QUANTUM as u64; +/// The frame the anchored seek jumps to. +const ANCHORED_FRAME: u64 = 160 * Q; +/// The frame the plain seek jumps to. +const PLAIN_FRAME: u64 = 240 * Q; +/// Blocks checked after each seek. +const CHECKED: usize = 10; + +fn id(value: &str) -> StableId { + StableId::parse(value).expect("stable ID") +} + +/// Track `eq0` on [`SOURCE`], both console sections empty, no inserts, input filters off. +fn one_track() -> 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 +} + +/// `model` plus a muted copy of `eq0` on [`SOURCE`], with its own route: the successor's edit. +fn with_added_track(mut model: SessionModel) -> SessionModel { + let mut added = model.tracks[0].clone(); + added.id = id(ADDED_TRACK); + added.fader.left_mute = true; + added.fader.right_mute = true; + model.tracks.push(added); + let mut route = model.routes[0].clone(); + route.id = id(&format!("{ADDED_TRACK}-main")); + let RouteSource::Track { track_id, .. } = &mut route.source else { + panic!("a track route"); + }; + *track_id = id(ADDED_TRACK); + model.routes.push(route); + model +} + +/// `model` with a true-peak limiter insert of `lookahead_ms` lookahead on `track`: a latent node. +fn with_limiter(mut model: SessionModel, track: &str, lookahead_ms: f32) -> SessionModel { + let param = |parameter_id, unit, value| EffectParam { + parameter_id, + channel: ParameterChannel::Both, + unit, + value, + }; + let strip = model + .tracks + .iter_mut() + .find(|strip| strip.id.as_str() == track) + .expect("the track"); + strip.inserts.effects.push(Effect { + id: id(&format!("{track}-limiter")), + identity: EffectIdentity::Native { + effect_id: id("miso.true-peak-limiter"), + }, + quality: EffectQuality::Normal, + bypass: false, + link_mode: LinkMode::Maximum, + params: vec![ + param(1, ParameterUnit::Db, -8.0), + param(2, ParameterUnit::Milliseconds, 60.0), + param(3, ParameterUnit::Milliseconds, lookahead_ms), + ], + sidechain: SidechainDeclaration::None, + }); + model +} + +/// The timeline's `K` the plan's inventory implies (#1641 D1): `⌈A_max / q⌉ + 1`. +fn history_blocks(inventory: &PlanStateInventory) -> u64 { + let largest = inventory + .input_floors() + .iter() + .map(|(_, arrival)| *arrival) + .max() + .unwrap_or(0); + largest.div_ceil(Q) + 1 +} + +/// Submit source frames `frames` of the ramp at `generation`, one quantum per chunk. +fn submit(sources: &mut SourceControlSet, generation: u64, frames: core::ops::Range) { + for start in frames.step_by(QUANTUM) { + let plane: Vec = (start..start + Q).map(|frame| frame as f32).collect(); + sources + .submit( + SOURCE.as_bytes(), + SourceSubmission { + generation, + start_frame: start, + sample_rate_hz: 48_000, + planes: &[&plane, &plane], + frames: QUANTUM as u32, + end_of_region: false, + }, + ) + .unwrap_or_else(|error| panic!("generation {generation} frame {start}: {error:?}")); + } +} + +/// One rendered block: its render sample and its right output channel (the fixture pans `eq0` +/// right, so its left output is near zero). +struct Block { + sample: u64, + right: Vec, + allocator: (u64, u64, u64), +} + +fn render(plan: &mut PreparedRenderPlan) -> Block { + let mut output = [f32::NAN; QUANTUM * 2]; + let sample = plan.next_absolute_sample(); + let mark = bench_alloc::current_thread_counters(); + plan.render_contiguous( + RenderIo { + output: PlanarBufferMut::try_new(&mut output, 2, QUANTUM, QUANTUM).expect("output"), + }, + sample, + ) + .unwrap_or_else(|error| panic!("render: {error:?}")); + let delta = bench_alloc::current_thread_delta_since(mark); + Block { + sample, + right: output[QUANTUM..].to_vec(), + allocator: (delta.allocations, delta.reallocations, delta.deallocations), + } +} + +/// The output gain of the ramp: block 1 of a fresh run starts at frame `Q`. +fn gain_of(block: &Block) -> f64 { + let gain = f64::from(block.right[0]) / Q as f64; + assert!(gain > 0.1, "the source plays: gain {gain}"); + gain +} + +/// The frame a block's source read started at, from its first output sample; checks that its +/// last sample is the same read, `Q - 1` frames on. +fn frame_of(block: &Block, gain: f64) -> i64 { + let decode = |sample: f32| (f64::from(sample) / gain).round() as i64; + let first = decode(block.right[0]); + assert_eq!( + decode(block.right[QUANTUM - 1]), + first + Q as i64 - 1, + "block at {}: one contiguous read", + block.sample + ); + first +} + +fn timeline(plan: &mut PreparedRenderPlan, source_read_sample: u64) -> i64 { + test_only_timeline_at(plan, source_read_sample).expect("a graph plan with a timeline") +} + +fn producer(sources: &mut SourceControlSet) -> &mut TimelineProducer { + sources + .test_only_timeline_producer() + .expect("the set holds the timeline producer") +} + +/// Push `command` to the source (through its control set) and to the timeline (through its +/// producer directly). +fn seek_both(sources: &mut SourceControlSet, command: SourceCommand) { + match command { + SourceCommand::Seek { generation, frame } => { + sources.seek(SOURCE.as_bytes(), generation.0, frame.0) + } + 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"); +} + +/// Render `blocks` blocks of `plan` (whose source-read clock leads its render clock by `offset`) +/// and check, after each, that the frame its source read started at is the timeline at its +/// source-read sample. Returns the frames. +fn render_in_step( + plan: &mut PreparedRenderPlan, + offset: u64, + gain: f64, + blocks: usize, +) -> Vec { + (0..blocks) + .map(|_| { + let block = render(plan); + let frame = frame_of(&block, gain); + assert_eq!( + timeline(plan, block.sample + offset), + frame, + "block at render sample {}: the timeline and the source's frame", + block.sample + ); + frame + }) + .collect() +} + +/// Gate 1. +/// +/// Test value: red if the timeline observes a seek one block before or after a source given the +/// same seek (an automation curve would sit one quantum off its audio), or ignores the anchor. +#[test] +fn a_source_and_the_timeline_given_the_same_seeks_read_the_same_frame_in_every_block() { + let session = Session::compile(one_track()); + let mut host = session.prepare(Backend::current()); + const SEEK_BLOCK: u64 = 2; + const ANCHOR_BLOCK: u64 = 6; + // Generation 1 up to the anchor, queued before the seek makes it stale. + submit(&mut host.sources, 1, 0..ANCHOR_BLOCK * Q); + render(&mut host.plan); + let gain = gain_of(&render(&mut host.plan)); + assert_eq!(host.plan.next_absolute_sample(), SEEK_BLOCK * Q); + + let anchor = ANCHOR_BLOCK * Q; + seek_both( + &mut host.sources, + SourceCommand::SeekAt { + generation: SourceGeneration(2), + frame: SourceFrame(ANCHORED_FRAME), + anchor_sample: anchor, + }, + ); + submit( + &mut host.sources, + 2, + ANCHORED_FRAME..ANCHORED_FRAME + CHECKED as u64 * Q, + ); + let frames = render_in_step(&mut host.plan, 0, gain, CHECKED); + // Not vacuous: the source plays on to the anchor block and jumps there. + let jump = (ANCHOR_BLOCK - SEEK_BLOCK) as usize; + assert_eq!(frames[jump - 1], ((ANCHOR_BLOCK - 1) * Q) as i64); + assert_eq!(frames[jump], ANCHORED_FRAME as i64); + + seek_both( + &mut host.sources, + SourceCommand::Seek { + generation: SourceGeneration(3), + frame: SourceFrame(PLAIN_FRAME), + }, + ); + submit( + &mut host.sources, + 3, + PLAIN_FRAME..PLAIN_FRAME + CHECKED as u64 * Q, + ); + let frames = render_in_step(&mut host.plan, 0, gain, CHECKED); + assert_eq!( + frames[0], PLAIN_FRAME as i64, + "a plain seek lands in the next block" + ); +} + +/// `b` prepared as the successor of `a`'s plan `inventory` at `discontinuity`. +fn successor_of( + b: &Session, + a: &Session, + inventory: &PlanStateInventory, + discontinuity: bool, +) -> PreparedHost { + test_only_prepare_host_runtime_successor_on( + &b.compiled, + &caps(), + SuccessorBase { + discontinuity, + ..SuccessorBase::new(inventory, &a.model, SUCCESSOR_REVISION) + }, + Backend::current(), + ) + .unwrap_or_else(|failure| panic!("successor: {failure:?}")) +} + +/// Hand `predecessor`'s producers and plan state to `successor` as the C ABI does at commit and +/// at the swap block. Returns the swap block's carry outcome. +fn hand_over(successor: &mut PreparedHost, predecessor: &mut PreparedHost) -> CarryOutcome { + successor.sources.adopt_persisting(&mut predecessor.sources); + successor + .plan + .take_revision_gate() + .expect("the successor's gate handle") + .publish(SUCCESSOR_REVISION); + successor.plan.adopt_predecessor_plan(&mut predecessor.plan) +} + +/// Render `blocks` blocks of `host`, feeding generation 1's ramp one block ahead of the source's +/// frame `*fed`. +fn render_fed(host: &mut PreparedHost, fed: &mut u64, blocks: usize) { + for _ in 0..blocks { + submit(&mut host.sources, 1, *fed..*fed + Q); + *fed += Q; + render(&mut host.plan); + } +} + +/// Gate 2, the edit the spec names: one added track, no latency on either side. +/// +/// Test value: red if a successor restarts the timeline at 0, drops its history or its producer, +/// or repeats or skips a block at the swap. +#[test] +fn a_rebuild_carries_the_timeline_its_history_and_its_producer() { + let a = Session::compile(one_track()); + let b = Session::compile(with_added_track(one_track())); + let mut predecessor = a.prepare(Backend::current()); + let mut fed = 0; + submit(&mut predecessor.sources, 1, 0..Q); + fed += Q; + render_fed(&mut predecessor, &mut fed, 20); + const SOUGHT: u64 = 1_000 * Q; + producer(&mut predecessor.sources) + .try_seek(SourceCommand::Seek { + generation: SourceGeneration(2), + frame: SourceFrame(SOUGHT), + }) + .expect("timeline seek"); + render_fed(&mut predecessor, &mut fed, 5); + let now = predecessor.plan.next_absolute_sample(); + assert_eq!(now, 25 * Q); + // The predecessor's value at every sample of the window a successor of `K` blocks keeps. + let mut successor = successor_of(&b, &a, &predecessor.inventory, false); + let k = history_blocks(&successor.inventory); + assert_eq!(k, 1, "no latency: one block of history"); + let window: Vec<(u64, i64)> = (now - k * Q..now) + .map(|sample| (sample, timeline(&mut predecessor.plan, sample))) + .collect(); + assert_eq!( + window[0].1, + (SOUGHT + 4 * Q) as i64, + "the predecessor played the seek" + ); + + assert_eq!( + hand_over(&mut successor, &mut predecessor), + CarryOutcome::Carried + ); + for &(sample, value) in &window { + assert_eq!( + timeline(&mut successor.plan, sample), + value, + "sample {sample}" + ); + } + // No gap or repeat: ten blocks continue a quantum apart. + for block in 0..10_u64 { + submit(&mut successor.sources, 1, fed..fed + Q); + fed += Q; + let sample = render(&mut successor.plan).sample; + assert_eq!( + timeline(&mut successor.plan, sample), + (SOUGHT + (5 + block) * Q) as i64, + "successor block {block}" + ); + } + // The producer moved: the predecessor's set holds none, and a seek through the successor's + // set (its generation strictly above the predecessor's last) reaches the carried consumer. + assert!(predecessor.sources.test_only_timeline_producer().is_none()); + let moved = producer(&mut successor.sources); + assert_eq!(moved.active_generation(), SourceGeneration(2)); + moved + .try_seek(SourceCommand::Seek { + generation: SourceGeneration(3), + frame: SourceFrame(7 * Q), + }) + .expect("a seek through the successor's set"); + let sample = render(&mut successor.plan).sample; + assert_eq!(timeline(&mut successor.plan, sample), (7 * Q) as i64); +} + +/// Gate 2, a successor with a larger `A_max`: its history is its own, of its own `K`. +/// +/// Test value: red if the successor sizes its history from the predecessor's `K` (a seek its +/// latent node can still be reading falls out of the window), or does not keep the predecessor's +/// newest entries. +#[test] +fn a_latent_successor_keeps_the_newest_entries_in_a_history_of_its_own_size() { + // `eq0` behind a limiter in both plans; the successor's added track has a longer one. + let a_model = with_limiter(one_track(), "eq0", 4.0); + let a = Session::compile(a_model.clone()); + let b = Session::compile(with_limiter(with_added_track(a_model), ADDED_TRACK, 10.0)); + let mut predecessor = a.prepare(Backend::current()); + let k_a = history_blocks(&predecessor.inventory); + let mut fed = 0; + submit(&mut predecessor.sources, 1, 0..Q); + fed += Q; + render_fed(&mut predecessor, &mut fed, 8); + producer(&mut predecessor.sources) + .try_seek(SourceCommand::Seek { + generation: SourceGeneration(2), + frame: SourceFrame(500 * Q), + }) + .expect("timeline seek"); + render_fed(&mut predecessor, &mut fed, 2); + let mut successor = successor_of(&b, &a, &predecessor.inventory, false); + let k_b = history_blocks(&successor.inventory); + assert!( + k_a >= 2 && k_b >= k_a + 2, + "the successor reads further back: K {k_a} -> {k_b}" + ); + let now = predecessor.plan.next_absolute_sample(); + // The successor's whole window: the predecessor's `K` newest entries, the rest extrapolated + // back from the oldest of them, exactly as the predecessor answers. + let window: Vec<(u64, i64)> = (now - k_b * Q..now) + .map(|sample| (sample, timeline(&mut predecessor.plan, sample))) + .collect(); + assert_eq!( + hand_over(&mut successor, &mut predecessor), + CarryOutcome::Carried + ); + for &(sample, value) in &window { + assert_eq!( + timeline(&mut successor.plan, sample), + value, + "sample {sample}" + ); + } + + // A seek after the swap, then `K_b - 2` blocks: the block before the seek is still in the + // successor's window, so its value is exact, not extrapolated back from the seek. + let before = successor.plan.next_absolute_sample(); + render_fed(&mut successor, &mut fed, 1); + let unsought = timeline(&mut successor.plan, before); + producer(&mut successor.sources) + .try_seek(SourceCommand::Seek { + generation: SourceGeneration(3), + frame: SourceFrame(9 * Q), + }) + .expect("timeline seek"); + render_fed(&mut successor, &mut fed, (k_b - 1) as usize); + assert_eq!(timeline(&mut successor.plan, before), unsought); + assert_eq!(timeline(&mut successor.plan, before + Q), (9 * Q) as i64); + assert_ne!( + unsought, + (9 * Q) as i64 - Q as i64, + "the exact value differs from the extrapolation, so the check discriminates" + ); +} + +/// Gate 3, a declared discontinuity. +/// +/// Test value: red if a discontinuity successor moves the sources and not the timeline (it +/// restarts it, or steps it back with the source-read clock), or reads the overlap's older entry. +#[test] +fn a_discontinuity_keeps_the_sources_and_the_timeline_in_step() { + const OFFSET: u64 = 3 * Q; + let a = Session::compile(one_track()); + let mut predecessor = a.prepare(Backend::current()); + test_only_set_source_read_offset(&mut predecessor, OFFSET); + let mut fed = 0; + submit(&mut predecessor.sources, 1, 0..2 * Q); + fed += 2 * Q; + render(&mut predecessor.plan); + let gain = gain_of(&render(&mut predecessor.plan)); + for _ in 0..4 { + submit(&mut predecessor.sources, 1, fed..fed + Q); + fed += Q; + render_in_step(&mut predecessor.plan, OFFSET, gain, 1); + } + let mut successor = successor_of(&a, &a, &predecessor.inventory, true); + assert_eq!(successor.plan.source_read_offset(), 0); + assert_eq!( + hand_over(&mut successor, &mut predecessor), + CarryOutcome::Carried + ); + // The source-read clock steps back by `OFFSET`; the timeline and the source continue, and + // the overlap's samples read the newest entry, the block render played last. + for _ in 0..2 { + submit(&mut successor.sources, 1, fed..fed + Q); + fed += Q; + let frames = render_in_step(&mut successor.plan, 0, gain, 1); + assert_eq!(frames[0], (fed - Q) as i64, "no frame lost or repeated"); + } + + let anchor = successor.plan.next_absolute_sample() + 3 * Q; + // Generation 1 up to the anchor, queued before the seek makes it stale. + submit(&mut successor.sources, 1, fed..fed + 3 * Q); + seek_both( + &mut successor.sources, + SourceCommand::SeekAt { + generation: SourceGeneration(2), + frame: SourceFrame(ANCHORED_FRAME), + anchor_sample: anchor, + }, + ); + submit( + &mut successor.sources, + 2, + ANCHORED_FRAME..ANCHORED_FRAME + CHECKED as u64 * Q, + ); + let frames = render_in_step(&mut successor.plan, 0, gain, CHECKED); + assert_eq!(frames[3], ANCHORED_FRAME as i64, "the anchored seek lands"); + seek_both( + &mut successor.sources, + SourceCommand::Seek { + generation: SourceGeneration(3), + frame: SourceFrame(PLAIN_FRAME), + }, + ); + submit( + &mut successor.sources, + 3, + PLAIN_FRAME..PLAIN_FRAME + CHECKED as u64 * Q, + ); + let frames = render_in_step(&mut successor.plan, 0, gain, CHECKED); + assert_eq!(frames[0], PLAIN_FRAME as i64, "the plain seek lands"); +} + +/// Gate 3, a prime (#1641 D5, B16-R3), on the two consumers directly: no prime path above the +/// consumer exists until #1355. +/// +/// Test value: red if a prime moves a source and not the timeline, or the timeline primes over a +/// queued command or a held anchored seek inside the window (it would skip that seek). +#[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), + }) + .expect("ring"); + let mut host = producer.into_host_chunk_provider(engine::SampleRateHz(48_000)); + for start in (0..6 * Q).step_by(QUANTUM) { + let plane: Vec = (start..start + Q).map(|frame| frame as f32).collect(); + host.submit(source::HostPlanarChunk { + sample_rate_hz: engine::SampleRateHz(48_000), + generation: SourceGeneration(1), + start_frame: SourceFrame(start), + planes: &[&plane], + frames: QUANTUM as u32, + end_of_region: false, + }) + .expect("source PCM"); + } + let (mut timeline_producer, mut timeline, _) = + SourceTimeline::prepare(quantum, NonZeroUsize::new(4).expect("four")).expect("timeline"); + // Two ordinary blocks. + for sample in [0, Q] { + source.begin_block_at(sample); + timeline.begin_block_at(sample); + } + let first = 2 * Q; + let (k, lead) = (2_u64, 1_u32); + assert!(source.prime_ready(first, lead) && timeline.prime_ready(first, lead)); + let before = ( + source.telemetry().cumulative_read_frames, + timeline.position(), + ); + for j in 0..k { + let sample = first + j * Q; + source.prime_block_at(sample); + timeline.prime_block_at(sample); + let played = source.played_plane(0).expect("a played block")[0]; + assert_eq!( + timeline.timeline_at(sample), + played as i64, + "prime block {j}" + ); + } + assert_eq!( + source.telemetry().cumulative_read_frames - before.0, + k * Q, + "the source primes 2q frames" + ); + assert_eq!( + timeline.position() - before.1, + k * Q, + "the timeline primes 2q" + ); + + // B16-R3: a held anchored seek inside the window refuses both; one past the window does not. + let next = first + k * Q; + let held = |generation, anchor_sample| SourceCommand::SeekAt { + generation: SourceGeneration(generation), + frame: SourceFrame(0), + anchor_sample, + }; + // Queued and not yet observed: a queued command refuses both. + host.try_seek(held(2, next + Q)).expect("source seek"); + timeline_producer + .try_seek(held(2, next + Q)) + .expect("timeline seek"); + assert!(!source.prime_ready(next, lead) && !timeline.prime_ready(next, lead)); + // Observed and held, anchored at the window's last block: still refused, by both. + source.begin_block_at(next); + timeline.begin_block_at(next); + assert!(!source.prime_ready(next + Q, 0) && !timeline.prime_ready(next + Q, 0)); + // Anchored past the window: ready. + timeline_producer + .try_seek(held(3, next + 4 * Q)) + .expect("timeline seek"); + timeline.begin_block_at(next + Q); + assert!(timeline.prime_ready(next + 2 * Q, lead)); + assert!(!timeline.prime_ready(next + 2 * Q, lead + 1)); +} + +/// Gate 4 on the direct path: a carry that copies a history of its own size and moves a held +/// anchored seek, and the ten blocks after it, allocate and free nothing. +/// +/// Test value: red if the carry or the history write allocates or frees on the render thread. +#[test] +fn the_timeline_carry_and_the_blocks_after_it_allocate_and_free_nothing() { + 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); + engine::realtime::audit::warm_up(); + let a_model = with_limiter(one_track(), "eq0", 4.0); + let a = Session::compile(a_model.clone()); + let b = Session::compile(with_limiter(with_added_track(a_model), ADDED_TRACK, 10.0)); + let mut predecessor = a.prepare(Backend::current()); + let mut fed = 0; + submit(&mut predecessor.sources, 1, 0..Q); + fed += Q; + render_fed(&mut predecessor, &mut fed, 4); + let anchor = predecessor.plan.next_absolute_sample() + 3 * Q; + producer(&mut predecessor.sources) + .try_seek(SourceCommand::SeekAt { + generation: SourceGeneration(2), + frame: SourceFrame(77 * Q), + anchor_sample: anchor, + }) + .expect("timeline seek"); + render_fed(&mut predecessor, &mut fed, 1); + let mut successor = successor_of(&b, &a, &predecessor.inventory, false); + successor.sources.adopt_persisting(&mut predecessor.sources); + successor + .plan + .take_revision_gate() + .expect("the successor's gate handle") + .publish(SUCCESSOR_REVISION); + submit(&mut successor.sources, 1, fed..fed + 11 * Q); + let mark = bench_alloc::current_thread_counters(); + let carry = successor.plan.adopt_predecessor_plan(&mut predecessor.plan); + let delta = bench_alloc::current_thread_delta_since(mark); + assert_eq!(carry, CarryOutcome::Carried); + assert_eq!( + (delta.allocations, delta.reallocations, delta.deallocations), + (0, 0, 0), + "the carry" + ); + for block in 0..11 { + let rendered = render(&mut successor.plan); + assert_eq!(rendered.allocator, (0, 0, 0), "successor block {block}"); + } + // The held seek moved with its consumer (D9) and applied at its anchor. + assert_eq!(timeline(&mut successor.plan, anchor), (77 * Q) as i64); +} diff --git a/crates/source/src/lib.rs b/crates/source/src/lib.rs index 0960d91ba..3cc79f130 100644 --- a/crates/source/src/lib.rs +++ b/crates/source/src/lib.rs @@ -1328,6 +1328,32 @@ impl SeekState { .is_some_and(|held| held.anchor_sample < sample) } + /// The seek half of a prime's readiness, which every source-transport consumer shares + /// (#1320 D3, #1641 D5): no command is queued (`queued` is the command queue's count at entry) + /// and no held anchored seek is anchored before the end of the window of `lead_blocks + 1` + /// blocks of `quantum_frames` from `first_sample`. An overflowing window is not clear. + fn prime_window_clear( + &self, + queued: usize, + quantum_frames: u32, + first_sample: u64, + lead_blocks: u32, + ) -> bool { + if queued != 0 { + return false; + } + let Some(window_end) = u64::from(lead_blocks) + .checked_add(1) + .and_then(|blocks| blocks.checked_mul(u64::from(quantum_frames))) + .and_then(|window| first_sample.checked_add(window)) + else { + return false; + }; + // A held anchor is always past the last block's sample, so this is every held seek that + // would apply at one of the blocks, a late one at `first_sample` included. + !self.holds_anchor_before(window_end) + } + /// Whether the held anchored seek, if any, is of `generation`. fn holds_generation(&self, generation: SourceGeneration) -> bool { self.held_seek @@ -1848,20 +1874,12 @@ impl PcmSourceConsumer { /// observes no command. #[must_use] pub fn prime_ready(&self, first_sample: u64, lead_blocks: u32) -> bool { - if self.command_consumer.available_at_entry() != 0 { - return false; - } - let quantum = u64::from(self.quantum_frames); - let Some(window_end) = u64::from(lead_blocks) - .checked_add(1) - .and_then(|blocks| blocks.checked_mul(quantum)) - .and_then(|window| first_sample.checked_add(window)) - else { - return false; - }; - // A held anchor is always past the last block's sample, so this is every held seek that - // would apply at one of the blocks, a late one at `first_sample` included. - if self.seek.holds_anchor_before(window_end) { + if !self.seek.prime_window_clear( + self.command_consumer.available_at_entry(), + self.quantum_frames, + first_sample, + lead_blocks, + ) { return false; } let mut frame = self.next_frame.0; @@ -2122,6 +2140,8 @@ pub struct TimelineResourceReport { pub history_blocks: u64, /// One-slot seek-command SPSC header plus slot: a ring's `command_queue_bytes` formula. pub command_queue_bytes: u64, + /// The largest single allocation of the command queue (#1641 D7: a carried queue's row). + pub command_queue_largest_allocation_bytes: u64, /// The history allocation: `16 * K` bytes. pub history_bytes: u64, /// The [`TimelineConsumer`] value itself, wherever its owner stores it. @@ -2155,13 +2175,13 @@ impl SourceTimeline { let consumer = u64::try_from(size_of::()) .map_err(|_| PcmSourceRingError::PlatformSizeLimit)?; let overhead = checked_u64_add(checked_u64_add(command_queue, history)?, consumer)?; - let largest = largest_queue_allocation::(NonZeroUsize::MIN)? - .max(history) - .max(consumer); + let queue_largest = largest_queue_allocation::(NonZeroUsize::MIN)?; + let largest = queue_largest.max(history).max(consumer); Ok(TimelineResourceReport { history_blocks: u64::try_from(history_blocks.get()) .map_err(|_| PcmSourceRingError::PlatformSizeLimit)?, command_queue_bytes: command_queue, + command_queue_largest_allocation_bytes: queue_largest, history_bytes: history, consumer_bytes: consumer, overhead_bytes: overhead, @@ -2181,11 +2201,8 @@ impl SourceTimeline { let (command_producer, command_consumer) = bounded_spsc(NonZeroUsize::MIN, QueueGeneration(initial_generation.0)) .map_err(map_spsc_error)?; - let mut history = Vec::new(); - history - .try_reserve_exact(history_blocks.get()) - .map_err(|_| PcmSourceRingError::AllocationFailure)?; - history.resize(history_blocks.get(), TimelineEntry::default()); + let consumer = + TimelineConsumer::with_history(quantum_frames, history_blocks, Some(command_consumer))?; Ok(( TimelineProducer { seek: SeekProducer { @@ -2195,20 +2212,24 @@ impl SourceTimeline { frame_limit: Some(TIMELINE_SAMPLE_LIMIT), }, }, - TimelineConsumer { - seek: SeekState::new(initial_generation), - command_consumer, - quantum_frames: quantum_frames.0, - position: 0, - history: history.into_boxed_slice(), - capacity: history_blocks, - oldest: 0, - len: 0, - oldest_expected_timeline: 0, - }, + consumer, report, )) } + + /// Allocate a vacant timeline (#1641 D3): its own history of `history_blocks` entries and no + /// 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. + pub fn prepare_vacant( + quantum_frames: QuantumFrames, + history_blocks: NonZeroUsize, + ) -> Result<(TimelineConsumer, TimelineResourceReport), PcmSourceRingError> { + let report = Self::resource_report(quantum_frames, history_blocks)?; + let consumer = TimelineConsumer::with_history(quantum_frames, history_blocks, None)?; + Ok((consumer, report)) + } } /// The timeline's seek producer (#1640 D4): the shared seek producer and nothing else. Plain and @@ -2273,7 +2294,8 @@ pub struct TimelineStep { /// plan can still be processing to its timeline sample. Every value is an exact integer. pub struct TimelineConsumer { seek: SeekState, - command_consumer: Consumer, + /// `None` while vacant (#1641 D3): a successor's timeline before its carry. + command_consumer: Option>, quantum_frames: u32, /// The timeline sample of the next block. position: u64, @@ -2289,6 +2311,38 @@ pub struct TimelineConsumer { } impl TimelineConsumer { + /// A timeline at generation 1, timeline sample 0, with an empty history of `history_blocks` + /// entries. Control thread only. + fn with_history( + quantum_frames: QuantumFrames, + history_blocks: NonZeroUsize, + command_consumer: Option>, + ) -> Result { + let mut history = Vec::new(); + history + .try_reserve_exact(history_blocks.get()) + .map_err(|_| PcmSourceRingError::AllocationFailure)?; + history.resize(history_blocks.get(), TimelineEntry::default()); + Ok(Self { + seek: SeekState::new(SourceGeneration(1)), + command_consumer, + quantum_frames: quantum_frames.0, + position: 0, + history: history.into_boxed_slice(), + capacity: history_blocks, + oldest: 0, + len: 0, + oldest_expected_timeline: 0, + }) + } + + /// Whether this timeline is vacant: it has no command queue, because it waits for a carry + /// (#1641 D3) or a carry has moved its queue to a successor. + #[must_use] + pub const fn is_vacant(&self) -> bool { + self.command_consumer.is_none() + } + /// The quantum every block advances the timeline by. #[must_use] pub const fn quantum_frames(&self) -> u32 { @@ -2319,13 +2373,59 @@ 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 command = self.command_consumer.try_pop().ok(); + // A vacant timeline has no queue and observes nothing. + let command = match self.command_consumer.as_mut() { + Some(consumer) => consumer.try_pop().ok(), + None => None, + }; let observation = self .seek .observe(command, SeekClock::At { source_read_sample }); if let Some(seek) = observation.apply { self.position = seek.frame.0; } + self.advance(source_read_sample); + } + + /// Whether a prime of `lead_blocks + 1` blocks from `first_sample` can run (#1641 D5, + /// B16-R3): the source consumer's non-PCM test, written once in [`SeekState::prime_window_clear`]. The + /// command queue is empty and no held anchored seek falls before the prime window's end, so a + /// prime never skips a seek. A timeline has no PCM to run out of, so that is the whole test. + #[must_use] + pub fn prime_ready(&self, first_sample: u64, lead_blocks: u32) -> bool { + let queued = self + .command_consumer + .as_ref() + .map_or(0, Consumer::available_at_entry); + self.seek + .prime_window_clear(queued, self.quantum_frames, first_sample, lead_blocks) + } + + /// Advance one block from `source_read_sample` as [`Self::begin_block_at`] would, without + /// observing any command (#1641 D5): a warm successor's prime. It neither pops the command + /// queue nor applies a held seek; it records the history entry and advances one quantum. + /// Precondition, as for [`PcmSourceConsumer::prime_block_at`]: [`Self::prime_ready`] was + /// `true` for the window in this render callback. Allocation-free and wait-free. + pub fn prime_block_at(&mut self, source_read_sample: u64) { + self.advance(source_read_sample); + } + + /// Take `predecessor`'s timeline at a carry (#1641 D3): swap the seek state, the command + /// consumer and the position, and copy the predecessor's newest history entries into this + /// timeline's own history. Nothing is allocated or freed; the predecessor is left vacant. + /// Both timelines have the same quantum. + fn adopt_from(&mut self, predecessor: &mut Self) { + core::mem::swap(&mut self.seek, &mut predecessor.seek); + core::mem::swap( + &mut self.command_consumer, + &mut predecessor.command_consumer, + ); + core::mem::swap(&mut self.position, &mut predecessor.position); + self.copy_history_from(predecessor); + } + + /// Record `(source_read_sample, position)` and advance one quantum. + fn advance(&mut self, source_read_sample: u64) { self.record(TimelineEntry { source_read_sample, timeline: self.position, @@ -2574,6 +2674,8 @@ pub enum SourceGraphSourceSetError { SourceIndex, ChannelIndex, ArithmeticOverflow, + /// The timeline's quantum is not the envelope's (#1641 D1). + TimelineQuantum, } struct GraphSourceEntry { @@ -2584,6 +2686,8 @@ struct GraphSourceEntry { } struct SourceGraphSourceSetDriver { + /// The session's playhead (#1641 D1), inline: the driver's own allocation counts its value. + timeline: TimelineConsumer, sources: Box<[GraphSourceEntry]>, mappings: Box<[SourceGraphTrackMapping]>, quantum_frames: u32, @@ -2686,6 +2790,9 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { source_generation_changed: self.pending_generation_change, }; self.pending_generation_change = false; + // #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); 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 { @@ -2824,6 +2931,10 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { /// 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 /// pending generation change (a seek prepared since its last block) is carried too. + /// + /// A vacant timeline takes the predecessor's (#1641 D3): its seek state, command consumer and + /// position by swap and its newest history entries by copy, into this timeline's own + /// history. The predecessor's timeline must be occupied, or nothing moves. fn adopt_sources( &mut self, predecessor: &mut dyn GraphPreparedSourceSetDriver, @@ -2836,6 +2947,7 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { return false; }; let movable = predecessor.quantum_frames == self.quantum_frames + && (!self.timeline.is_vacant() || !predecessor.timeline.is_vacant()) && moves.iter().all(|&(successor, source)| { match ( self.sources.get(successor as usize), @@ -2858,9 +2970,17 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { &mut predecessor.sources[source as usize].consumer, ); } + if self.timeline.is_vacant() { + self.timeline.adopt_from(&mut predecessor.timeline); + } self.pending_generation_change |= predecessor.pending_generation_change; true } + + /// The timeline sample at `source_read_sample` (#1641 D8), from the timeline's history. + fn timeline_at(&self, source_read_sample: u64) -> Option { + Some(self.timeline.timeline_at(source_read_sample)) + } // REALTIME_POLICY_END fn as_any(&self) -> Option<&dyn core::any::Any> { @@ -2953,18 +3073,36 @@ impl SourceGraphSourceSetDriver { /// Seal one or more prepared source consumers into a graph-owned fan-out source set. /// -/// The wrapper owns exactly one consumer per source. The graph calls `begin_block` once, 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 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 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( envelope: engine::realtime::RenderEnvelope, sources: Vec, + timeline: TimelineConsumer, mappings: Vec, ) -> Result { + if timeline.quantum_frames != envelope.quantum.0 { + return Err(SourceGraphSourceSetError::TimelineQuantum); + } let retained = source_set_retained_resources(&sources, &mappings)?; + let timeline_report = SourceTimeline::resource_report(envelope.quantum, timeline.capacity) + .map_err(|_| SourceGraphSourceSetError::ArithmeticOverflow)?; let mut pcm_payload = 0_u64; - let mut overhead = 0_u64; - let mut largest = 0_u64; + let mut overhead = timeline_report.history_bytes; + let mut largest = timeline_report.history_bytes; + if !timeline.is_vacant() { + overhead = overhead + .checked_add(timeline_report.command_queue_bytes) + .ok_or(SourceGraphSourceSetError::ArithmeticOverflow)?; + largest = largest.max(timeline_report.command_queue_largest_allocation_bytes); + } let mut entries = Vec::with_capacity(sources.len()); for source in sources { // A vacant source owns no ring: it charges no PCM payload and no ring overhead. @@ -3023,6 +3161,7 @@ pub fn prepare_graph_source_set( largest_allocation_bytes: largest, }, Box::new(SourceGraphSourceSetDriver { + timeline, sources: entries.into_boxed_slice(), mappings: mappings.into_boxed_slice(), quantum_frames: envelope.quantum.0, @@ -3038,6 +3177,13 @@ mod tests { const RATE: SampleRateHz = SampleRateHz(48_000); + /// 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) + .expect("timeline") + .1 + } + fn config(channels: u32, quantum: u32, capacity: u64) -> PcmSourceRingConfig { PcmSourceRingConfig { channel_count: channels, @@ -3782,6 +3928,7 @@ mod tests { mappings: Vec, ) -> SourceGraphSourceSetDriver { SourceGraphSourceSetDriver { + timeline: test_timeline(4), sources: vec![GraphSourceEntry { channel_count: consumer.channel_count(), consumer: Some(consumer), @@ -4457,6 +4604,7 @@ mod tests { prepare_graph_source_set( envelope, vec![SourceGraphSource::new(consumer, resources, 0, 0)], + test_timeline(2), mappings, ) .expect("source set") @@ -5008,7 +5156,9 @@ mod tests { mappings: Vec, validity: &Arc, ) -> PreparedRenderPlan { - let set = prepare_graph_source_set(envelope(), sources, mappings).expect("source set"); + let set = + prepare_graph_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))], @@ -5153,6 +5303,7 @@ mod tests { GraphCarryProgram { predecessor, sources: vec![(0, 0)].into_boxed_slice(), + timeline: false, } } @@ -5215,6 +5366,7 @@ mod tests { Some(GraphCarryProgram { predecessor: identity, sources: vec![(0, 1)].into_boxed_slice(), + timeline: false, }) }); assert_eq!(carry, CarryOutcome::PredecessorMismatch); @@ -5290,6 +5442,7 @@ mod tests { let program = |sources: &[(u32, u32)]| GraphCarryProgram { predecessor: 1, sources: sources.into(), + timeline: false, }; assert_eq!( graph::install_carry_program(&mut vacant, program(&[(1, 0)])), @@ -5332,11 +5485,17 @@ 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(envelope(), vec![occupied_source], mappings()) - .expect("occupied set"); + let occupied = prepare_graph_source_set( + envelope(), + vec![occupied_source], + test_timeline(FRAMES), + mappings(), + ) + .expect("occupied set"); let vacant = prepare_graph_source_set( envelope(), vec![SourceGraphSource::vacant(2)], + test_timeline(FRAMES), mappings(), ) .expect("vacant set"); @@ -5360,6 +5519,7 @@ mod tests { prepare_graph_source_set( envelope(), vec![SourceGraphSource::vacant(1)], + test_timeline(FRAMES), mappings() ), Err(SourceGraphSourceSetError::ChannelIndex) @@ -5409,6 +5569,7 @@ mod tests { GraphCarryProgram { predecessor: identity, sources: vec![(0, 0), (1, 5)].into_boxed_slice(), + timeline: false, }, ) .expect("install"); @@ -5437,6 +5598,7 @@ mod tests { GraphCarryProgram { predecessor: 1, sources: vec![(0, 0), (1, 0)].into_boxed_slice(), + timeline: false, }, ), Err(GraphCarryInstallError::DuplicateSourceIndex) @@ -5449,6 +5611,7 @@ mod tests { fn a_vacant_claim_writes_positive_zero_over_dirty_buffers_and_refuses_seek() { let [a, _] = inputs(); let mut driver = SourceGraphSourceSetDriver { + timeline: test_timeline(FRAMES), sources: vec![GraphSourceEntry { consumer: None, channel_count: 2, @@ -5569,6 +5732,7 @@ mod tests { let (_host, source) = filled_source(); let driver = |sources: Vec, quantum_frames| SourceGraphSourceSetDriver { + timeline: test_timeline(quantum_frames), sources: sources.into_boxed_slice(), mappings: Vec::new().into_boxed_slice(), quantum_frames, @@ -5601,6 +5765,7 @@ mod tests { quantum_frames: u32, ) -> SourceGraphSourceSetDriver { SourceGraphSourceSetDriver { + timeline: test_timeline(quantum_frames), sources: entries.into_boxed_slice(), mappings: Vec::new().into_boxed_slice(), quantum_frames, @@ -6453,6 +6618,11 @@ mod tests { assert_eq!(report.history_blocks, 5); assert_eq!(report.history_bytes, 80); assert_eq!(report.command_queue_bytes, ring.command_queue_bytes); + assert_eq!( + report.command_queue_largest_allocation_bytes, + largest_queue_allocation::(NonZeroUsize::MIN).unwrap() + ); + assert!(report.command_queue_largest_allocation_bytes <= report.command_queue_bytes); assert_eq!( report.consumer_bytes, u64::try_from(size_of::()).unwrap() diff --git a/hosts/host-web/tests/browser-v1/expected.json b/hosts/host-web/tests/browser-v1/expected.json index b86b962f4..409b59f71 100644 --- a/hosts/host-web/tests/browser-v1/expected.json +++ b/hosts/host-web/tests/browser-v1/expected.json @@ -69,8 +69,8 @@ "resourceCeilings": { "bridgeMetadataBytes": "1628864", "bridgeRetainedBytes": "1667648", - "sourceTotalBytes": "3648", - "sourceOverheadBytes": "2496", + "sourceTotalBytes": "4352", + "sourceOverheadBytes": "3264", "builtinRetainedBytes": "4288", "graphSessionPlusPlanBytes": "35648", "graphIncrementalPlanBytes": "35648", diff --git a/hosts/host-web/tests/retained_ceilings.rs b/hosts/host-web/tests/retained_ceilings.rs index f077520af..2ea4f222d 100644 --- a/hosts/host-web/tests/retained_ceilings.rs +++ b/hosts/host-web/tests/retained_ceilings.rs @@ -115,6 +115,13 @@ const CAPTURE: u64 = LIVE_RESPONSE_CAPTURE_BYTES as u64; /// the retained row fell from 1,764,878 to 1,631,294 bytes (x86-64; -133,584 and 113 for the ID /// staging, so +113 over the row before B1), and the metadata row to 1,594,186. The new /// beyond-capture figure is 582,718; plus 10 % is 640,989.8, rounded up to 64: 641,024. +/// +/// **Re-pinned: `source_total_bytes` and `source_overhead_bytes` (#1641, batch 16).** Every plan +/// holds the session timeline: the timeline consumer's fixed bytes (#1641 D7), its one-slot command +/// queue, its one-entry history (the fixture has no latency, so `K = 1`) and the consumer inline in +/// the source driver. Both rows rose by 480 bytes (x86-64): overhead 2,658 to 3,138 and total 3,682 +/// to 4,162. The ceilings are the new values plus 10 %, rounded up to 64: 3,451.8 -> 3,456 (was +/// 2,688) and 4,578.2 -> 4,608 (was 3,840). The four-lane values are inferred, as before. const BUDGETS: [Budget; 12] = [ Budget { row: "bridge_metadata_bytes", @@ -131,14 +138,14 @@ const BUDGETS: [Budget; 12] = [ Budget { row: "source_total_bytes", value: |report| report.source_total_bytes, - eight_lanes: 3_840, - four_lanes: 3_840, + eight_lanes: 4_608, + four_lanes: 4_608, }, Budget { row: "source_overhead_bytes", value: |report| report.source_overhead_bytes, - eight_lanes: 2_688, - four_lanes: 2_688, + eight_lanes: 3_456, + four_lanes: 3_456, }, Budget { row: "builtin_retained_bytes", From 90b76cfdfa907ece42aeffa4f8db8a9460277667 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 14:03:01 +0000 Subject: [PATCH 6/8] Fold the batch 16a review (#1640, #1641, #1293) M1: timeline_block reports the first sample where the timeline is not the previous sample plus one, so a source-read step-back at a declared discontinuity is a step (test red on the old reader). M2: a rebuild that carries no source still carries the timeline (red without either flag clause). M3: the timeline no longer blocks the other lanes' carry; it is adopted only from an occupied predecessor into a vacant successor. M4: #1297 D3 sends anchored seeks through the message handler only, and #1294 D1 applies through control_request_ptr / control_command. Minors: adopt_persisting doc states the vacant-timeline exception; two byte guards no longer pass on the timeline queue alone; tests for the rate/quantum guard, a never-adopted timeline, a seek queued at the swap block and the reader's search order; unread TimelineResourceReport rows removed (no charged byte moves); offset_timeline inside its policy region; the seek report pointer export has a behavioural test; the ABI self-test swaps seekReport field names; check-session-map-shape.py excludes _ptr exports as a rule; install_carry_program refuses a timeline carry on a plan with no source set; three tests with no unique catch deleted; private intra-doc links fixed. pcm_digest unchanged. Co-Authored-By: Claude Opus 5.5 --- ...red-seek-from-the-browser-engine-module.md | 34 +- ...ransaction-to-the-browser-control-plane.md | 50 +++ ...-sources-a-browser-edit-adds-or-removes.md | 58 ++++ ...uild-the-timeline-consumer-and-producer.md | 55 ++- ...n-a-timeline-that-carries-like-a-source.md | 49 ++- crates/capi/tests/resource_lifecycle.rs | 4 +- crates/graph/src/lib.rs | 27 +- crates/host-core/src/source.rs | 6 +- crates/host-core/tests/successor_swap.rs | 74 ---- crates/host-core/tests/timeline.rs | 141 +++++++- crates/source/src/lib.rs | 315 +++++++++++++----- hosts/host-web/src/ffi.rs | 66 ++-- hosts/host-web/src/lib.rs | 2 +- hosts/host-web/src/tests.rs | 33 +- scripts/check-abi-layout-v1.py | 14 +- scripts/check-session-map-shape.py | 15 +- 16 files changed, 720 insertions(+), 223 deletions(-) diff --git a/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md b/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md index b00c65333..3ccc2f102 100644 --- a/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md +++ b/.github/ISSUE_SPECS/1293-export-transaction-apply-and-anchored-seek-from-the-browser-engine-module.md @@ -373,15 +373,16 @@ Root ruling R7 (2026-10-16): the record is 64 bytes, the C struct's layout, rese **Departures from the text.** - `scripts/check-session-map-shape.py` (not in the authorized paths) treated every `source_*(handle) -> u32` export as a source-map query, so it flagged `source_seek_report_ptr`. - Mechanical fix: it skips that one name, with a comment. No other behavior changed. + Mechanical fix: it skips record-pointer exports (a name ending in `_ptr`; the fold made the + one-name skip this rule). No other behavior changed. - The plain seek is unchanged. The amendment's hazard did not materialize: the parity row shows the browser's report after a plain seek equals the C ABI's at the call (the old generation) and at every later block (`first_sample` = the first block that begins after the call), at 48 and 96 kHz. `prepare_source_seek` at the call changes what the consumer reads, not the published report. -- Gate 5 for the pointer export is partly unobservable natively: `pointer_u32` truncates a 64-bit - native address to 0, so a native test cannot tell "not ready" from "ready" for - `source_seek_report_ptr`. The not-ready refusal of both call exports is tested; the pointer's - not-ready 0 follows from the same `status().state` check. +- Gate 5 for the pointer export: `pointer_u32` truncates a 64-bit native address to 0, so the + export's value cannot be read natively. The fold split out `seek_report_pointer`, which the + export casts, and the test reads its address before the cast (`test_seek_report_addresses`, the + `test_buffer_address` pattern): the record's address while ready, null once not ready. - Gate 3's "silence until the anchor" is set up with no PCM of the old generation queued. **Gates -> tests.** @@ -401,7 +402,7 @@ Root ruling R7 (2026-10-16): the record is 64 bytes, the C struct's layout, rese **Commands run.** `cargo test --locked -p host-web --features host-web/test-support` (all pass); `cargo clippy --locked -p host-web -p parameter-metadata --all-targets --all-features -- -D -warnings`; `cargo fmt --all`; `build-web-audioworklet.sh --named-twin target/ci/h1293/n +warnings` (two packages only; the workspace-wide clippy runs at the batch gates); `cargo fmt --all`; `build-web-audioworklet.sh --named-twin target/ci/h1293/n target/ci/h1293/a`; `check-web-audioworklet.sh`; `check-sdk-generated.sh target/ci/h1293/a`; `test-web-audioworklet.sh`; `check-workspace-policy.sh`; `cargo run -p realtime-policy`. @@ -411,4 +412,25 @@ target/ci/h1293/a`; `check-web-audioworklet.sh`; `check-sdk-generated.sh target/ **Successors.** The SDK binding for the three exports and the message-handler-only rule for `source_seek_at` (the feed worklet's render-side seek must keep the plain seek) are #1294 and #1297. +Correction (batch 16a fold): at `454d65688` neither spec said so. #1297 D3 had `applySharedSeek`, an +audio-thread caller, call `source_seek_at`, and #1294 D1 still named the dropped `edit_ptr`/`apply` +exports; the fold amends both (M4, outside this record). A `seek_at_unserviced` for a render-side caller stays unbuilt until a caller needs it. + +**Fold (batch 16a review).** + +- **MINOR 8.** `source_seek_report_ptr` has a behavioural test: in + `tests::the_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing` the pointer + (before its `u32` cast) is the record's address while ready and null once the host is failed. + Red if the pointer names anything else while ready, or anything at all once not ready (both run). +- **MINOR 9.** The ABI self-test swaps the `firstSample` and `sourceFrame` names of `seekReport`, + offsets kept, which only the record's name rule catches (red with that rule deleted, run). It + replaces the record-field drop (caught by the generic layout rules) and two of the three seek + export drops; the `source_seek_at` drop stays. 34 mutations caught. +- **MINOR 10.** `check-session-map-shape.py` states the `_ptr` rule in its docstring and code (16 + self-test mutations caught, unchanged). The gate 3/4 test no longer claims to catch another + source's ring (its session has one source). The staged-ID refusal now follows its operation's + bookkeeping: `source_seek_at` records it as `seek_source_at` records its results, and + `source_seek_report` records none, as `read_seek_report` records none (`with_staged_source_id` + returns the refusal to its caller). The gate 5 test checks both; red if the report export + records its refusal (run). diff --git a/.github/ISSUE_SPECS/1294-send-a-session-transaction-to-the-browser-control-plane.md b/.github/ISSUE_SPECS/1294-send-a-session-transaction-to-the-browser-control-plane.md index 4f344d934..ee6959a9a 100644 --- a/.github/ISSUE_SPECS/1294-send-a-session-transaction-to-the-browser-control-plane.md +++ b/.github/ISSUE_SPECS/1294-send-a-session-transaction-to-the-browser-control-plane.md @@ -196,3 +196,53 @@ worklet only renders and swaps. - *Supersede an unadopted candidate plan by compare-and-swap* (#1310). **Decision 17 amendment (2026-10-13).** D1's `miso.document.v1` message and D6's `committedDocument()` wrapper method are withdrawn: #1571 reads the committed document with `SESSION_SNAPSHOT_GET` frames through #1562's `control` message. Every event the control plane stages, `SESSION_COMMITTED` included, reaches the page through #1570's `control-event` message. D2-D5 stay. + +## Amendment (controls coordinator, batch 16a review, 2026-10-16) + +Anchors checked against `35ac17545` plus the batch 16a tree. Earlier text is kept. + +**Finding (review M4).** D1 names exports that #1293 dropped. +- `miso_engine_web_v1_edit_ptr` and `miso_engine_web_v1_apply`, and the "outcome record", do not exist. + Root ruling B15-R8 dropped D1's staging, and decision 17 dropped D2's `apply` and D3's outcome record + (see #1293, "Decision 17 amendment (2026-10-13)" and "B15-R8" in its batch 15 check). +- `miso_engine_web_v1_replace` (#1386 D3) is not built either. #1386 is re-scoped under C1 + (`docs/rulings/one-control-abstraction-2026-10-13.md`, C1 and C13): the replace is a protocol frame + through the same generic entry. Its browser transport is #1697. See #1386's amendments. + +**Rule, replacing D1's call path.** +- A transaction is staged in `miso_engine_web_v1_control_request_ptr` (`hosts/host-web/src/ffi.rs:3450`) + and applied with `miso_engine_web_v1_control_command` (`:3469`), on the control half only. The worklet + already has the staging helper: `readControlCommand` + (`hosts/host-web/web/miso-engine-v1-audio-worklet.js:1727`) and the message `miso.control.v1` + (`CONTROL_FIELDS = ["tag", "requestId", "frame"]`, `:93`; dispatched at `:1116`). +- The outcome is the response frame, read with `miso_engine_web_v1_control_response_ptr` (`ffi.rs:3502`) + and `_control_response_bytes` (`:3515`): `revision`, `edit_path` and the diagnostics. `miso.edit.v1` + `{ result, revision, path, diagnostic }` is built from that frame and from the call's result code. + `path` is the frame's `edit_path`; `diagnostic` is the frame's diagnostic text, empty on success. +- `miso.apply.v1` and `miso.replace.v1` stay as the SDK's internal messages only if the root re-brief + keeps them. They may be dropped for `miso.control.v1` frames. Root decides in the #1386 re-brief; this + issue does not add a message that has no export behind it. + +**Other text that names the dropped exports.** +- Context, the exports bullet (`:47-53`): "the exports come from #1293" is now only `source_seek_at` and + `source_seek_report`. Apply and replace come from #1562's generic entry and #1386/#1697. The service and + watermark exports stay with #1381. +- D2 (`:71-75`): `source_seek_at` is real (`ffi.rs:3311`) and is control half only: it runs the service + step (`crates/control-plane/src/control.rs:4073-4081`) and must never run in `process()`. Its sentence + "Feeds that use the shared PCM ring do not use it" is amended: a ring's anchored seek is applied by this + handler between blocks (#1297 amendment of 2026-10-16). The feed worklet's render path keeps the plain + seek. No render-side caller is added. +- D6 (`:118-120`): `apply(transaction)` and `replace(document)` call the generic entry; they need no + `edit_ptr`. +- Non-goals (`:140`, "every export used here comes from #1293, #1381 or #1386"): read it as #1293 + (`source_seek_at`, `source_seek_report`), #1381, #1562 and #1697. +- Gate 1 (`:145-147`, "calls `edit_ptr` then `apply`"): replace with "stages the frame through + `control_request_ptr`, calls `control_command` once, and replies from the response frame". A malformed + message still calls no export. +- Gate 5 (`:162-163`, "typed result and diagnostic"): read both from the response frame. +- Test value, gate 1 (`:173-174`, "replies before the outcome record is written"): read it as "replies + before the response frame is read, or from a stale response of the previous command". The worklet + has this hazard today (`readControlCommand`, `:1727-1747`, returns an empty response when staging + refuses). +- Dependencies (`:187`, `:193-194`): #1293 supplies only the seek exports. Add #1562 (the generic entry) + and #1697 (document-replace transport). #1386 is a dependency only through its re-brief. diff --git a/.github/ISSUE_SPECS/1297-feed-and-retire-the-sources-a-browser-edit-adds-or-removes.md b/.github/ISSUE_SPECS/1297-feed-and-retire-the-sources-a-browser-edit-adds-or-removes.md index 76e3019aa..6551a130b 100644 --- a/.github/ISSUE_SPECS/1297-feed-and-retire-the-sources-a-browser-edit-adds-or-removes.md +++ b/.github/ISSUE_SPECS/1297-feed-and-retire-the-sources-a-browser-edit-adds-or-removes.md @@ -146,3 +146,61 @@ during its ramp-out. - *Swap and retire browser plans through the Worker's service loop* (#1381). - *Move browser source submission and seeks into the Worker* (#1387). - *Run the browser control plane in a Worker and keep the AudioWorklet render-only* (#1332). + +## Amendment (controls coordinator, batch 16a review, 2026-10-16) + +Anchors checked against `35ac17545` plus the batch 16a tree. Earlier text is kept. + +**Finding (review M4).** D3 has `applySharedSeek` call `miso_engine_web_v1_source_seek_at`. That is +wrong for the feed worklet's render path. +- `applySharedSeek` is called from `drainSharedRing`, which runs in `process()` + (`sdk/src/browser-assets/miso-engine-v1-pcm-feed-worklet.js:366`; the helper is `:466`, its + comment at `:465` says "shared by render and the between-block control handler"). +- The feed worklet is a render-thread caller of the plain seek: `tools/realtime-policy/render-thread-exports.json:10` + lists `source_seek` with the callers `engine/receiveSeek` and `feed/applySharedSeek`. `source_seek_at` + is not on that table and must not be added. +- `source_seek_at` (`hosts/host-web/src/ffi.rs:3311`) calls `SessionState::seek_at` + (`crates/control-plane/src/control.rs:4073-4081`). That runs `service()` first (`:4080`; `service` is + `:1930`). The service step drops retired plans, takes the shared report lock and may allocate. The + doc comment on the export says the same: control half only, never `process()` (`ffi.rs:3299-3303`). + The plain seek uses `seek_unserviced` (`hosts/host-web/src/lib.rs:3630`; `control.rs:4059`) for this + reason. + +**Rule, replacing D3's call.** +- A render-thread caller keeps the plain `miso_engine_web_v1_source_seek`. `applySharedSeek` called from + `drainSharedRing` never calls `source_seek_at`. +- An anchored seek (anchor not `-1`) is applied by the control half's message handler, between blocks: + the Worker in `worker` mode, the worklet's control handler in `single` mode. The handler may reach + `source_seek_at` through #1294 D2's path (`miso.seek-at.v1`, or the same handler code reading the + ring's published generation, frame and anchor). It does not run in `process()`. +- The drain must not consume a ring whose newest seek epoch is anchored and not yet applied by the + handler. It leaves the epoch unseen and reads no slot of that ring (the same wait the drain does + today on `RESULT_BACKPRESSURE`, `:476-480`). Otherwise the drain would feed old-generation PCM to + a source that is about to seek. +- "One seek channel" (D3) still holds: the ring publishes the seek once. The handler applying it is + not a second channel. #1294 D2's sentence that shared-ring feeds do not use `miso.seek-at.v1` is + amended in #1294 in the same batch. +- A render-side anchored seek would need `seek_at_unserviced` in `crates/control-plane`. No slice builds + it (#1293 record, "Successors"). If this issue needs one, it names that successor first and waits + for it. Do not call `source_seek_at` from `process()` meanwhile. + +**Other text that carries the same assumption.** +- Context, `applySharedSeek` bullet (`:23-25`): true for the plain seek only. Read it with the rule above. +- D4 (`:80-81`, "then publishes the anchored seek once"): the publish is the writer's. Application is + the handler's, so `A` must still be ahead of the render clock when the handler runs. The prime + length in `A` (D4) covers the handler latency. It stays an engine-compensated lateness otherwise (gate 2). +- D4 `alignTo` reads the seek report. `source_seek_report` (`ffi.rs:3339`) runs + `SessionState::seek_report`, which also runs the service step. So the report is read from the control + half, not from `process()`. It is not on a render path in this spec; keep it so. +- Gate 1 (`:110-112`) says "the drain calls `source_seek_at` exactly once". Read it as "the control + handler calls `source_seek_at` exactly once, and the drain never calls it". Add a check that the + drain's recorded export calls contain no `source_seek_at`. +- Gate 3 (`:118-119`, "an unanchored seek takes the plain path"): the plain path is the only seek the + drain may call. An anchored epoch seen by the drain is left unapplied, not sent to the plain seek. +- Test value, gate 1 (`:130-131`): add the defect "an SDK that calls `source_seek_at` from the drain". + It runs the service step in `process()`. +- Non-goals (`:106`, "No engine, Worker or engine-worklet change"): the handler path lives in the + SDK-side feed worklet and in #1294's control half. If it needs a change in + `hosts/host-web/web/miso-engine-v1-audio-worklet.js`, that file is outside this issue's authorized + paths. Name the owner before implementation. +- Dependencies: #1294 (the message handler path, D2) is a dependency of D3 now. Add it. diff --git a/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md index 451df9e86..35ea2da32 100644 --- a/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md +++ b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md @@ -244,8 +244,9 @@ Built on `eab696d09` (worktree `codex/controls-batch-16a`). - **D4.** `TimelineProducer` (`try_seek`, `check_seek`, `active_generation`), `TIMELINE_SAMPLE_LIMIT = 1 << 53`, `SourceSeekError::FrameOutOfRange`. - **D5.** `TimelineResourceReport`: `history_blocks`, `command_queue_bytes` (the ring formula), - `history_bytes` (`16·K`), `consumer_bytes` (`size_of::()`), `overhead_bytes` - (the sum of the three), `largest_allocation_bytes`. No seek-report row (B16-R4). + `command_queue_largest_allocation_bytes` (#1641 D7) and `history_bytes` (`16·K`). No + seek-report row (B16-R4). The batch 16a fold removed `consumer_bytes`, `overhead_bytes` and + `largest_allocation_bytes` (see the fold paragraph). - **Host-core.** The `FrameOutOfRange` arm, `source.seek.frame_out_of_range`, and its row 20 in `crates/host-core/tests/source_diagnostics.rs`. @@ -261,28 +262,29 @@ Built on `eab696d09` (worktree `codex/controls-batch-16a`). position. `copy_history_from` carries it. - `timeline_at` with no entry treats the position as standing at source-read sample 0. A sample in a gap or after the newest entry extrapolates forward from the newest entry that starts at or - before it. A step is reported only when its entry is the one that resolves its own first sample - (newest first), so the step and `timeline_at` always agree. + before it. A step is reported at the first sample `s` of the node block where + `timeline_at(s) != timeline_at(s - 1) + 1` (a seek, or a source-read step-back's overlap; fold + M1), so for a node block with at most one step, which is every block without a step-back inside + it, the step and `timeline_at` agree on every sample. Of two steps, the first is reported. - `timeline_block(first, frames)` takes the node block's length as `frames`. **Departures:** -- Gate 2 "unchanged": the assertions are unchanged. Two of the four named tests and six others - read the moved fields, so they now read `consumer.seek.active_generation` and +- Gate 2 "unchanged": the assertions are unchanged. Two of the four named tests, seven others and + the shared `twin` helper read the moved fields, so they now read `consumer.seek.active_generation` and `consumer.seek.held_seek` (a path rewrite only). `crates/source/tests/seek_schedule_model.rs` is textually unchanged. `crates/source/tests/randomized.rs` gains one `unreachable!` arm for the new variant (exhaustive match). - `tools/realtime-policy/src/policy.rs`: `marked_regions_floor` 151 -> 153, for the two new regions (mechanical, outside the authorized paths). -**For #1641:** `consumer_bytes` is the inline value. If #1641 stores the consumer inside an -allocation it already counts (the driver), it must not charge that row twice. +**For #1641:** the consumer value is inline. #1641 stores it inside an allocation it already +counts (the driver), so no row charges it (fold MINOR 6 removed `consumer_bytes`). **Gates:** -- Gate 1, `cargo test --locked -p source`, `tests::timeline::*` (11 new): - `a_fresh_timeline_starts_at_zero_and_advances_a_quantum_every_block`, - `a_plain_seek_reads_its_frame_at_the_next_blocks_first_sample`, +- Gate 1, `cargo test --locked -p source`, `tests::timeline::*` (11 new; the fold replaced the + first two, see below): `an_anchored_seek_is_held_until_its_anchor_and_applies_with_its_lateness`, `a_newer_seek_plain_or_anchored_replaces_a_held_one`, `the_producer_refuses_without_changing_anything`, @@ -300,3 +302,34 @@ allocation it already counts (the driver), it must not charge that row twice. `check-workspace-policy.sh`, `cargo test -p realtime-policy`, `source_diagnostics` (2), clippy `-p source -p host-core --all-targets --all-features -D warnings`, and `cargo fmt --all -- --check`. The workspace-wide clippy was not run (worker rules). + +**Fold (batch 16a review).** + +- **M1.** `timeline_block` reported a step only at a seek entry, so a node block that straddles a + source-read step-back (#1641 D4) read part of its samples a whole overlap off (`q = 128`, blocks + at 0, 128, 256, 384, 256: `timeline_block(216, 128)` gave no step while `timeline_at(256)` is + 512). It now checks every entry's first sample and its end (`sr + q`) inside the node block and + reports the first sample `s` where `timeline_at(s) != timeline_at(s - 1) + 1`. Where no held entry + starts at or before `s - 1` (or `s` is 0), the entry at `s` is a step when it is a seek, read + against `oldest_expected_timeline` as before. Bound: at most `6K² + 6K` entry reads, + allocation-free. Test `a_node_block_reports_the_steps_of_a_source_read_step_back` (red on the + unfixed code: `{start: 216, step: None}`), which also checks every block's span against + `timeline_at` sample by sample. +- **MINOR 4.** `the_reader_reads_the_newest_overlap_and_reports_the_first_step` (`K` 4 and 3): red + for a `resolve` searched oldest first, a later step reported instead of the first, and an + eviction that does not update `oldest_expected_timeline` (each mutation run). +- **MINOR 5.** D6 and `TimelineProducer::try_seek` now read: an accepted seek applies unless a newer + accepted seek replaces it first (#1274); it is never dropped silently. +- **MINOR 6.** `TimelineResourceReport` drops `consumer_bytes`, `overhead_bytes` and + `largest_allocation_bytes`: nothing read them after #1641, and the two sums counted the inline + consumer, which no allocation of the timeline holds. No charged byte moved (the source set never + read them). +- **MINOR 7.** `offset_timeline` is inside the marked region (its END marker moved below it; 153 + regions, unchanged). +- **Test value.** `a_fresh_timeline_starts_at_zero_and_advances_a_quantum_every_block` and + `a_plain_seek_reads_its_frame_at_the_next_blocks_first_sample` are deleted (no unique catch: the + anchored, newer-seek and history tests cover both). `the_resource_report_states_each_row` lost + its `consumer_bytes` check with the row. New: `a_vacant_timeline_never_adopted_advances_from_zero` + (#1641's, in this module). +- Rustdoc: the public docs no longer link the private `SeekState`, `SeekClock::At` and + `SeekState::prime_window_clear` (`cargo doc -D warnings`). diff --git a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md index 2c1f591e3..6fcdc84f9 100644 --- a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md +++ b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md @@ -345,9 +345,9 @@ Built on `3f53c0755` (worktree `codex/controls-batch-16a`, #1640's timeline type - **D3.** `SourceTimeline::prepare_vacant(q, K)`: its own history, no command queue (`command_consumer: Option<_>`). `GraphCarryProgram { timeline: bool }`; `adopt_predecessor` calls `adopt_sources` when `!sources.is_empty() || timeline`. The driver moves the timeline when - its own is vacant: the predecessor's must be occupied (else nothing moves, `PredecessorMismatch`), - then `TimelineConsumer::adopt_from` swaps seek state, command consumer and position and calls - `copy_history_from`. Nothing is allocated or freed. A vacant timeline never adopted advances + its own is vacant and the predecessor's is occupied (any other pairing leaves both timelines as + they are and still moves the sources; fold M3), through `TimelineConsumer::adopt_from`, which + swaps seek state, command consumer and position and calls `copy_history_from`. Nothing is allocated or freed. A vacant timeline never adopted advances from 0 and pops nothing. `swap_sources` (donation) leaves the timeline where it is. - **D4.** No extra code: the discontinuity successor carries as D3 (its program now always exists). - **D5 (B16-R3).** `TimelineConsumer::prime_ready(first, lead)` and `prime_block_at(sample)`. The @@ -375,8 +375,8 @@ Built on `3f53c0755` (worktree `codex/controls-batch-16a`, #1640's timeline type `adopt_sources` signature is unchanged. The flag makes the call happen. - **The `has_carry_program` fallback** in `prepare.rs` (a program for delay/route carries) is kept with `timeline: false`; it is reached only by a successor whose timeline does not carry. -- **Gate 4** is in both files: `timeline.rs` on the direct `adopt_predecessor_plan` path, and the - one `successor_swap.rs` case through the real plan exchange. +- **Gate 4** is in `timeline.rs`, on the direct `adopt_predecessor_plan` path. (The + `successor_swap.rs` case through the plan exchange was deleted in the batch 16a fold.) - **D6** is out (B16-R4): no seek-report record, bytes or move. Gate 1 has no report clause. **Re-pins** (each "the timeline consumer's fixed bytes (#1641 D7)"; ceilings are the new value @@ -404,8 +404,7 @@ The native step is 480 bytes (queue 320, history 16, consumer 144); the wasm32 s `a_latent_successor_keeps_the_newest_entries_in_a_history_of_its_own_size` (K 5 -> 9). 3. `a_discontinuity_keeps_the_sources_and_the_timeline_in_step`; `a_prime_advances_the_timeline_as_it_advances_a_source`. -4. `the_timeline_carry_and_the_blocks_after_it_allocate_and_free_nothing`; - `successor_swap::the_timeline_carry_allocates_and_frees_nothing_through_the_exchange`. +4. `the_timeline_carry_and_the_blocks_after_it_allocate_and_free_nothing`. 5. No render arithmetic changed. `./target/release/audit capi`: `pcm_digest` `4f52089e4a8cf83c` at base and head, 0 violations. The browser oracle's three PCM digests agree with `expected.json` (`scripts/check-browser-expected-resources.py`, which also measured the wasm @@ -418,3 +417,39 @@ existing regions; the timeline's pop is a `match`, not a closure, which the poli `check-cross-targets.sh`, `check-host-core-policy.sh`, `check-workspace-policy.sh`; clippy `-D warnings` on the six touched crates; `cargo fmt --all -- --check`. The workspace-wide clippy was not run (worker rules). + +**Fold (batch 16a review).** + +- **M2.** The D3 flag had no test: dropping `|| program.timeline` (graph) or `timeline_carries ||` + (host-core) passed every test, because the successor always carried a source or strip state + too. `timeline::a_rebuild_that_carries_no_source_still_carries_the_timeline`: the successor + changes the source's declaration and renames the track, so the timeline is the program's only + move (`carried_source_total_bytes` equals the timeline queue's bytes); the timeline continues + with no gap and a seek through the moved producer lands. Red on each of the two removals + (each run). +- **M3.** `adopt_sources` refused every source move when both timelines were vacant. The timeline + clause left `movable`; the timeline moves only from an occupied predecessor into a vacant + successor. `source::tests::carry::the_source_moves_carry_whatever_the_two_timelines_hold` + (all four pairings) is red on the unfixed code (the both-vacant pairing). +- **MINOR 1 (D9).** On a `PredecessorMismatch` the timeline producer has already moved + (`adopt_persisting`) while the successor's vacant timeline advances from 0 and observes no seek, + and nothing reports the vacant timeline itself beyond the mismatch count. Unreachable on both + hosts (#1269 P11), the same contract as a vacant source; stated in the `adopt_persisting` doc. +- **MINOR 2.** The capi `resource_lifecycle` and host-web companion non-vacuity guards compare + `carried_source_total_bytes > carried_source_overhead_bytes`: the timeline queue is overhead + only, a carried ring adds its PCM payload. (Not `timeline_queue_bytes()` as in + `successor_swap.rs`: host-web does not depend on `source`, and this needs no new dependency.) +- **MINOR 3.** `timeline::a_successor_at_another_rate_or_quantum_starts_a_fresh_timeline` (red when + the guard is `successor.is_some()`), `timeline::a_seek_queued_at_the_swap_block_lands_in_the_successors_first_block` + (red when the carry does not swap the command consumer), and the vacant-timeline unit test + `source::tests::timeline::a_vacant_timeline_never_adopted_advances_from_zero`. +- **MINOR 4.** The discontinuity gate's claim that it reads the overlap's older entry is removed: + its plan has `K = 1`, so no overlap is held. The overlap rule is `source`'s + `a_node_block_reports_the_steps_of_a_source_read_step_back` (#1640 fold M1). +- **MINOR 11.** `install_carry_program` refuses `timeline: true` on a plan with no source set + (`NoSourceSet`): `graph::tests::install_refuses_a_timeline_carry_on_a_plan_with_no_source_set`. +- **Test value.** `successor_swap::the_timeline_carry_allocates_and_frees_nothing_through_the_exchange` + is deleted: the exchange owner exposes no timeline to show that the held seek lands, and + `timeline.rs`'s gate 4 already counts the same carry and blocks. +- No charged byte moved (#1640 fold MINOR 6 removed only unread rows); `realtime_policy` 153 + regions, unchanged. diff --git a/crates/capi/tests/resource_lifecycle.rs b/crates/capi/tests/resource_lifecycle.rs index d14cef899..22b11e150 100644 --- a/crates/capi/tests/resource_lifecycle.rs +++ b/crates/capi/tests/resource_lifecycle.rs @@ -1808,8 +1808,10 @@ fn double_live_oracle_drives_exact_and_one_below_c_caps() { .unwrap_or_else(|_| panic!("the prospective session prepares as a successor")) .0 .report; + // Every successor carries the timeline's command queue (#1641 D7), which is overhead only; a + // carried ring also brings its PCM payload. assert!( - successor.carried_source_total_bytes > 0, + successor.carried_source_total_bytes > successor.carried_source_overhead_bytes, "vacuous: the replacement carries no ring" ); assert_eq!( diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 794d75d13..8d0c1fbc6 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -3702,8 +3702,8 @@ pub fn plan_identity(plan: &mut PreparedRenderPlan) -> Option { /// Install the hand-over this graph plan runs when it displaces its predecessor. /// /// Call it before publication. Refused, leaving any earlier program in place, when the plan is -/// not a graph plan, has no source set, or a successor index is out of range, not vacant or -/// repeated, or a predecessor index is repeated. Predecessor indices are checked against the +/// not a graph plan, has no source set for a program that moves a source or the timeline, or a +/// successor index is out of range, not vacant or repeated, or a predecessor index is repeated. Predecessor indices are checked against the /// predecessor at the swap block, where an out-of-range index refuses the whole move. pub fn install_carry_program( plan: &mut PreparedRenderPlan, @@ -3713,11 +3713,11 @@ pub fn install_carry_program( .executor_any_mut() .and_then(|any| any.downcast_mut::()) .ok_or(GraphCarryInstallError::NotAGraphPlan)?; - // A program that moves no source needs no source set (issue #1276: a plan may carry only - // strip state). + // A program that moves no source and no timeline needs no source set (issue #1276: a plan + // may carry only strip state). The timeline lives in the source set (#1641 D1). let set = match executor.source_set.as_ref() { Some(set) => Some(set), - None if program.sources.is_empty() => None, + None if program.sources.is_empty() && !program.timeline => None, None => return Err(GraphCarryInstallError::NoSourceSet), }; for (index, &(successor, predecessor)) in program.sources.iter().enumerate() { @@ -9167,6 +9167,23 @@ mod tests { } } + /// Batch 16a fold MINOR 11 (#1641 D1). Red if install accepts a timeline carry on a plan + /// with no source set, which holds the timeline: the swap block would report a mismatch. + #[test] + fn install_refuses_a_timeline_carry_on_a_plan_with_no_source_set() { + let mut plan = sidechain_pdc_plan(false); + let program = |timeline| GraphCarryProgram { + predecessor: 1, + sources: Box::new([]), + timeline, + }; + assert_eq!( + install_carry_program(&mut plan, program(true)), + Err(GraphCarryInstallError::NoSourceSet) + ); + assert_eq!(install_carry_program(&mut plan, program(false)), Ok(())); + } + #[test] fn faster_main_and_faster_sidechain_align_on_their_typed_ports() { for delay_main in [true, false] { diff --git a/crates/host-core/src/source.rs b/crates/host-core/src/source.rs index 637e2bd65..fe0557b22 100644 --- a/crates/host-core/src/source.rs +++ b/crates/host-core/src/source.rs @@ -339,7 +339,11 @@ impl SourceControlSet { /// The producers move before the plan swap that moves their consumers. Should that swap's /// carry report `PredecessorMismatch` -- unreachable on both hosts by construction (issue /// #1269 P11) -- the successor's vacant sources render `+0.0` from the swap block on, since - /// their producers have already moved here and their rings stay with the retired plan. + /// their producers have already moved here and their rings stay with the retired plan. The + /// timeline keeps the same contract: its producer has moved here too, and the successor's + /// vacant timeline advances from timeline sample 0 and observes no seek. Its one exception + /// (#1641 D9): nothing reports the vacant timeline itself; the swap's mismatch count is the + /// only sign. pub fn adopt_persisting(&mut self, predecessor: &mut SourceControlSet) -> usize { let mut moved = 0; for index in 0..self.sources.len() { diff --git a/crates/host-core/tests/successor_swap.rs b/crates/host-core/tests/successor_swap.rs index a9b5fda23..e24356d87 100644 --- a/crates/host-core/tests/successor_swap.rs +++ b/crates/host-core/tests/successor_swap.rs @@ -539,80 +539,6 @@ fn the_swap_block_allocates_and_frees_nothing() { } } -/// #1641 gate 4, through the plan exchange. The successor adds a latent track, so its timeline's -/// history is longer than the predecessor's, and the predecessor's timeline holds an anchored seek -/// across the swap. The swap block, which swaps the timeline's seek state, queue and position and -/// copies its history, and the ten blocks after it, make no allocator call. -/// -/// Test value: red if the timeline carry or its history write allocates or frees on the render -/// thread of the real plan exchange. -#[test] -fn the_timeline_carry_allocates_and_frees_nothing_through_the_exchange() { - 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); - engine::realtime::audit::warm_up(); - let a = Session::compile(session_a()); - let mut b_model = session_a(); - add_track(&mut b_model, "eq0", MUTED_TRACK, SOURCE, true); - add_limiter(&mut b_model, MUTED_TRACK, "timeline-limiter"); - let b = Session::compile(b_model); - const EXTRA: usize = 5; - let feed = Feed::with_blocks(SOURCE, 1, BLOCKS + EXTRA + 1); - let backend = Backend::current(); - let mut predecessor = a.prepare(backend); - predecessor - .sources - .test_only_timeline_producer() - .expect("a fresh plan's timeline producer") - .try_seek(source::SourceCommand::SeekAt { - generation: source::SourceGeneration(2), - frame: source::SourceFrame(4_096), - anchor_sample: ((SWAP_BLOCK + 2) * QUANTUM) as u64, - }) - .expect("an anchored timeline seek"); - let mut run = successor::swapped_run_with( - predecessor, - |inventory| { - b.prepare_successor(inventory, &a.model, &caps(), backend) - .unwrap_or_else(|failure| panic!("successor: {failure:?}")) - }, - &[&feed], - &[&feed], - Successor::Carry, - ); - assert_eq!(run.blocks[SWAP_BLOCK].carry, CarryOutcome::Carried); - assert!( - run.successor_sources - .test_only_timeline_producer() - .is_some(), - "the producer moved" - ); - let mut allocators: Vec<(u64, u64, u64)> = run.blocks[SWAP_BLOCK..] - .iter() - .map(|block| block.allocator) - .collect(); - for block in BLOCKS..BLOCKS + EXTRA { - feed.submit(&mut run.successor_sources, 48_000, block); - allocators.push(successor::render(&mut run.owner).allocator); - } - assert_eq!(allocators.len(), 11, "the swap block and ten after it"); - for (index, allocator) in allocators.into_iter().enumerate() { - assert_eq!( - allocator, - (0, 0, 0), - "block {index} from the swap: allocator calls" - ); - } -} - // Issue #1274 (slice 5 of #1269): an anchored seek, `SourceControlSet::seek_at`, holds a source's // new generation until the block that starts at its anchor sample on the plan's absolute render // clock, which the swap continues. diff --git a/crates/host-core/tests/timeline.rs b/crates/host-core/tests/timeline.rs index e713f6cb0..869fa9b76 100644 --- a/crates/host-core/tests/timeline.rs +++ b/crates/host-core/tests/timeline.rs @@ -423,6 +423,140 @@ fn a_rebuild_carries_the_timeline_its_history_and_its_producer() { assert_eq!(timeline(&mut successor.plan, sample), (7 * Q) as i64); } +/// The command queue a successor's carried timeline brings (#1641 D7): a successor that carries no +/// ring carries exactly these source bytes. +fn timeline_queue_bytes() -> u64 { + SourceTimeline::resource_report(QuantumFrames(QUANTUM as u32), NonZeroUsize::MIN) + .expect("timeline report") + .command_queue_bytes +} + +/// Gate 2 with nothing else carried (batch 16a fold M2): the successor changes the source's +/// declaration, so it prepares a ring of its own, and renames the track, so no strip state +/// carries either: the timeline is the carry program's only move. +/// +/// Test value: red if preparation builds a carry program only when a source or strip carries +/// (the D3 `timeline_carries` condition dropped: the carry is not requested), or the graph calls +/// the driver's adopt step only for a source move (`program.timeline` dropped: the successor's +/// timeline restarts at 0 and a seek through the moved producer is lost). +#[test] +fn a_rebuild_that_carries_no_source_still_carries_the_timeline() { + let a = Session::compile(one_track()); + let mut b_model = one_track(); + b_model.sources[0].frames *= 2; + b_model.tracks[0].id = id(ADDED_TRACK); + let RouteSource::Track { track_id, .. } = &mut b_model.routes[0].source else { + panic!("a track route"); + }; + *track_id = id(ADDED_TRACK); + let b = Session::compile(b_model); + let mut predecessor = a.prepare(Backend::current()); + let mut fed = 0; + submit(&mut predecessor.sources, 1, 0..Q); + fed += Q; + render_fed(&mut predecessor, &mut fed, 6); + const SOUGHT: u64 = 300 * Q; + producer(&mut predecessor.sources) + .try_seek(SourceCommand::Seek { + generation: SourceGeneration(2), + frame: SourceFrame(SOUGHT), + }) + .expect("timeline seek"); + render_fed(&mut predecessor, &mut fed, 2); + let now = predecessor.plan.next_absolute_sample(); + assert_eq!( + timeline(&mut predecessor.plan, now - 1), + (SOUGHT + 2 * Q - 1) as i64 + ); + let mut successor = successor_of(&b, &a, &predecessor.inventory, false); + assert_eq!( + successor.report.carried_source_total_bytes, + timeline_queue_bytes(), + "not vacuous: the successor carries no ring, only the timeline's queue" + ); + assert_eq!( + hand_over(&mut successor, &mut predecessor), + CarryOutcome::Carried + ); + // No gap or repeat: the successor's blocks continue the predecessor's timeline. + for block in 0..3_u64 { + let sample = render(&mut successor.plan).sample; + assert_eq!( + timeline(&mut successor.plan, sample), + (SOUGHT + (2 + block) * Q) as i64, + "successor block {block}" + ); + } + // A seek through the moved producer lands in the next block. + producer(&mut successor.sources) + .try_seek(SourceCommand::Seek { + generation: SourceGeneration(3), + frame: SourceFrame(7 * Q), + }) + .expect("a seek through the successor's set"); + let sample = render(&mut successor.plan).sample; + assert_eq!(timeline(&mut successor.plan, sample), (7 * Q) as i64); +} + +/// Gate 2, a seek accepted but not yet popped at the swap block (batch 16a fold MINOR 3): the +/// predecessor renders no block after the push, so the command is still in the queue that the +/// carry moves. +/// +/// Test value: red if the carry moves the timeline without its command queue, or the successor +/// drains it without applying it (an acked seek would be dropped). +#[test] +fn a_seek_queued_at_the_swap_block_lands_in_the_successors_first_block() { + let a = Session::compile(one_track()); + let b = Session::compile(with_added_track(one_track())); + let mut predecessor = a.prepare(Backend::current()); + let mut fed = 0; + submit(&mut predecessor.sources, 1, 0..Q); + fed += Q; + render_fed(&mut predecessor, &mut fed, 4); + let mut successor = successor_of(&b, &a, &predecessor.inventory, false); + producer(&mut predecessor.sources) + .try_seek(SourceCommand::Seek { + generation: SourceGeneration(2), + frame: SourceFrame(40 * Q), + }) + .expect("timeline seek"); + assert_eq!( + hand_over(&mut successor, &mut predecessor), + CarryOutcome::Carried + ); + let sample = render(&mut successor.plan).sample; + assert_eq!(timeline(&mut successor.plan, sample), (40 * Q) as i64); + assert_eq!( + producer(&mut successor.sources).active_generation(), + SourceGeneration(2) + ); +} + +/// The D3 guard (batch 16a fold MINOR 3): a successor prepared by hand at another rate or quantum +/// gets a fresh timeline with a producer of its own, not a vacant one whose carry the swap block +/// would refuse. +/// +/// Test value: red if the guard is dropped (every successor's timeline vacant): this successor +/// would hold no timeline producer until a carry that cannot happen. +#[test] +fn a_successor_at_another_rate_or_quantum_starts_a_fresh_timeline() { + let a = Session::compile(one_track()); + let predecessor = a.prepare(Backend::current()); + for (rate, quantum) in [(44_100, QUANTUM as u32), (48_000, 2 * QUANTUM as u32)] { + let mut model = one_track(); + model.sample_rate_hz = rate; + model.quantum_frames = quantum; + let b = Session::compile(model); + let mut successor = successor_of(&b, &a, &predecessor.inventory, false); + let fresh = producer(&mut successor.sources); + assert_eq!( + fresh.active_generation(), + SourceGeneration(1), + "{rate} Hz, {quantum} frames" + ); + } +} + /// Gate 2, a successor with a larger `A_max`: its history is its own, of its own `K`. /// /// Test value: red if the successor sizes its history from the predecessor's `K` (a seek its @@ -495,7 +629,9 @@ fn a_latent_successor_keeps_the_newest_entries_in_a_history_of_its_own_size() { /// Gate 3, a declared discontinuity. /// /// Test value: red if a discontinuity successor moves the sources and not the timeline (it -/// restarts it, or steps it back with the source-read clock), or reads the overlap's older entry. +/// restarts it, or steps it back with the source-read clock). The plan has no latency, so its +/// history holds one entry and no overlap: the reader's overlap rule is the `source` unit tests' +/// (`a_node_block_reports_the_steps_of_a_source_read_step_back`). #[test] fn a_discontinuity_keeps_the_sources_and_the_timeline_in_step() { const OFFSET: u64 = 3 * Q; @@ -518,8 +654,7 @@ fn a_discontinuity_keeps_the_sources_and_the_timeline_in_step() { hand_over(&mut successor, &mut predecessor), CarryOutcome::Carried ); - // The source-read clock steps back by `OFFSET`; the timeline and the source continue, and - // the overlap's samples read the newest entry, the block render played last. + // The source-read clock steps back by `OFFSET`; the timeline and the source continue. for _ in 0..2 { submit(&mut successor.sources, 1, fed..fed + Q); fed += Q; diff --git a/crates/source/src/lib.rs b/crates/source/src/lib.rs index 3cc79f130..d2408b1da 100644 --- a/crates/source/src/lib.rs +++ b/crates/source/src/lib.rs @@ -2132,8 +2132,9 @@ impl PcmSourceConsumer { /// derived from it stays an exact `i64` for any run a host can render. pub const TIMELINE_SAMPLE_LIMIT: u64 = 1 << 53; -/// Exact fixed bytes of one prepared timeline (#1640 D5), row by row. #1641 adds them to the -/// source set's overhead. +/// Exact fixed bytes of one prepared timeline (#1640 D5), row by row: the allocations it owns. +/// #1641 adds them to the source set's overhead. The [`TimelineConsumer`] value itself has no row: +/// it is inline in its owner (the source set's driver), whose own allocation counts it. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TimelineResourceReport { /// The history's entries, `K`. @@ -2144,12 +2145,6 @@ pub struct TimelineResourceReport { pub command_queue_largest_allocation_bytes: u64, /// The history allocation: `16 * K` bytes. pub history_bytes: u64, - /// The [`TimelineConsumer`] value itself, wherever its owner stores it. - pub consumer_bytes: u64, - /// The sum of the three byte rows above. - pub overhead_bytes: u64, - /// The largest of the three byte rows above. - pub largest_allocation_bytes: u64, } /// Prepared timeline constructor: the session's playhead as a source consumer with no PCM and no @@ -2172,20 +2167,13 @@ impl SourceTimeline { .size(), ) .map_err(|_| PcmSourceRingError::PlatformSizeLimit)?; - let consumer = u64::try_from(size_of::()) - .map_err(|_| PcmSourceRingError::PlatformSizeLimit)?; - let overhead = checked_u64_add(checked_u64_add(command_queue, history)?, consumer)?; let queue_largest = largest_queue_allocation::(NonZeroUsize::MIN)?; - let largest = queue_largest.max(history).max(consumer); Ok(TimelineResourceReport { history_blocks: u64::try_from(history_blocks.get()) .map_err(|_| PcmSourceRingError::PlatformSizeLimit)?, command_queue_bytes: command_queue, command_queue_largest_allocation_bytes: queue_largest, history_bytes: history, - consumer_bytes: consumer, - overhead_bytes: overhead, - largest_allocation_bytes: largest, }) } @@ -2242,7 +2230,8 @@ pub struct TimelineProducer { impl TimelineProducer { /// Request a strictly newer generation at `frame`, a timeline sample. It applies on the next /// block, or for [`SourceCommand::SeekAt`] on the block its anchor names. A full slot is - /// [`SourceSeekError::Backpressure`] and changes nothing; an accepted seek is never dropped. + /// [`SourceSeekError::Backpressure`] and changes nothing. An accepted seek applies unless a + /// newer accepted seek replaces it first (#1274): it is never dropped silently. pub fn try_seek(&mut self, command: SourceCommand) -> Result<(), SourceSeekError> { self.seek.try_seek(command) } @@ -2289,9 +2278,9 @@ pub struct TimelineStep { /// The session's playhead (#1640 D2): a source consumer with no PCM, no data queue and no /// recycle queue. It starts at generation 1, timeline sample 0, takes plain and anchored seeks -/// through the shared [`SeekState`], advances one quantum on every block and keeps a fixed -/// history of `K` blocks, newest last, so a reader can map any source-read sample a node of the -/// plan can still be processing to its timeline sample. Every value is an exact integer. +/// through the shared seek state (`SeekState`), advances one quantum on every block and keeps a +/// fixed history of `K` blocks, newest last, so a reader can map any source-read sample a node of +/// the plan can still be processing to its timeline sample. Every value is an exact integer. pub struct TimelineConsumer { seek: SeekState, /// `None` while vacant (#1641 D3): a successor's timeline before its carry. @@ -2368,7 +2357,7 @@ impl TimelineConsumer { } // REALTIME_POLICY_BEGIN /// Begin the block that reads source time from `source_read_sample`: observe one command - /// through the shared seek state ([`SeekClock::At`]), set the position to a seek's frame if + /// through the shared seek state (`SeekClock::At`), set the position to a seek's frame if /// one applies, record `(source_read_sample, position)` in the history, then advance one /// quantum. It runs on every block, so the timeline never stops. Allocation-free, lock-free /// and bounded. @@ -2388,9 +2377,10 @@ impl TimelineConsumer { } /// Whether a prime of `lead_blocks + 1` blocks from `first_sample` can run (#1641 D5, - /// B16-R3): the source consumer's non-PCM test, written once in [`SeekState::prime_window_clear`]. The - /// command queue is empty and no held anchored seek falls before the prime window's end, so a - /// prime never skips a seek. A timeline has no PCM to run out of, so that is the whole test. + /// B16-R3): the source consumer's non-PCM test, written once in + /// `SeekState::prime_window_clear`. The command queue is empty and no held anchored seek + /// falls before the prime window's end, so a prime never skips a seek. A timeline has no PCM + /// to run out of, so that is the whole test. #[must_use] pub fn prime_ready(&self, first_sample: u64, lead_blocks: u32) -> bool { let queued = self @@ -2444,41 +2434,68 @@ impl TimelineConsumer { } /// The node-time mapping of the node block of `frames` samples from - /// `first_source_read_sample` (#1640 D3): its `start`, and a step at the first sample of a - /// seek's history entry inside it. When the node block starts exactly at that entry the step - /// is `(0, start)`. A node block of at most one quantum covers at most two source-read - /// blocks, so it has at most one step; of several, the newest is reported. Read-only, with at - /// most `K * (K + 2)` entry reads. + /// `first_source_read_sample` (#1640 D3): its `start`, and a step at the first sample `s` of + /// the block where the timeline is not the sample before it plus one (`timeline_at(s) != + /// timeline_at(s - 1) + 1`): a seek, or a source-read clock step-back whose newer entry + /// covers or uncovers an older one (#1641 D4). A step can only fall where an entry starts or + /// ends, so those are the samples checked. When the node block starts exactly at a step the + /// step is `(0, start)`. A node block with at most one step, every block + /// a host renders without a step-back inside it, agrees with `timeline_at` on every sample; + /// of several, the first is reported. Read-only and allocation-free, with at most + /// `6K² + 6K` entry reads. #[must_use] pub fn timeline_block(&self, first_source_read_sample: u64, frames: u32) -> TimelineSpan { let start = self.timeline_at(first_source_read_sample); - let mut step = None; + let quantum = u64::from(self.quantum_frames); + let mut step: Option = None; for age in 0..self.len { let Some(entry) = self.entry(age) else { break; }; - let Some(offset) = entry - .source_read_sample - .checked_sub(first_source_read_sample) - .and_then(|offset| u32::try_from(offset).ok()) - .filter(|&offset| offset < frames) - else { - continue; - }; - if !self.is_seek_entry(age) { - continue; - } - // The step is what the reader sees: the entry must be the one that resolves its own - // first sample, not one that a newer, overlapping entry hides. - let (resolving, timeline) = self.resolve(entry.source_read_sample); - if resolving == Some(age) { - step = Some(TimelineStep { offset, timeline }); - break; + for sample in [ + entry.source_read_sample, + entry.source_read_sample.saturating_add(quantum), + ] { + let Some(offset) = sample + .checked_sub(first_source_read_sample) + .and_then(|offset| u32::try_from(offset).ok()) + .filter(|&offset| offset < frames) + else { + continue; + }; + if step.is_some_and(|earlier| earlier.offset <= offset) { + continue; + } + if let Some(timeline) = self.step_at(sample) { + step = Some(TimelineStep { offset, timeline }); + } } } TimelineSpan { start, step } } + /// The timeline value at `sample` when the timeline steps there: when it is not the value at + /// `sample - 1` plus one. When no held entry starts at or before `sample - 1` (that value is + /// extrapolated back, or `sample` is 0), the entry that starts at `sample` is a step when it + /// is a seek, read against `oldest_expected_timeline` for the oldest entry. + fn step_at(&self, sample: u64) -> Option { + let (resolving, timeline) = self.resolve(sample); + let stepped = match sample.checked_sub(1) { + Some(previous) if self.starts_at_or_before(previous) => { + self.resolve(previous).1.checked_add(1) != Some(timeline) + } + _ => resolving.is_some_and(|age| self.is_seek_entry(age)), + }; + stepped.then_some(timeline) + } + + /// Whether a held entry starts at or before `sample`. + fn starts_at_or_before(&self, sample: u64) -> bool { + (0..self.len) + .filter_map(|age| self.entry(age)) + .any(|entry| entry.source_read_sample <= sample) + } + /// Replace this history with the newest `min(len_other, K_self)` entries of `other`, oldest /// first (#1641's carry): a bounded copy with no allocation. Both timelines have the same /// quantum. @@ -2570,7 +2587,6 @@ impl TimelineConsumer { }); (None, offset_timeline(anchor, sample)) } - // REALTIME_POLICY_END } /// `entry.timeline + (sample - entry.source_read_sample)`, exact, saturated to the `i64` range. @@ -2579,6 +2595,7 @@ fn offset_timeline(entry: TimelineEntry, sample: u64) -> i64 { i128::from(entry.timeline) + i128::from(sample) - i128::from(entry.source_read_sample); i64::try_from(value).unwrap_or(if value < 0 { i64::MIN } else { i64::MAX }) } +// REALTIME_POLICY_END fn queue_bytes(capacity: NonZeroUsize) -> Result { let payload = bounded_spsc_retained_payload::(capacity).map_err(map_spsc_error)?; @@ -2932,9 +2949,10 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { /// the channel counts and quanta 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 the predecessor's (#1641 D3): its seek state, command consumer and - /// position by swap and its newest history entries by copy, into this timeline's own - /// history. The predecessor's timeline must be occupied, or nothing moves. + /// A vacant timeline takes an occupied predecessor's (#1641 D3): its seek state, command + /// consumer and position by swap and its newest history entries by copy, into this + /// timeline's own history. Any other pair of timelines stays as it is and does not refuse + /// the source moves: a timeline vacant on both sides keeps advancing from its own position. fn adopt_sources( &mut self, predecessor: &mut dyn GraphPreparedSourceSetDriver, @@ -2947,7 +2965,6 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { return false; }; let movable = predecessor.quantum_frames == self.quantum_frames - && (!self.timeline.is_vacant() || !predecessor.timeline.is_vacant()) && moves.iter().all(|&(successor, source)| { match ( self.sources.get(successor as usize), @@ -2970,7 +2987,7 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { &mut predecessor.sources[source as usize].consumer, ); } - if self.timeline.is_vacant() { + if self.timeline.is_vacant() && !predecessor.timeline.is_vacant() { self.timeline.adopt_from(&mut predecessor.timeline); } self.pending_generation_change |= predecessor.pending_generation_change; @@ -5759,6 +5776,56 @@ mod tests { assert!(same_quantum.adopt_sources(&mut predecessor, &[(0, 0)])); } + /// Batch 16a fold M3 (#1641 D3): the timeline moves only from an occupied predecessor + /// into a vacant successor, and no pairing of the two timelines refuses the source moves. + /// + /// Test value: red if a timeline vacant on both sides refuses the carry (every source of + /// that swap would render silence), or a timeline moves in any other pairing. + #[test] + fn the_source_moves_carry_whatever_the_two_timelines_hold() { + let vacant_timeline = || { + SourceTimeline::prepare_vacant(QuantumFrames(FRAMES), NonZeroUsize::MIN) + .expect("vacant timeline") + .0 + }; + for (successor_vacant, predecessor_vacant) in + [(true, true), (true, false), (false, true), (false, false)] + { + let pairing = (successor_vacant, predecessor_vacant); + let (_host, entry) = occupied(2); + let mut predecessor = swap_driver(vec![entry], FRAMES); + if predecessor_vacant { + predecessor.timeline = vacant_timeline(); + } + let mut successor = swap_driver( + vec![GraphSourceEntry { + consumer: None, + channel_count: 2, + }], + FRAMES, + ); + if successor_vacant { + successor.timeline = vacant_timeline(); + } + assert!( + successor.adopt_sources(&mut predecessor, &[(0, 0)]), + "{pairing:?}" + ); + assert!(successor.sources[0].consumer.is_some(), "{pairing:?}"); + let moved = successor_vacant && !predecessor_vacant; + assert_eq!( + successor.timeline.is_vacant(), + successor_vacant && !moved, + "{pairing:?}" + ); + assert_eq!( + predecessor.timeline.is_vacant(), + predecessor_vacant || moved, + "{pairing:?}" + ); + } + } + /// A hand-built driver over `entries` at `quantum_frames`. fn swap_driver( entries: Vec, @@ -6358,28 +6425,132 @@ mod tests { .collect() } + /// Begin one block at each source-read sample of `samples`, in order. + fn blocks_at(consumer: &mut TimelineConsumer, samples: &[u64]) { + for &sample in samples { + consumer.begin_block_at(sample); + } + } + + /// The span's value at sample `i` of its node block. + fn span_value(span: TimelineSpan, i: u32) -> i64 { + match span.step { + Some(step) if i >= step.offset => step.timeline + i64::from(i - step.offset), + _ => span.start + i64::from(i), + } + } + + /// Batch 16a fold M1: a source-read clock step-back (#1641 D4) overlaps a newer entry on + /// an older one, so the timeline steps where the newer entry starts and again where it + /// ends, with no seek entry in sight. + /// + /// Test value: red if `timeline_block` reports only seek entries (a node block that + /// straddles the step-back reads 88 of its 128 samples 256 off), or misses the step where + /// the newer entry ends. #[test] - fn a_fresh_timeline_starts_at_zero_and_advances_a_quantum_every_block() { - let (producer, mut consumer) = timeline(4); - assert_eq!(consumer.position(), 0); - assert_eq!(consumer.active_generation(), SourceGeneration(1)); - assert_eq!(producer.active_generation(), SourceGeneration(1)); - let starts = run(&mut consumer, 0, 6); - assert_eq!(starts, (0..6).map(|block| block * QU).collect::>()); - assert_eq!(consumer.timeline_at(5 * QU + 17), 5 * 128 + 17); + fn a_node_block_reports_the_steps_of_a_source_read_step_back() { + let (_producer, mut consumer) = timeline(4); + blocks_at(&mut consumer, &[0, QU, 2 * QU, 3 * QU, 2 * QU]); + assert_eq!(consumer.timeline_at(2 * QU), 512); + assert_eq!( + consumer.timeline_block(216, Q), + TimelineSpan { + start: 216, + step: Some(TimelineStep { + offset: 40, + timeline: 512, + }), + } + ); + // The newer entry ends at 384, where the older entry of the same samples resumes. + assert_eq!( + consumer.timeline_block(344, Q), + TimelineSpan { + start: 600, + step: Some(TimelineStep { + offset: 40, + timeline: 384, + }), + } + ); + // Every node block with one step at most agrees with `timeline_at` sample by sample. + for first in (0..5 * QU).step_by(8) { + let span = consumer.timeline_block(first, Q); + for i in 0..Q { + assert_eq!( + span_value(span, i), + consumer.timeline_at(first + u64::from(i)), + "block from {first}, sample {i}" + ); + } + } } + /// Batch 16a fold MINOR 4, `K` of 3 and 4: the reader's search order. + /// + /// Test value: red if `timeline_at` searches the history oldest first (an overlap reads + /// the older entry), if `timeline_block` reports any step but the first of two, or if an + /// eviction does not update the oldest entry's expected timeline (a plain oldest entry + /// would read as a seek). #[test] - fn a_plain_seek_reads_its_frame_at_the_next_blocks_first_sample() { + fn the_reader_reads_the_newest_overlap_and_reports_the_first_step() { let (mut producer, mut consumer) = timeline(4); - run(&mut consumer, 0, 3); + blocks_at(&mut consumer, &[0, QU, 2 * QU]); producer.try_seek(seek(2, 10_000)).unwrap(); - assert_eq!(producer.active_generation(), SourceGeneration(2)); - consumer.begin_block_at(3 * QU); - assert_eq!(consumer.timeline_at(3 * QU), 10_000); - assert_eq!(consumer.timeline_at(3 * QU + 127), 10_127); - assert_eq!(consumer.active_generation(), SourceGeneration(2)); - assert_eq!(run(&mut consumer, 4, 6), vec![10_128, 10_256]); + // A seek at 384, then a step-back of 64 samples: the history holds (256, 256), + // (384, 10_000), (512, 10_128) and (448, 10_256). + blocks_at(&mut consumer, &[3 * QU, 4 * QU, 448]); + assert_eq!( + consumer.timeline_at(460), + 10_268, + "the newest overlapping entry" + ); + assert_eq!(consumer.timeline_at(520), 10_328); + assert_eq!( + consumer.timeline_at(580), + 10_196, + "after the newest entry ends" + ); + // Two steps in one node block: the seek at 384, then the step-back at 448. + assert_eq!( + consumer.timeline_block(380, Q), + TimelineSpan { + start: 380, + step: Some(TimelineStep { + offset: 4, + timeline: 10_000, + }), + } + ); + assert_eq!( + consumer.timeline_block(400, Q).step, + Some(TimelineStep { + offset: 48, + timeline: 10_256, + }) + ); + + // Evictions keep the oldest entry's expectation: a plain oldest entry is no step. + let (_producer, mut plain) = timeline(3); + run(&mut plain, 0, 6); + assert_eq!(plain.timeline_block(3 * QU, Q).step, None); + assert_eq!(plain.timeline_block(3 * QU - 40, Q).step, None); + } + + /// Batch 16a fold MINOR 3: a vacant timeline that no carry fills (#1641 D3). + /// + /// Test value: red if a vacant timeline stands still, starts anywhere but sample 0, or + /// 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(); + 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]); + assert_eq!(consumer.timeline_at(3 * QU - 1), 383); + assert_eq!(consumer.active_generation(), SourceGeneration(1)); } #[test] @@ -6623,14 +6794,6 @@ mod tests { largest_queue_allocation::(NonZeroUsize::MIN).unwrap() ); assert!(report.command_queue_largest_allocation_bytes <= report.command_queue_bytes); - assert_eq!( - report.consumer_bytes, - u64::try_from(size_of::()).unwrap() - ); - assert_eq!( - report.overhead_bytes, - report.command_queue_bytes + report.history_bytes + report.consumer_bytes - ); assert_eq!( SourceTimeline::resource_report(QuantumFrames(0), NonZeroUsize::MIN), Err(PcmSourceRingError::ZeroQuantumFrames) diff --git a/hosts/host-web/src/ffi.rs b/hosts/host-web/src/ffi.rs index 6160e5d90..044051f2d 100644 --- a/hosts/host-web/src/ffi.rs +++ b/hosts/host-web/src/ffi.rs @@ -3275,25 +3275,27 @@ pub extern "C" fn miso_engine_web_v1_source_seek( }) } -/// Run `operation` on the first `id_count` bytes of the source ID staging, or answer -/// [`RESULT_INVALID_ARGUMENT`] for a count past the staging. The prefix is only read during the -/// synchronous call. +/// Run `operation` on the first `id_count` bytes of the source ID staging and return its result, +/// or refuse with `Err(`[`RESULT_INVALID_ARGUMENT`]`)` for a count past the staging +/// (`Err(`[`RESULT_INTERNAL`]`)` with no staging). A refusal is not recorded: the caller records +/// it exactly when its operation would record its own results. The prefix is only read during +/// the synchronous call. fn with_staged_source_id( host: &mut AudioWorkletEngineHost, id_count: usize, operation: impl FnOnce(&mut AudioWorkletEngineHost, &[u8]) -> u32, -) -> u32 { +) -> Result { let Some(ids) = host.source_id_mut() else { - return host.record_boundary_result(RESULT_INTERNAL); + return Err(RESULT_INTERNAL); }; let Some(source_id) = ids.get(..id_count) else { - return host.record_boundary_result(RESULT_INVALID_ARGUMENT); + return Err(RESULT_INVALID_ARGUMENT); }; let source_id_pointer = source_id.as_ptr(); // SAFETY: The checked ID prefix lies in stable staging and is only read during the synchronous // safe-host call; neither anchored-seek operation mutates staging. let source_id = unsafe { slice::from_raw_parts(source_id_pointer, id_count) }; - operation(host, source_id) + Ok(operation(host, source_id)) } /// Queue one generation-tagged anchored source seek for the staged source ID (issue #1293 D5): @@ -3319,9 +3321,13 @@ pub extern "C" fn miso_engine_web_v1_source_seek_at( if host.status().state != STATE_READY { return host.seek_source_at(&[], 0, 0, 0); } - with_staged_source_id(host, source_id_bytes as usize, |host, source_id| { + // `seek_source_at` records its result, so its staged-ID refusal is recorded too. + match with_staged_source_id(host, source_id_bytes as usize, |host, source_id| { host.seek_source_at(source_id, generation, source_frame, anchor_sample) - }) + }) { + Ok(code) => code, + Err(code) => host.record_boundary_result(code), + } }) } @@ -3334,16 +3340,21 @@ pub extern "C" fn miso_engine_web_v1_source_seek_at( /// Results: [`RESULT_OK`]; [`RESULT_INVALID_ARGUMENT`] for a wrong handle, an ID count past the /// staging or an unknown source; [`RESULT_BACKPRESSURE`] for a read that landed inside render's /// publication (read again); [`RESULT_REFUSED_LIFECYCLE`] for a host that is not ready; -/// [`RESULT_INTERNAL`]. A refusal leaves the record alone. +/// [`RESULT_INTERNAL`]. A refusal leaves the record alone, and no result changes the status's last +/// result. #[unsafe(no_mangle)] pub extern "C" fn miso_engine_web_v1_source_seek_report(handle: u32, source_id_bytes: u32) -> u32 { with_host_mut(handle, RESULT_INVALID_ARGUMENT, |host| { if host.status().state != STATE_READY { return host.read_seek_report(&[]); } - with_staged_source_id(host, source_id_bytes as usize, |host, source_id| { - host.read_seek_report(source_id) - }) + // `read_seek_report` leaves the status's last result alone, so its staged-ID refusal + // does too. + let (Ok(code) | Err(code)) = + with_staged_source_id(host, source_id_bytes as usize, |host, source_id| { + host.read_seek_report(source_id) + }); + code }) } @@ -3351,13 +3362,16 @@ pub extern "C" fn miso_engine_web_v1_source_seek_report(handle: u32, source_id_b /// host that is not ready (issue #1293 D6). #[unsafe(no_mangle)] pub extern "C" fn miso_engine_web_v1_source_seek_report_ptr(handle: u32) -> u32 { - with_host(handle, 0, |host| { - if host.status().state == STATE_READY { - pointer_u32(ptr::from_ref(host.seek_report())) - } else { - 0 - } - }) + with_host(handle, 0, |host| pointer_u32(seek_report_pointer(host))) +} + +/// The seek report record's address for a ready host, else null (#1293 D6). +fn seek_report_pointer(host: &AudioWorkletEngineHost) -> *const crate::WebSeekReport { + if host.status().state == STATE_READY { + ptr::from_ref(host.seek_report()) + } else { + ptr::null() + } } /// Render one exact prepared quantum after validating the browser's actual frame count. @@ -4344,6 +4358,18 @@ pub(crate) fn test_buffer_address(handle: u32, kind: u32) -> usize { with_host_mut(handle, 0, |host| buffer_pointer(host, kind).addr()) } +/// The address `miso_engine_web_v1_source_seek_report_ptr` returns before its `u32` cast (a +/// native address does not fit it), and the record's own address. +#[cfg(test)] +pub(crate) fn test_seek_report_addresses(handle: u32) -> (usize, usize) { + with_host(handle, (0, 0), |host| { + ( + seek_report_pointer(host).addr(), + ptr::from_ref(host.seek_report()).addr(), + ) + }) +} + #[cfg(test)] pub(crate) fn test_status(handle: u32) -> Option { 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 94d099702..fc80aebaa 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -3650,7 +3650,7 @@ impl AudioWorkletEngineHost { /// anchored seek is held by the ring and the consumer applies it at its anchor block, where /// `prepare_source_seek` would refuse a held seek. A host that is not ready refuses with /// [`RESULT_REFUSED_LIFECYCLE`] and changes nothing; the core's refusals map through - /// [`source_failure_result`] (an unknown source or an anchor that is not a quantum multiple is + /// `source_failure_result` (an unknown source or an anchor that is not a quantum multiple is /// [`RESULT_INVALID_ARGUMENT`]). pub fn seek_source_at( &mut self, diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 35d27ef82..3c6b54398 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -1359,8 +1359,8 @@ impl SeekExports { /// /// Test value: red if `source_seek_at` is wired through `plan.prepare_source_seek` (the D4 trap: /// the future anchor would be `RESULT_INTERNAL`), if it queues a plain seek (sound from the next -/// block), or if the report export reads another source's ring, another word order or a stale -/// record. +/// block), or if the report export reads another word order or a stale record. The session has +/// one source, so this test cannot show which source's ring the report reads. #[test] fn the_anchored_seek_exports_play_at_the_anchor_and_report_where_it_landed() { let rig = SeekExports::boot(); @@ -1428,7 +1428,9 @@ fn the_anchored_seek_exports_play_at_the_anchor_and_report_where_it_landed() { /// words, and a wrong handle or an ID count past the staging is `RESULT_INVALID_ARGUMENT`. /// /// Test value: red if either export runs on a host with no control half (it would reach for -/// control state the instance does not hold) or overwrites the record on a refusal. +/// control state the instance does not hold) or overwrites the record on a refusal, if the +/// pointer export names anything but the record while ready or anything at all once not ready, +/// or if the report export records a staged-ID refusal as the last result (it records none). #[test] fn the_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing() { let rig = SeekExports::boot(); @@ -1437,6 +1439,9 @@ fn the_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing() { let _ = rig.render(); let (code, before) = rig.report(); assert_eq!(code, RESULT_OK); + // A ready host's pointer export names the record. + let (pointer, record) = crate::ffi::test_seek_report_addresses(rig.handle); + assert!(record != 0 && pointer == record); // Argument checks on a live host. assert_eq!( @@ -1451,10 +1456,24 @@ fn the_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing() { miso_engine_web_v1_source_seek_at(rig.handle, u32::MAX, 3, 0, 512), RESULT_INVALID_ARGUMENT ); + // The anchored seek records its refusal; the report leaves the last result alone. + assert_eq!( + crate::ffi::test_status(rig.handle) + .expect("status") + .last_result, + RESULT_INVALID_ARGUMENT + ); + assert_eq!(rig.seek_at(3, 0, 1024), RESULT_OK); assert_eq!( miso_engine_web_v1_source_seek_report(rig.handle, u32::MAX), RESULT_INVALID_ARGUMENT ); + assert_eq!( + crate::ffi::test_status(rig.handle) + .expect("status") + .last_result, + RESULT_OK + ); assert_eq!( crate::ffi::native_staging::seek_report(rig.handle), Some(before) @@ -1476,6 +1495,9 @@ fn the_seek_exports_refuse_a_host_with_no_control_half_and_change_nothing() { Some(before), "a refusal leaves the record alone" ); + // A host that is not ready has no record address to give. + let (pointer, record) = crate::ffi::test_seek_report_addresses(rig.handle); + assert!(record != 0 && pointer == 0); assert_eq!(miso_engine_web_v1_source_seek_report_ptr(0), 0); } @@ -6145,8 +6167,11 @@ fn web_runtime_preparer_attaches_the_companions_on_every_branch() { .prepare(&compiled, &caps, lanes, Some(base), &superseded) .unwrap_or_else(|failure| panic!("{capture:?} successor: {failure:?}")); check(&mut successor, &successor_handles, "successor"); + // Every successor carries the timeline's command queue (#1641 D7), which is overhead + // only; a carried ring also brings its PCM payload. assert!( - successor.report.carried_source_total_bytes > 0, + successor.report.carried_source_total_bytes + > successor.report.carried_source_overhead_bytes, "{capture:?}: the successor prepares the unchanged source vacant" ); } diff --git a/scripts/check-abi-layout-v1.py b/scripts/check-abi-layout-v1.py index 65d4a1b3c..dffab2fc8 100644 --- a/scripts/check-abi-layout-v1.py +++ b/scripts/check-abi-layout-v1.py @@ -650,8 +650,12 @@ def drop(document: dict) -> None: raise AssertionError(f"{export} exists in the valid fixture") from error return drop - def drop_seek_report_field(document: dict) -> None: - document["structures"]["seekReport"]["fields"].pop(3) + def swap_seek_report_names(document: dict) -> None: + # Two u64 words trade names and keep their offsets: only the record's name rule sees it. + fields = document["structures"]["seekReport"]["fields"] + first, second = (next(row for row in fields if row["name"] == name) + for name in ("firstSample", "sourceFrame")) + first["name"], second["name"] = second["name"], first["name"] def drop_status_watermark_word(document: dict) -> None: document["structures"]["status"]["fields"].pop() @@ -737,13 +741,9 @@ def boolean_named_value(document: dict) -> None: ("the watermark record omits a field", drop_watermark_field), ("the plan latency record omits a field", drop_plan_latency_field), ("the plan latency read export is dropped", drop_plan_latency_export), - ("the seek report record omits a field", drop_seek_report_field), + ("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 seek report export is dropped", - drop_seek_export("miso_engine_web_v1_source_seek_report")), - ("the seek report pointer export is dropped", - drop_seek_export("miso_engine_web_v1_source_seek_report_ptr")), ("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-session-map-shape.py b/scripts/check-session-map-shape.py index 2dd29e6ae..12d83d853 100644 --- a/scripts/check-session-map-shape.py +++ b/scripts/check-session-map-shape.py @@ -36,7 +36,9 @@ Extending it: the introspection family is derived from the Rust FFI by *signature* -- a handle and at most an index -- but the name pattern is `source_`, because that is the only family the map -carries today. When the output-bus and route lists arrive on this same mechanism (#210 phase 4), +carries today. One name rule excludes the record-pointer exports, whose names end in `_ptr` +(`source_seek_report_ptr`, #1293 D6): they take only the handle, like a count query, but answer +the address of a control-half record, not a source-map field. When the output-bus and route lists arrive on this same mechanism (#210 phase 4), the family regexes below take a second prefix and the field/type derivation applies unchanged; the rules do not need rewriting, only pointing at one more list. @@ -54,7 +56,6 @@ REPO = pathlib.Path(__file__).resolve().parent.parent RUST_FFI = pathlib.Path("hosts/host-web/src/ffi.rs") -SEEK_REPORT_RECORD_POINTER = "miso_engine_web_v1_source_seek_report_ptr" EXPORT_GATE = pathlib.Path("scripts/check-web-audioworklet.sh") WORKLET_JS = pathlib.Path("hosts/host-web/web/miso-engine-v1-audio-worklet.js") HOST_JS = pathlib.Path("hosts/host-web/web/miso-engine-v1-audio-worklet-host.js") @@ -133,16 +134,16 @@ def rust_introspection_exports(text: str) -> dict[str, str]: """`export name -> return width`, for the source queries only. The PCM feed path (`source_submit`, `source_seek`) shares the `source_` prefix and is - deliberately excluded by *signature*, not by name: an introspection query takes the handle and - at most an index, which is what makes it answerable without touching the render plane. + deliberately excluded by *signature*: an introspection query takes the handle and at most an + index, which is what makes it answerable without touching the render plane. A record-pointer + export (a name ending in `_ptr`) has a query's signature and is excluded by that name rule. """ found: dict[str, str] = {} for name, params, width in re.findall( r'pub extern "C" fn (miso_engine_web_v1_source_[a-z_]+)\(([^)]*)\) -> (u32|u64)', text ): - if name == SEEK_REPORT_RECORD_POINTER: - # The seek report's record address (#1293 D6) takes only the handle, like a count - # query, but it is the control half's record pointer, not a source-map query. + if name.endswith("_ptr"): + # A record pointer (#1293 D6), not a source-map query. continue signature = [part.strip() for part in params.split(",") if part.strip()] if signature in (["handle: u32"], ["handle: u32", "index: u32"]): From 35a125e024863b132a5337d4165810ce578cadde Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 14:18:20 +0000 Subject: [PATCH 7/8] Fold the batch 16a fold verification (#1640, #1641, #1294, #1297) M1 completed: where no held entry starts at or before s - 1, step_at also reports a step when timeline_at(s - 1) + 1 != timeline_at(s), so a source-read step-back that leaves the oldest held entry above the lowest sample is a step. Tests: the K=3 and K=2 cases and a sweep over K 1..5 and five step-backs (red on the fold-1 reader). Read bound 6K^2 + 10K + 1. #1294/#1297 amendment cites re-anchored to 90b76cfdf; the publisher of an anchored seek also posts it to the control half as miso.seek-at.v1. #1641 record wording; two rewraps. Co-Authored-By: Claude Opus 5.5 --- ...ransaction-to-the-browser-control-plane.md | 16 ++-- ...-sources-a-browser-edit-adds-or-removes.md | 20 ++-- ...uild-the-timeline-consumer-and-producer.md | 23 ++++- ...n-a-timeline-that-carries-like-a-source.md | 12 ++- crates/graph/src/lib.rs | 5 +- crates/source/src/lib.rs | 93 ++++++++++++++++++- scripts/check-session-map-shape.py | 7 +- 7 files changed, 143 insertions(+), 33 deletions(-) diff --git a/.github/ISSUE_SPECS/1294-send-a-session-transaction-to-the-browser-control-plane.md b/.github/ISSUE_SPECS/1294-send-a-session-transaction-to-the-browser-control-plane.md index ee6959a9a..cad573152 100644 --- a/.github/ISSUE_SPECS/1294-send-a-session-transaction-to-the-browser-control-plane.md +++ b/.github/ISSUE_SPECS/1294-send-a-session-transaction-to-the-browser-control-plane.md @@ -199,7 +199,7 @@ worklet only renders and swaps. ## Amendment (controls coordinator, batch 16a review, 2026-10-16) -Anchors checked against `35ac17545` plus the batch 16a tree. Earlier text is kept. +Anchors checked against `90b76cfdf` (batch 16a with its first fold). Earlier text is kept. **Finding (review M4).** D1 names exports that #1293 dropped. - `miso_engine_web_v1_edit_ptr` and `miso_engine_web_v1_apply`, and the "outcome record", do not exist. @@ -210,13 +210,13 @@ Anchors checked against `35ac17545` plus the batch 16a tree. Earlier text is kep through the same generic entry. Its browser transport is #1697. See #1386's amendments. **Rule, replacing D1's call path.** -- A transaction is staged in `miso_engine_web_v1_control_request_ptr` (`hosts/host-web/src/ffi.rs:3450`) - and applied with `miso_engine_web_v1_control_command` (`:3469`), on the control half only. The worklet +- A transaction is staged in `miso_engine_web_v1_control_request_ptr` (`hosts/host-web/src/ffi.rs:3464`) + and applied with `miso_engine_web_v1_control_command` (`:3483`), on the control half only. The worklet already has the staging helper: `readControlCommand` (`hosts/host-web/web/miso-engine-v1-audio-worklet.js:1727`) and the message `miso.control.v1` (`CONTROL_FIELDS = ["tag", "requestId", "frame"]`, `:93`; dispatched at `:1116`). -- The outcome is the response frame, read with `miso_engine_web_v1_control_response_ptr` (`ffi.rs:3502`) - and `_control_response_bytes` (`:3515`): `revision`, `edit_path` and the diagnostics. `miso.edit.v1` +- The outcome is the response frame, read with `miso_engine_web_v1_control_response_ptr` (`ffi.rs:3516`) + and `_control_response_bytes` (`:3529`): `revision`, `edit_path` and the diagnostics. `miso.edit.v1` `{ result, revision, path, diagnostic }` is built from that frame and from the call's result code. `path` is the frame's `edit_path`; `diagnostic` is the frame's diagnostic text, empty on success. - `miso.apply.v1` and `miso.replace.v1` stay as the SDK's internal messages only if the root re-brief @@ -227,10 +227,12 @@ Anchors checked against `35ac17545` plus the batch 16a tree. Earlier text is kep - Context, the exports bullet (`:47-53`): "the exports come from #1293" is now only `source_seek_at` and `source_seek_report`. Apply and replace come from #1562's generic entry and #1386/#1697. The service and watermark exports stay with #1381. -- D2 (`:71-75`): `source_seek_at` is real (`ffi.rs:3311`) and is control half only: it runs the service +- D2 (`:71-75`): `source_seek_at` is real (`ffi.rs:3313`) and is control half only: it runs the service step (`crates/control-plane/src/control.rs:4073-4081`) and must never run in `process()`. Its sentence "Feeds that use the shared PCM ring do not use it" is amended: a ring's anchored seek is applied by this - handler between blocks (#1297 amendment of 2026-10-16). The feed worklet's render path keeps the plain + handler between blocks (#1297 amendment of 2026-10-16). The writer that publishes an anchored seek on + the ring also posts the same seek (generation, frame, anchor) to the control half as + `miso.seek-at.v1`; that message is what wakes this handler. The feed worklet's render path keeps the plain seek. No render-side caller is added. - D6 (`:118-120`): `apply(transaction)` and `replace(document)` call the generic entry; they need no `edit_ptr`. diff --git a/.github/ISSUE_SPECS/1297-feed-and-retire-the-sources-a-browser-edit-adds-or-removes.md b/.github/ISSUE_SPECS/1297-feed-and-retire-the-sources-a-browser-edit-adds-or-removes.md index 6551a130b..9d423ccd2 100644 --- a/.github/ISSUE_SPECS/1297-feed-and-retire-the-sources-a-browser-edit-adds-or-removes.md +++ b/.github/ISSUE_SPECS/1297-feed-and-retire-the-sources-a-browser-edit-adds-or-removes.md @@ -149,7 +149,7 @@ during its ramp-out. ## Amendment (controls coordinator, batch 16a review, 2026-10-16) -Anchors checked against `35ac17545` plus the batch 16a tree. Earlier text is kept. +Anchors checked against `90b76cfdf` (batch 16a with its first fold). Earlier text is kept. **Finding (review M4).** D3 has `applySharedSeek` call `miso_engine_web_v1_source_seek_at`. That is wrong for the feed worklet's render path. @@ -159,10 +159,10 @@ wrong for the feed worklet's render path. - The feed worklet is a render-thread caller of the plain seek: `tools/realtime-policy/render-thread-exports.json:10` lists `source_seek` with the callers `engine/receiveSeek` and `feed/applySharedSeek`. `source_seek_at` is not on that table and must not be added. -- `source_seek_at` (`hosts/host-web/src/ffi.rs:3311`) calls `SessionState::seek_at` +- `source_seek_at` (`hosts/host-web/src/ffi.rs:3313`) calls `SessionState::seek_at` (`crates/control-plane/src/control.rs:4073-4081`). That runs `service()` first (`:4080`; `service` is `:1930`). The service step drops retired plans, takes the shared report lock and may allocate. The - doc comment on the export says the same: control half only, never `process()` (`ffi.rs:3299-3303`). + doc comment on the export says the same: control half only, never `process()` (`ffi.rs:3301-3305`). The plain seek uses `seek_unserviced` (`hosts/host-web/src/lib.rs:3630`; `control.rs:4059`) for this reason. @@ -170,15 +170,17 @@ wrong for the feed worklet's render path. - A render-thread caller keeps the plain `miso_engine_web_v1_source_seek`. `applySharedSeek` called from `drainSharedRing` never calls `source_seek_at`. - An anchored seek (anchor not `-1`) is applied by the control half's message handler, between blocks: - the Worker in `worker` mode, the worklet's control handler in `single` mode. The handler may reach - `source_seek_at` through #1294 D2's path (`miso.seek-at.v1`, or the same handler code reading the - ring's published generation, frame and anchor). It does not run in `process()`. + the Worker in `worker` mode, the worklet's control handler in `single` mode. The writer that + publishes an anchored seek on the ring also posts the same seek (generation, frame, anchor) to the + control half as `miso.seek-at.v1` (#1294 D2 and its amendment of 2026-10-16). That message is what + wakes the handler, and the handler calls `source_seek_at` once for it. It does not run in + `process()`. - The drain must not consume a ring whose newest seek epoch is anchored and not yet applied by the handler. It leaves the epoch unseen and reads no slot of that ring (the same wait the drain does today on `RESULT_BACKPRESSURE`, `:476-480`). Otherwise the drain would feed old-generation PCM to a source that is about to seek. -- "One seek channel" (D3) still holds: the ring publishes the seek once. The handler applying it is - not a second channel. #1294 D2's sentence that shared-ring feeds do not use `miso.seek-at.v1` is +- "One seek channel" (D3) still holds: the ring publishes the seek once. The `miso.seek-at.v1` post + carries the same seek to the handler that applies it; it is not a second seek. #1294 D2's sentence that shared-ring feeds do not use `miso.seek-at.v1` is amended in #1294 in the same batch. - A render-side anchored seek would need `seek_at_unserviced` in `crates/control-plane`. No slice builds it (#1293 record, "Successors"). If this issue needs one, it names that successor first and waits @@ -189,7 +191,7 @@ wrong for the feed worklet's render path. - D4 (`:80-81`, "then publishes the anchored seek once"): the publish is the writer's. Application is the handler's, so `A` must still be ahead of the render clock when the handler runs. The prime length in `A` (D4) covers the handler latency. It stays an engine-compensated lateness otherwise (gate 2). -- D4 `alignTo` reads the seek report. `source_seek_report` (`ffi.rs:3339`) runs +- D4 `alignTo` reads the seek report. `source_seek_report` (`ffi.rs:3346`) runs `SessionState::seek_report`, which also runs the service step. So the report is read from the control half, not from `process()`. It is not on a render path in this spec; keep it so. - Gate 1 (`:110-112`) says "the drain calls `source_seek_at` exactly once". Read it as "the control diff --git a/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md index 35ea2da32..a069991f2 100644 --- a/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md +++ b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md @@ -311,10 +311,11 @@ counts (the driver), so no row charges it (fold MINOR 6 removed `consumer_bytes` 512). It now checks every entry's first sample and its end (`sr + q`) inside the node block and reports the first sample `s` where `timeline_at(s) != timeline_at(s - 1) + 1`. Where no held entry starts at or before `s - 1` (or `s` is 0), the entry at `s` is a step when it is a seek, read - against `oldest_expected_timeline` as before. Bound: at most `6K² + 6K` entry reads, - allocation-free. Test `a_node_block_reports_the_steps_of_a_source_read_step_back` (red on the - unfixed code: `{start: 216, step: None}`), which also checks every block's span against - `timeline_at` sample by sample. + against `oldest_expected_timeline` as before. Bound: at most `6K² + 6K` entry reads (fold 2: + `6K² + 10K + 1`), allocation-free. Test + `a_node_block_reports_the_steps_of_a_source_read_step_back` (red on the unfixed code: + `{start: 216, step: None}`), which also checks every block's span against `timeline_at` sample by + sample. - **MINOR 4.** `the_reader_reads_the_newest_overlap_and_reports_the_first_step` (`K` 4 and 3): red for a `resolve` searched oldest first, a later step reported instead of the first, and an eviction that does not update `oldest_expected_timeline` (each mutation run). @@ -333,3 +334,17 @@ counts (the driver), so no row charges it (fold MINOR 6 removed `consumer_bytes` (#1641's, in this module). - Rustdoc: the public docs no longer link the private `SeekState`, `SeekClock::At` and `SeekState::prime_window_clear` (`cargo doc -D warnings`). +- **Fold 2: M1 completed.** After a step-back the oldest held entry is not the lowest source-read + sample, so where no held entry starts at or before `s - 1` the value at `s - 1` is extrapolated + back from an entry that need not start at `s`, and the seek-only test missed a real step (`K` 3, + blocks at 0, 128, 256, 384, 384, 512: `timeline_block(260, 128)` gave no step, `timeline_at(384)` + is 512; `K` 2, blocks at 0, 128, 256, 384, 256: `timeline_block(200, 128)` gave no step, + `timeline_at(256)` is 512). `step_at` now reports a step wherever `s > 0` and + `timeline_at(s - 1) + 1 != timeline_at(s)`, and keeps the oldest-seek rule where no held entry + starts at or before `s - 1`, so the claim above (a node block with at most one step agrees with + `timeline_at` on every sample) holds. Bound: at most `6K² + 10K + 1` entry reads, + allocation-free, no new panic path, inside its `REALTIME_POLICY` region. Tests + `a_step_back_steps_where_no_held_entry_starts_before_it` (the two cases) and + `every_node_block_with_one_step_agrees_with_timeline_at_after_a_step_back` (`K` 1 to 5, step-backs + of 64, 128, 200, 256 and 384, a node block from every fourth sample): both red on the fold-1 code + (`{start: 260, step: None}`; the sweep reads 384 for 512), green with the fix. diff --git a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md index 6fcdc84f9..a68b9c932 100644 --- a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md +++ b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md @@ -439,9 +439,11 @@ was not run (worker rules). `carried_source_total_bytes > carried_source_overhead_bytes`: the timeline queue is overhead only, a carried ring adds its PCM payload. (Not `timeline_queue_bytes()` as in `successor_swap.rs`: host-web does not depend on `source`, and this needs no new dependency.) -- **MINOR 3.** `timeline::a_successor_at_another_rate_or_quantum_starts_a_fresh_timeline` (red when - the guard is `successor.is_some()`), `timeline::a_seek_queued_at_the_swap_block_lands_in_the_successors_first_block` - (red when the carry does not swap the command consumer), and the vacant-timeline unit test +- **MINOR 3.** `timeline::a_successor_at_another_rate_or_quantum_starts_a_fresh_timeline` (red + when the guard is `successor.is_some()`), + `timeline::a_seek_queued_at_the_swap_block_lands_in_the_successors_first_block` (its unique + catch: a carry that drains the queue, so the seek queued at the swap block is lost), and the + vacant-timeline unit test `source::tests::timeline::a_vacant_timeline_never_adopted_advances_from_zero`. - **MINOR 4.** The discontinuity gate's claim that it reads the overlap's older entry is removed: its plan has `K = 1`, so no overlap is held. The overlap rule is `source`'s @@ -453,3 +455,7 @@ was not run (worker rules). `timeline.rs`'s gate 4 already counts the same carry and blocks. - No charged byte moved (#1640 fold MINOR 6 removed only unread rows); `realtime_policy` 153 regions, unchanged. +- **Fold 2.** M1 is completed in `source` (#1640 "Fold 2"): a node block now also steps where a + newer entry starts below every older entry after a step-back, so a carried timeline's step-back + reads right in every node block with at most one step. This record's MINOR 3 line now names the + queued-seek test's unique catch. No charged byte moved; `realtime_policy` regions unchanged. diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 8d0c1fbc6..034eca344 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -3703,8 +3703,9 @@ pub fn plan_identity(plan: &mut PreparedRenderPlan) -> Option { /// /// Call it before publication. Refused, leaving any earlier program in place, when the plan is /// not a graph plan, has no source set for a program that moves a source or the timeline, or a -/// successor index is out of range, not vacant or repeated, or a predecessor index is repeated. Predecessor indices are checked against the -/// predecessor at the swap block, where an out-of-range index refuses the whole move. +/// successor index is out of range, not vacant or repeated, or a predecessor index is repeated. +/// Predecessor indices are checked against the predecessor at the swap block, where an +/// out-of-range index refuses the whole move. pub fn install_carry_program( plan: &mut PreparedRenderPlan, program: GraphCarryProgram, diff --git a/crates/source/src/lib.rs b/crates/source/src/lib.rs index d2408b1da..cbc080f0a 100644 --- a/crates/source/src/lib.rs +++ b/crates/source/src/lib.rs @@ -2442,7 +2442,7 @@ impl TimelineConsumer { /// step is `(0, start)`. A node block with at most one step, every block /// a host renders without a step-back inside it, agrees with `timeline_at` on every sample; /// of several, the first is reported. Read-only and allocation-free, with at most - /// `6K² + 6K` entry reads. + /// `6K² + 10K + 1` entry reads. #[must_use] pub fn timeline_block(&self, first_source_read_sample: u64, frames: u32) -> TimelineSpan { let start = self.timeline_at(first_source_read_sample); @@ -2476,15 +2476,18 @@ impl TimelineConsumer { /// The timeline value at `sample` when the timeline steps there: when it is not the value at /// `sample - 1` plus one. When no held entry starts at or before `sample - 1` (that value is - /// extrapolated back, or `sample` is 0), the entry that starts at `sample` is a step when it - /// is a seek, read against `oldest_expected_timeline` for the oldest entry. + /// extrapolated back from the oldest entry, or `sample` is 0), the entry that starts at + /// `sample` is also a step when it is a seek, read against `oldest_expected_timeline` for the + /// oldest entry: the history no longer holds the block it stepped from. fn step_at(&self, sample: u64) -> Option { let (resolving, timeline) = self.resolve(sample); let stepped = match sample.checked_sub(1) { - Some(previous) if self.starts_at_or_before(previous) => { + Some(previous) => { self.resolve(previous).1.checked_add(1) != Some(timeline) + || (!self.starts_at_or_before(previous) + && resolving.is_some_and(|age| self.is_seek_entry(age))) } - _ => resolving.is_some_and(|age| self.is_seek_entry(age)), + None => resolving.is_some_and(|age| self.is_seek_entry(age)), }; stepped.then_some(timeline) } @@ -6486,6 +6489,86 @@ mod tests { } } + /// Batch 16a fold 2, M1: after a step-back the oldest held entry is not the lowest + /// source-read sample, so a sample before every held entry is extrapolated back from an + /// entry that does not start next to it, and the timeline can step where a newer entry + /// starts although that entry is no seek. + /// + /// Test value: red if `timeline_block` tests only for a seek entry where no held entry + /// starts at or before `s - 1` (`K` 3: samples 124 to 127 read 128 off; `K` 2: samples 56 + /// to 127 read 256 off). + #[test] + fn a_step_back_steps_where_no_held_entry_starts_before_it() { + let (_producer, mut consumer) = timeline(3); + blocks_at(&mut consumer, &[0, QU, 2 * QU, 3 * QU, 3 * QU, 4 * QU]); + assert_eq!(consumer.timeline_at(383), 383); + assert_eq!(consumer.timeline_at(384), 512); + assert_eq!( + consumer.timeline_block(260, Q), + TimelineSpan { + start: 260, + step: Some(TimelineStep { + offset: 124, + timeline: 512, + }), + } + ); + + let (_producer, mut consumer) = timeline(2); + blocks_at(&mut consumer, &[0, QU, 2 * QU, 3 * QU, 2 * QU]); + assert_eq!(consumer.timeline_at(255), 255); + assert_eq!(consumer.timeline_at(256), 512); + assert_eq!( + consumer.timeline_block(200, Q), + TimelineSpan { + start: 200, + step: Some(TimelineStep { + offset: 56, + timeline: 512, + }), + } + ); + } + + /// Batch 16a fold 2, M1 sweep: `K` 1 to 5, a step-back of 64, 128, 200, 256 or 384 + /// samples after four plain blocks, and a node block from every fourth sample. + /// + /// Test value: red if any node block with at most one step disagrees with `timeline_at` + /// on any sample (a missed or a misplaced step, whatever entry is the oldest). + #[test] + fn every_node_block_with_one_step_agrees_with_timeline_at_after_a_step_back() { + for history in 1..=5 { + for back in [64, 128, 200, 256, 384] { + let (_producer, mut consumer) = timeline(history); + blocks_at( + &mut consumer, + &[0, QU, 2 * QU, 3 * QU, 4 * QU - back, 5 * QU - back], + ); + for first in (0..6 * QU).step_by(4) { + let steps = (0..Q) + .filter(|&i| { + let sample = first + u64::from(i); + sample > 0 + && consumer.timeline_at(sample) + != consumer.timeline_at(sample - 1) + 1 + }) + .count(); + if steps > 1 { + continue; + } + let span = consumer.timeline_block(first, Q); + for i in 0..Q { + assert_eq!( + span_value(span, i), + consumer.timeline_at(first + u64::from(i)), + "K {history}, back {back}, block from {first}, sample {i}" + ); + } + } + } + } + } + /// Batch 16a fold MINOR 4, `K` of 3 and 4: the reader's search order. /// /// Test value: red if `timeline_at` searches the history oldest first (an overlap reads diff --git a/scripts/check-session-map-shape.py b/scripts/check-session-map-shape.py index 12d83d853..76b5e0bbf 100644 --- a/scripts/check-session-map-shape.py +++ b/scripts/check-session-map-shape.py @@ -38,9 +38,10 @@ at most an index -- but the name pattern is `source_`, because that is the only family the map carries today. One name rule excludes the record-pointer exports, whose names end in `_ptr` (`source_seek_report_ptr`, #1293 D6): they take only the handle, like a count query, but answer -the address of a control-half record, not a source-map field. When the output-bus and route lists arrive on this same mechanism (#210 phase 4), -the family regexes below take a second prefix and the field/type derivation applies unchanged; the -rules do not need rewriting, only pointing at one more list. +the address of a control-half record, not a source-map field. When the output-bus and route +lists arrive on this same mechanism (#210 phase 4), the family regexes below take a second prefix +and the field/type derivation applies unchanged; the rules do not need rewriting, only pointing at +one more list. Run with no arguments to check the tree, or `--artifacts DIR` to additionally hold a built artifact directory's shipped JS/`.d.ts` to the same rules. From 999aa48a3dbe8896ffa43f45ff60570235888238 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 16 Oct 2026 15:00:37 +0000 Subject: [PATCH 8/8] Number the timeline seek report placeholder: #1704 Co-Authored-By: Claude Opus 5.5 --- ...641-give-every-plan-a-timeline-that-carries-like-a-source.md | 2 +- ...ts.md => 1704-report-the-timeline-seek-position-to-hosts.md} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename .github/ISSUE_SPECS/{TBD45-report-the-timeline-seek-position-to-hosts.md => 1704-report-the-timeline-seek-position-to-hosts.md} (93%) diff --git a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md index a68b9c932..217f4103b 100644 --- a/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md +++ b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md @@ -320,7 +320,7 @@ Checked against `35ac17545`. and a source consumer directly until #1355 wires the prime above the consumer. - **B16-R4, D6 deferred.** No host reads a timeline seek report after this batch. D6 (the timeline's seek-report record), its bytes in D7 and its move in D3 are taken out of this slice. - Placeholder TBD45 *Report the timeline seek position to hosts* keeps them, for the first slice + #1704 *Report the timeline seek position to hosts* keeps them, for the first slice that needs a host reader. Gate 1 compares `test_only_timeline_at` with the frame each block's source read starts at; its seek-report clause is dropped. diff --git a/.github/ISSUE_SPECS/TBD45-report-the-timeline-seek-position-to-hosts.md b/.github/ISSUE_SPECS/1704-report-the-timeline-seek-position-to-hosts.md similarity index 93% rename from .github/ISSUE_SPECS/TBD45-report-the-timeline-seek-position-to-hosts.md rename to .github/ISSUE_SPECS/1704-report-the-timeline-seek-position-to-hosts.md index 7175b0ade..90411d0a2 100644 --- a/.github/ISSUE_SPECS/TBD45-report-the-timeline-seek-position-to-hosts.md +++ b/.github/ISSUE_SPECS/1704-report-the-timeline-seek-position-to-hosts.md @@ -1,6 +1,6 @@ # Report the timeline seek position to hosts -Placeholder from root ruling B16-R4 (batch 16, 2026-10-16). A draft for root to file. +Filed by root from ruling B16-R4 (batch 16, 2026-10-16). ## Product outcome