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..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 @@ -282,3 +282,155 @@ 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. + +## 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 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: `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.** +- 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` (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`. + +**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. +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..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 @@ -196,3 +196,55 @@ 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 `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. + 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: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: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 + 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: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 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`. +- 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..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 @@ -146,3 +146,63 @@ 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 `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. +- `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: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:3301-3305`). + 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 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 `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 + 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: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 + 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/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/1640-build-the-timeline-consumer-and-producer.md b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md index 3a6fb0d9c..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 @@ -179,3 +179,172 @@ 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. + +## 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), + `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`. + +**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 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, 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:** 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; 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`, + `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). + +**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 (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). +- **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`). +- **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 ae1388a5e..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 @@ -233,3 +233,229 @@ 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. + +## 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. + #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. + +## 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 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 + 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 `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 +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`. +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). + +**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` (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 + `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. +- **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/.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..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 @@ -246,3 +246,115 @@ 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`. + +## 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 a8a3ce14c..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 @@ -189,3 +189,70 @@ 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). + +## 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/1704-report-the-timeline-seek-position-to-hosts.md b/.github/ISSUE_SPECS/1704-report-the-timeline-seek-position-to-hosts.md new file mode 100644 index 000000000..90411d0a2 --- /dev/null +++ b/.github/ISSUE_SPECS/1704-report-the-timeline-seek-position-to-hosts.md @@ -0,0 +1,29 @@ +# Report the timeline seek position to hosts + +Filed by root from ruling B16-R4 (batch 16, 2026-10-16). + +## 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/crates/capi/tests/resource_lifecycle.rs b/crates/capi/tests/resource_lifecycle.rs index ba5d22e55..22b11e150 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", @@ -1800,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 b3afe5d3f..034eca344 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 { @@ -3693,9 +3702,10 @@ 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 -/// predecessor at the swap block, where an out-of-range index refuses the whole move. +/// 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, program: GraphCarryProgram, @@ -3704,11 +3714,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() { @@ -4344,6 +4354,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 +4808,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 { @@ -9147,6 +9168,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/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 a93666584..fe0557b22 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; @@ -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", } } @@ -137,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] { @@ -306,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 @@ -317,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() { @@ -332,6 +358,9 @@ impl SourceControlSet { moved += 1; } } + if self.timeline.is_none() { + self.timeline = predecessor.timeline.take(); + } moved } 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/host-core/tests/successor_swap.rs b/crates/host-core/tests/successor_swap.rs index e6e93541b..e24356d87 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 @@ -2377,6 +2405,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..869fa9b76 --- /dev/null +++ b/crates/host-core/tests/timeline.rs @@ -0,0 +1,846 @@ +//! 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); +} + +/// 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 +/// 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). 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; + 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. + 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 b8b82fa9d..cbc080f0a 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,235 @@ 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) + } + + /// 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 + .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 +1470,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 +1480,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 +1606,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 +1633,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 +1760,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 +1798,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 +1812,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); + 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); } - 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); - } - } - 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; @@ -1674,23 +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 - .held_seek - .is_some_and(|held| held.anchor_sample < 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; @@ -1711,7 +1900,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 +2027,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,80 +2056,550 @@ 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 { - self.note_end_frame(&block); + fn note_end_and_discard(&mut self, block: Box) { + if block.end_of_region && block.generation == self.seek.active_generation { + self.note_end_frame(&block); + } + self.discard_block(block); + } + + fn note_end_frame(&mut self, block: &TransferBlock) { + self.end_frame = Some(SourceFrame( + block.start_frame.0.saturating_add(u64::from(block.frames)), + )); + } + + fn discard_block(&mut self, mut block: Box) { + self.stale_generation_discard_count = self.stale_generation_discard_count.saturating_add(1); + block.reset_metadata(); + self.recycle_block(block); + } + + /// Make `block` the played block, then hand the idle retained block back to the producer: + /// the consumer keeps exactly one block outside the queues. + fn play(&mut self, mut block: Box) { + let quantum = usize::try_from(self.quantum_frames).expect("u32 fits usize"); + let frames = usize::try_from(block.frames).expect("u32 fits usize"); + if frames < quantum { + // A short block is the region's last: zero each plane's tail in place, once, so a + // borrowed plane is a whole quantum. `copy_channel` keeps its own tail fill. + for plane in block.samples.chunks_exact_mut(quantum) { + plane[frames..].fill(0.0); + } + } + debug_assert!( + self.played.is_none(), + "begin_block ended the previous block" + ); + self.played = Some(block); + if let Some(idle) = self.retained_idle.take() { + self.recycle_block(idle); + } + } + + fn recycle_block(&mut self, block: Box) { + match self.recycle_producer.try_push(block) { + Ok(()) => {} + Err(QueueFull { value, .. }) => { + self.deferred_recycle = Some(value); + } + } + } + + fn flush_deferred_recycle(&mut self) { + let Some(block) = self.deferred_recycle.take() else { + return; + }; + self.recycle_block(block); + } + + fn end_reached(&self) -> bool { + self.end_frame.is_some_and(|end| self.next_frame.0 >= end.0) + } +} + +/// 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: 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`. + 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, +} + +/// 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 queue_largest = largest_queue_allocation::(NonZeroUsize::MIN)?; + 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, + }) + } + + /// 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 consumer = + TimelineConsumer::with_history(quantum_frames, history_blocks, Some(command_consumer))?; + Ok(( + TimelineProducer { + seek: SeekProducer { + command_producer, + active_generation: initial_generation, + quantum_frames: quantum_frames.0, + frame_limit: Some(TIMELINE_SAMPLE_LIMIT), + }, + }, + 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 +/// 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 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) + } + + /// 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 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. + command_consumer: Option>, + 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 { + /// 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 { + 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) { + // 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, + }); + 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 `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² + 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); + 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; + }; + 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 }); + } + } } - self.discard_block(block); + 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 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) => { + 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))) + } + None => resolving.is_some_and(|age| self.is_seek_entry(age)), + }; + stepped.then_some(timeline) } - fn note_end_frame(&mut self, block: &TransferBlock) { - self.end_frame = Some(SourceFrame( - block.start_frame.0.saturating_add(u64::from(block.frames)), - )); + /// 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) } - fn discard_block(&mut self, mut block: Box) { - self.stale_generation_discard_count = self.stale_generation_discard_count.saturating_add(1); - block.reset_metadata(); - self.recycle_block(block); + /// 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, + }; } - /// Make `block` the played block, then hand the idle retained block back to the producer: - /// the consumer keeps exactly one block outside the queues. - fn play(&mut self, mut block: Box) { - let quantum = usize::try_from(self.quantum_frames).expect("u32 fits usize"); - let frames = usize::try_from(block.frames).expect("u32 fits usize"); - if frames < quantum { - // A short block is the region's last: zero each plane's tail in place, once, so a - // borrowed plane is a whole quantum. `copy_channel` keeps its own tail fill. - for plane in block.samples.chunks_exact_mut(quantum) { - plane[frames..].fill(0.0); + /// 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; } - debug_assert!( - self.played.is_none(), - "begin_block ended the previous block" - ); - self.played = Some(block); - if let Some(idle) = self.retained_idle.take() { - self.recycle_block(idle); + 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; } - fn recycle_block(&mut self, block: Box) { - match self.recycle_producer.try_push(block) { - Ok(()) => {} - Err(QueueFull { value, .. }) => { - self.deferred_recycle = Some(value); - } - } + /// 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() } - fn flush_deferred_recycle(&mut self) { - let Some(block) = self.deferred_recycle.take() else { - return; + /// 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; }; - self.recycle_block(block); + 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 } - fn end_reached(&self) -> bool { - self.end_frame.is_some_and(|end| self.next_frame.0 >= end.0) + /// 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)) } } +/// `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 }) +} +// REALTIME_POLICY_END + fn queue_bytes(capacity: NonZeroUsize) -> Result { let payload = bounded_spsc_retained_payload::(capacity).map_err(map_spsc_error)?; u64::try_from( @@ -2036,6 +2694,8 @@ pub enum SourceGraphSourceSetError { SourceIndex, ChannelIndex, ArithmeticOverflow, + /// The timeline's quantum is not the envelope's (#1641 D1). + TimelineQuantum, } struct GraphSourceEntry { @@ -2046,6 +2706,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, @@ -2148,6 +2810,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 { @@ -2286,6 +2951,11 @@ 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 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, @@ -2320,9 +2990,17 @@ impl GraphPreparedSourceSetDriver for SourceGraphSourceSetDriver { &mut predecessor.sources[source as usize].consumer, ); } + if self.timeline.is_vacant() && !predecessor.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> { @@ -2415,18 +3093,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. @@ -2485,6 +3181,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, @@ -2500,6 +3197,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, @@ -2710,7 +3414,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 +3452,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 +3568,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 +3598,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 +3656,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" ); } @@ -3244,6 +3948,7 @@ mod tests { mappings: Vec, ) -> SourceGraphSourceSetDriver { SourceGraphSourceSetDriver { + timeline: test_timeline(4), sources: vec![GraphSourceEntry { channel_count: consumer.channel_count(), consumer: Some(consumer), @@ -3310,6 +4015,7 @@ mod tests { .consumer .as_ref() .unwrap() + .seek .held_seek .is_none() ); @@ -3918,6 +4624,7 @@ mod tests { prepare_graph_source_set( envelope, vec![SourceGraphSource::new(consumer, resources, 0, 0)], + test_timeline(2), mappings, ) .expect("source set") @@ -4469,7 +5176,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))], @@ -4614,6 +5323,7 @@ mod tests { GraphCarryProgram { predecessor, sources: vec![(0, 0)].into_boxed_slice(), + timeline: false, } } @@ -4676,6 +5386,7 @@ mod tests { Some(GraphCarryProgram { predecessor: identity, sources: vec![(0, 1)].into_boxed_slice(), + timeline: false, }) }); assert_eq!(carry, CarryOutcome::PredecessorMismatch); @@ -4751,6 +5462,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)])), @@ -4793,11 +5505,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"); @@ -4821,6 +5539,7 @@ mod tests { prepare_graph_source_set( envelope(), vec![SourceGraphSource::vacant(1)], + test_timeline(FRAMES), mappings() ), Err(SourceGraphSourceSetError::ChannelIndex) @@ -4870,6 +5589,7 @@ mod tests { GraphCarryProgram { predecessor: identity, sources: vec![(0, 0), (1, 5)].into_boxed_slice(), + timeline: false, }, ) .expect("install"); @@ -4898,6 +5618,7 @@ mod tests { GraphCarryProgram { predecessor: 1, sources: vec![(0, 0), (1, 0)].into_boxed_slice(), + timeline: false, }, ), Err(GraphCarryInstallError::DuplicateSourceIndex) @@ -4910,6 +5631,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, @@ -5030,6 +5752,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, @@ -5056,12 +5779,63 @@ 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, quantum_frames: u32, ) -> SourceGraphSourceSetDriver { SourceGraphSourceSetDriver { + timeline: test_timeline(quantum_frames), sources: entries.into_boxed_slice(), mappings: Vec::new().into_boxed_slice(), quantum_frames, @@ -5361,6 +6135,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 +6297,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 +6317,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 +6335,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 +6362,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 +6391,520 @@ 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() + } + + /// 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_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 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 + /// 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 the_reader_reads_the_newest_overlap_and_reports_the_first_step() { + let (mut producer, mut consumer) = timeline(4); + blocks_at(&mut consumer, &[0, QU, 2 * QU]); + producer.try_seek(seek(2, 10_000)).unwrap(); + // 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] + 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.command_queue_largest_allocation_bytes, + largest_queue_allocation::(NonZeroUsize::MIN).unwrap() + ); + assert!(report.command_queue_largest_allocation_bytes <= report.command_queue_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/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 diff --git a/hosts/host-web/src/ffi.rs b/hosts/host-web/src/ffi.rs index 21fda2868..044051f2d 100644 --- a/hosts/host-web/src/ffi.rs +++ b/hosts/host-web/src/ffi.rs @@ -3275,6 +3275,105 @@ pub extern "C" fn miso_engine_web_v1_source_seek( }) } +/// 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, +) -> Result { + let Some(ids) = host.source_id_mut() else { + return Err(RESULT_INTERNAL); + }; + let Some(source_id) = ids.get(..id_count) else { + 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) }; + Ok(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); + } + // `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), + } + }) +} + +/// 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, 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(&[]); + } + // `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 + }) +} + +/// 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| 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. #[unsafe(no_mangle)] pub extern "C" fn miso_engine_web_v1_render(handle: u32, actual_frames: u32) -> u32 { @@ -4159,6 +4258,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 { @@ -4253,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 9348d23b0..fc80aebaa 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..3c6b54398 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -1277,6 +1277,230 @@ 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 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(); + 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, 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(); + 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); + // 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!( + 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 + ); + // 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) + ); + + // 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" + ); + // 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); +} + #[test] fn output_mismatch_and_disposal_are_sticky_and_idempotent() { let mut host = prepared_host(64); @@ -5943,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/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/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/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", diff --git a/scripts/check-abi-layout-v1.py b/scripts/check-abi-layout-v1.py index ec1c3ff38..dffab2fc8 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,21 @@ 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 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() @@ -716,6 +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 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 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..76b5e0bbf 100644 --- a/scripts/check-session-map-shape.py +++ b/scripts/check-session-map-shape.py @@ -36,9 +36,12 @@ 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), -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. +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. 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. @@ -132,13 +135,17 @@ 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.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"]): 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", 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