diff --git a/Cargo.lock b/Cargo.lock index 5a2be1236..d995dc82d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4775,6 +4775,9 @@ name = "oak-worker" version = "0.1.0" dependencies = [ "libc", + "oakcore-rs", + "oaknode", + "oakplugin", "oakrender", "serde", "serde_json", @@ -4886,11 +4889,14 @@ dependencies = [ name = "oakrender" version = "0.1.0" dependencies = [ + "libc", "oakcodec", "oakcommon", "oakcore-rs", "oaknode", "ocio-rs", + "serde", + "serde_json", "thiserror 2.0.20", "wgpu 25.0.2", ] diff --git a/crates/oak-worker/Cargo.toml b/crates/oak-worker/Cargo.toml index 9c1ccdabe..9c1deb93e 100644 --- a/crates/oak-worker/Cargo.toml +++ b/crates/oak-worker/Cargo.toml @@ -30,11 +30,24 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" # POSIX shm_open/ftruncate/mmap/munmap/shm_unlink for the shared-memory -# frame-slot transport (src/ipc.rs). +# frame-slot transport (the oakrender::ipc shim above). libc also powers +# the SIGSEGV crash hook (OAK_WORKER_CRASH_ON_TICKET) used by the crash- +# isolation integration tests. libc = "0.2" -# M14 R2: oak-worker is a PURE module-crate consumer — the worker runtime -# (src/worker.rs, src/ipc.rs) lives in this binary and calls the oak* -# rlibs directly (oakrender for the render backend + color config). No -# liboakengine dylib, no C ABI, no build.rs link step. +# M15 S1: real per-process rendering — the worker loads the project graph +# itself (oaknode::serializer) and runs nodes through the plugin runtime +# (oakplugin::node_factory::install_render_executor wires the CPU render +# executor). oakcodec/ocio-rs/ffmpeg arrive transitively through +# oaknode/oakplugin — the same single-lib unification the engine uses. +oakcore-rs = { path = "../oakcore" } +oaknode = { path = "../oaknode" } +oakplugin = { path = "../oakplugin" } + +# M14 R2 / M15 S1: oak-worker is a PURE module-crate consumer — the +# worker runtime (src/worker.rs) lives in this binary and calls the oak* +# rlibs directly. oakrender supplies the render backend, the CPU +# evaluation path and the shared ipc protocol (src/ipc.rs is a shim +# re-exporting oakrender::ipc). No liboakengine dylib, no C ABI, no +# build.rs link step. oakrender = { path = "../oakrender" } diff --git a/crates/oak-worker/README.md b/crates/oak-worker/README.md index 782f665b5..ea162c89d 100644 --- a/crates/oak-worker/README.md +++ b/crates/oak-worker/README.md @@ -19,10 +19,16 @@ dylib is needed at build or run time. `oakengine_worker_main()` and owns the whole runtime: render backend selection through the oakrender crate's direct Rust API (dynamic → OpenGL fallback), the startup handshake and the NDJSON control loop. - `src/main.rs` only scans argv for `--backend` and forwards. -- `src/ipc.rs` owns the shared-memory frame-slot transport (the real - `SpscRingBuffer` + `FrameSlotPool` over POSIX `shm_open`/`mmap`) and the - NDJSON control-plane message structs. + Since M15 S1 it also renders for real: `load_graph` deserializes the + snapshot through `oaknode::serializer`, and `render_frame` / + `render_batch` render through `oakrender::eval` (generated frames, + footage decode via oakcodec/ffmpeg, montage compositing) directly into + the main-assigned shared-memory slots. +- `src/ipc.rs` is a shim re-exporting `oakrender::ipc` (M15 S1): the + NDJSON protocol and the shared-memory frame-slot transport moved to the + oakrender crate so both ends of the pipe link one copy (the + main-process dispatcher in `oakrender::procpool` creates the segments; + this worker attaches to them). The oakrender module crate (`../oakrender`) is a plain Rust dependency; it depends on `ocio-rs` with the `bundled` feature, whose first-time build @@ -39,49 +45,66 @@ CARGO_TARGET_DIR=/path/to/oak/crates/oakrender/target cargo build --release Same flow as the C++ main, in the same order: 1. **parse `--backend `** (default `opengl`; `none` skips - renderer creation and the process exits 1, like the C++ main). + renderer creation and the process exits 1, like the C++ main; + `cpu` is the M15 headless render mode — no renderer, but the session + stays fully operational and renders through the CPU evaluation path). 2. **initialize the render backend** (inside `src/worker.rs`): the oakrender `DisplayRenderer` direct Rust API, falling back to the direct OpenGL renderer exactly like the C++ `create_renderer()` fallback - chain. + chain. Then the runtime services load (color-manager default config, + the oakplugin render executor). 3. **write the startup handshake** (protocol version 1, empty shared-memory geometry — same as the C++ worker's startup handshake; the parent creates the segments and announces their geometry in its reply). 4. **serve the NDJSON control loop** on stdin/stdout until a `shutdown` message or EOF: `handshake` attaches the announced shared-memory - frame-slot pools through the real transport; `load_graph` / - `render_frame` / `cancel` / `shutdown` are dispatched by the session. - Responses are one compact JSON line per message. + frame-slot pools through the real transport and answers `hello_caps` + (protocol v2: supported slot formats + max slot size); `load_graph` + deserializes the graph snapshot; `render_frame` renders one frame + into an acquired slot; `render_batch` renders a batch of + main-assigned-slot tickets (`batch_accepted` claim confirmation, then + one `frame_ready`/`frame_failed` per ticket); `cancel` / `shutdown` + are dispatched by the session. Responses are one compact JSON line + per message. ## Implemented vs stubbed (nothing is faked) **Real:** argument parsing, render backend initialization (real wgpu -renderer, dynamic → OpenGL fallback), startup handshake, NDJSON framing, +renderer, dynamic → OpenGL fallback), runtime initialization (color +config + oakplugin render executor), startup handshake, NDJSON framing, message validation (protocol version, handshake geometry, `load_graph` -file existence/size — the same messages the C++ worker emits), the -**shared-memory frame-slot transport** (`src/ipc.rs` — POSIX -`shm_open`/`mmap`/`munmap`/`shm_unlink`, the SPSC ring buffer and the -frame-slot pool with the exact version-1 shared layout; a `handshake` -genuinely attaches the output and input pools), unknown-type/ -malformed-message errors, shutdown/EOF termination. +file existence/size), the **shared-memory frame-slot transport** +(`oakrender::ipc` — POSIX `shm_open`/`mmap`/`munmap`/`shm_unlink`, the +SPSC ring buffer and the frame-slot pool with the exact version-1 shared +layout; a `handshake` genuinely attaches the output and input pools), +**graph deserialization** (`oaknode::serializer::load`, plus the minimal +`{"project_copy":N}` identity payload), **frame rendering into shm +slots** (`render_frame` v1 + `render_batch` v2: generated frames, +footage decode, montage compositing, end-of-pipe F32→BGRA8 conversion), +unknown-type/malformed-message errors, shutdown/EOF termination. -**Stubbed (documented in `src/worker.rs`):** - -| area | reason | -|---|---| -| node-graph deserialization (`load_graph` beyond the file checks) | the oaknode crate is a `todo!()` skeleton | -| frame rendering (`render_frame`) | no graph/render-pipeline backing (the shm frame-slot transport is attached, but there is no graph to render) | - -Stubbed requests answer with a clear `{"type":"error","message":…}` that -names the missing piece (a `render_frame` error also carries the ticket, -mirroring the C++ `error_message()` shape). A real `load_graph` on a -non-existent/empty file produces the C++-identical error before reaching -the stub. +**Deferred to M15 S2/S3 (documented in `src/worker.rs`):** the loaded +project's node-graph render path (plugin-node evaluation per graph +snapshot update) — today tickets render from their wire spec +(montage/footage/generate), which covers the preview pipeline. **Deviation from the C++:** the startup handshake omits `gl_major`/ `gl_minor` — the oakrender module exposes no GL context version (the C++ worker reads them off its `QOpenGLContext`). +## Crash-isolation test hooks + +The batch render path honors two environment variables used by the +crash-isolation integration tests (`tests/procpool_integration.rs`): + +- `OAK_WORKER_CRASH_ON_TICKET=` — raise `SIGSEGV` while rendering + ticket `n` (like a real plugin crash). +- `OAK_WORKER_CRASH_MARKER=` — when the marker file exists the + crash is skipped; the hook writes the marker before dying, making the + crash one-shot so the restarted worker renders the re-queued frame. + +They are test-only; unset in production. + ## Layout ``` @@ -90,20 +113,24 @@ src/ forwards to worker::worker_main worker.rs the real worker runtime: backend selection (oakrender DisplayRenderer, dynamic -> OpenGL fallback), WorkerSession, - handshake + NDJSON loop (M14 R2: the facade's port, owned by - this binary since the facade C ABI was cut) - ipc.rs control-plane message structs + NDJSON framing (serde), - AND the real shared-memory frame-slot transport - (SpscRingBuffer + FrameSlotPool over POSIX shm) -tests/worker.rs binary-level tests (--backend none exit 1) + handshake + NDJSON loop, real load_graph + render_frame + + render_batch (M15 S1) + ipc.rs shim re-exporting oakrender::ipc (M15 S1: both pipe ends + link one copy of the protocol + shm transport) +tests/worker.rs binary-level tests (--backend none exit 1; + --backend cpu handshake + clean exit) +tests/procpool_integration.rs M15 S1 end-to-end: real workers spawned by + oakrender::procpool::ProcessDispatcher — batch + renders into shm slots, crash isolation with + restart + re-dispatch, zero-copy assertions ``` The NDJSON control-loop behavior is exercised in-process in `src/worker.rs` -against the local real shared memory (no GPU needed via `--backend none` -sessions); a binary-level loop test would require a working GPU backend and -is deliberately not part of the unit suite. Run the binary against a -created segment to see the real attach path: +against the local real shared memory (`--backend none` / `--backend cpu` +sessions, no GPU needed); `tests/procpool_integration.rs` drives real +worker processes end-to-end through the main-process dispatcher. Run the +binary against a created segment to see the real attach path: ```sh -target/release/oak-worker --backend opengl <<< '{"type":"shutdown"}' +target/release/oak-worker --backend cpu <<< '{"type":"shutdown"}' ``` diff --git a/crates/oak-worker/src/ipc.rs b/crates/oak-worker/src/ipc.rs index 921cddf85..fa78ef7b7 100644 --- a/crates/oak-worker/src/ipc.rs +++ b/crates/oak-worker/src/ipc.rs @@ -14,1464 +14,12 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! Render-worker IPC: the control-plane NDJSON protocol and the -//! shared-memory frame-slot transport, owned by the oak-worker binary -//! since M14 R2 (the facade keeps its own copy for the frozen -//! `oakengine_ipc_*` C ABI). The transport is the Rust port of -//! `engine/render/ipc/` + `ipcmessage.cpp`. -//! -//! Two halves: -//! -//! - **Control plane.** One compact JSON object per line on the stdio -//! pipes (worker.cpp / ipcmessage.cpp `write_message`/`read_message`). -//! Every message carries a `"type"` string; the field names below are -//! the ones the C++ serializers actually emit -//! (`engine/render/ipc/ipcmessage.cpp`): note `ticket` / `node` / -//! `channels` / `slot` — the longer names (`ticket_id`, `node_uuid`, -//! `channel_count`, `output_slot`) exist only on the C POD structs in -//! `ipc.h`. [`write_message`]/[`error_message`] build the wire lines. -//! - **Data plane.** Named shared memory holding the frame-slot pools — -//! the port of `engine/render/ipc/` (`sharedmemoryregion.cpp`, -//! `frameslotpool.cpp`): [`SharedMemoryRegion`] maps a named POSIX -//! segment (`shm_open` + `mmap`, `munmap` + `shm_unlink` on close), -//! and [`FrameSlotPool`] lays out a fixed pool of equal-sized frame -//! slots inside it with lock-free hand-off through two -//! [`SpscRingBuffer`]s of slot indices (free + ready). Each ring is a -//! single-producer/single-consumer structure; the filler owns -//! `free.pop` + `ready.push`, the drainer owns `ready.pop` + -//! `free.push`, so no mutex is ever taken. -//! -//! **The in-memory layout is the version-1 wire protocol** the app and the -//! render worker share, and it never changes: the byte offsets below are -//! copied field-for-field from the C++ implementation (64-byte cache-line -//! alignment, the `Header`/`SpscRingBuffer`/`oak_frame_slot_meta` POD -//! structs). A segment written by the C++ side attaches here and vice -//! versa. -//! -//! This module is deliberately unsafe-heavy and self-contained: it touches -//! raw shared memory and raw POSIX syscalls, and everything else in the -//! crate reaches it through the safe wrapper methods. -//! -//! Message types (M = main/editor, W = worker): -//! handshake M<->W negotiate protocol version + announce shm geometry -//! load_graph M ->W path to a temp file holding the serialized graph -//! render_frame M ->W request a frame render (ticket, node, time, params) -//! frame_ready W ->M a rendered frame is published (slot + ticket) -//! cancel M ->W abandon an in-flight ticket -//! graph_update M ->W reserved (no payload struct yet) -//! shutdown M ->W finish current work and exit cleanly -//! error W ->M worker-side failure report ("message" field) -//! -//! Items the worker does not emit yet (frame_ready, graph_update, -//! `FrameReadyMsg`) and message ids it ignores (`cancel`) are kept as the -//! documented protocol surface; `dead_code` until the frame-slot transport -//! is driven by a real graph (see [`crate::worker`]). - -#![allow(dead_code)] - -use std::ffi::{c_char, c_int}; -use std::io::{self, Write}; -use std::ptr; -use std::sync::atomic::{AtomicU32, Ordering}; - -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; - - -/// `"handshake"`. -pub const TYPE_HANDSHAKE: &str = "handshake"; -/// `"load_graph"`. -pub const TYPE_LOAD_GRAPH: &str = "load_graph"; -/// `"render_frame"`. -pub const TYPE_RENDER_FRAME: &str = "render_frame"; -/// `"frame_ready"`. -pub const TYPE_FRAME_READY: &str = "frame_ready"; -/// `"cancel"`. -pub const TYPE_CANCEL: &str = "cancel"; -/// `"graph_update"`. -pub const TYPE_GRAPH_UPDATE: &str = "graph_update"; -/// `"shutdown"`. -pub const TYPE_SHUTDOWN: &str = "shutdown"; -/// `"error"`. -pub const TYPE_ERROR: &str = "error"; - -/// `handshake` — field-for-field equivalent of `oak_ipc_handshake` -/// (ipc.h). Wire field names match the C++ serializer. -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct HandshakeMsg { - /// Protocol version. - pub protocol_version: i32, - /// Worker->main output shared-memory segment key. - pub shm_key: String, - /// Main->worker input shared-memory segment key (optional). - pub input_shm_key: String, - /// Number of main->worker input frame slots. - pub input_slots: i32, - /// Number of worker->main output frame slots. - pub output_slots: i32, - /// Per-output-slot pixel block size. - pub slot_data_bytes: i64, - /// Per-input-slot pixel block size. - pub input_slot_data_bytes: i64, -} - -impl HandshakeMsg { - /// The worker's startup handshake (`worker.cpp startup_handshake()`). - pub fn to_json(&self) -> Value { - json!({ - "type": TYPE_HANDSHAKE, - "protocol_version": self.protocol_version, - "shm_key": self.shm_key, - "input_shm_key": self.input_shm_key, - "input_slots": self.input_slots, - "output_slots": self.output_slots, - "slot_data_bytes": self.slot_data_bytes, - "input_slot_data_bytes": self.input_slot_data_bytes, - }) - } -} - -/// `render_frame` — request a frame render. Wire names per ipcmessage.cpp: -/// `ticket`, `node`, `channels` (not the ipc.h POD names). -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct RenderFrameMsg { - /// Correlates with the eventual frame_ready. - pub ticket: i64, - /// Viewer node stable uuid in the loaded graph. - pub node: String, - /// Frame timestamp numerator. - pub time_num: i64, - /// Frame timestamp denominator. - pub time_den: i64, - /// Forced output size (0 = graph default). - pub width: i32, - /// Forced output height (0 = graph default). - pub height: i32, - /// Forced PixelFormat (-1 = default). - pub format: i32, - /// Channel count (0 = default). - pub channels: i32, - /// RenderMode. - pub mode: i32, - /// Optional decoded input slot (-1 = none). - pub input_slot: i32, - /// Ordered decoded input slots. - pub input_slots: Vec, - /// Output color transform present? - pub has_color_transform: bool, - /// Color transform targets the display space. - pub color_is_display: bool, - /// Output color space name. - pub color_output: String, - /// Output color view name. - pub color_view: String, - /// Output color look name. - pub color_look: String, -} - -/// `frame_ready` — a rendered frame is published (wire names `ticket`/ -/// `slot`). -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct FrameReadyMsg { - /// Correlates with the render_frame request. - pub ticket: i64, - /// Index into the worker->main output FrameSlotPool. - pub slot: i32, -} - -/// `cancel` — abandon an in-flight ticket by id. -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct CancelMsg { - /// The in-flight ticket id to abandon. - pub ticket: i64, -} - -/// `load_graph` — path to a temporary file holding the serialized graph. -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -#[serde(default)] -pub struct LoadGraphMsg { - /// Path to the temporary file holding the serialized graph. - pub path: String, -} - -/// Build a worker-side error report, mirroring `error_message()` in -/// worker.cpp: `{"type":"error","message":...}` plus `"ticket"` when -/// non-zero. -pub fn error_message(message: &str, ticket: Option) -> Value { - match ticket.filter(|t| *t != 0) { - Some(t) => json!({ "type": TYPE_ERROR, "message": message, "ticket": t }), - None => json!({ "type": TYPE_ERROR, "message": message }), - } -} - -/// Write one NDJSON message line (compact JSON + `\n`), the Rust port of -/// `ipcmessage.cpp write_message()`. -pub fn write_message(w: &mut impl Write, msg: &Value) -> io::Result<()> { - let line = - serde_json::to_string(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - w.write_all(line.as_bytes())?; - w.write_all(b"\n") -} - -// --------------------------------------------------------------------------- -// Shared-memory frame-slot transport -// --------------------------------------------------------------------------- - -/// `OAK_IPC_SHM_KEY_CAP` — capacity of shm key strings (ipc.h), incl. NUL. -pub const OAK_IPC_SHM_KEY_CAP: usize = 128; -/// `OAK_IPC_COLORSPACE_CAP` — capacity of `oak_frame_slot_meta::colorspace`. -pub const OAK_IPC_COLORSPACE_CAP: usize = 128; - -/// Byte alignment of every sub-region of a frame slot pool (the C++ -/// `k_align = 64`; cache-line alignment). -const K_ALIGN: usize = 64; - -/// `k_magic = 0x4F4B5350` ("OKSP") — the frame slot pool header magic. -pub const FRAMEPOOL_MAGIC: u32 = 0x4F4B5350; - -/// Round `value` up to the next multiple of `align` (power of two). -const fn align_up(value: usize, align: usize) -> usize { - (value + (align - 1)) & !(align - 1) -} - -/// `OAK_IPC_SHM_MODE_CREATE` / `OAK_IPC_SHM_MODE_ATTACH` (ipc.h). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ShmMode { - /// Create (and own) the segment. Fails if it already exists; the owner - /// unlinks it on close. - Create, - /// Attach to a segment created by the peer. Does not unlink on close. - Attach, -} - -impl ShmMode { - /// Map the C ABI mode integer (`OAK_IPC_SHM_MODE_CREATE` = 0, - /// `OAK_IPC_SHM_MODE_ATTACH` = 1) back to the enum. - fn from_c(v: c_int) -> ShmMode { - match v { - 0 => ShmMode::Create, - _ => ShmMode::Attach, - } - } -} - -/// Per-slot metadata describing the frame currently occupying a slot — -/// field-for-field `oak_frame_slot_meta` from `engine/include/oakengine/ipc.h`. -/// -/// This POD lives in shared memory alongside the pixel data and is part of -/// the version-1 wire protocol; `#[repr(C)]` keeps the C ABI layout. -#[repr(C)] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FrameSlotMeta { - /// Caller-defined tag (ticket id, or footage stream hash). - pub id: i64, - /// Frame timestamp numerator. - pub time_num: i64, - /// Frame timestamp denominator. - pub time_den: i64, - /// Frame width. - pub width: i32, - /// Frame height. - pub height: i32, - /// `PixelFormat::Format` value. - pub format: i32, - /// Channel count. - pub channel_count: i32, - /// Bytes per scanline (stride). - pub linesize: i32, - /// Valid bytes written into the slot's data block. - pub data_size: i32, - /// Input colorspace name. - pub colorspace: [c_char; OAK_IPC_COLORSPACE_CAP], -} - -impl Default for FrameSlotMeta { - fn default() -> Self { - FrameSlotMeta { - id: 0, - time_num: 0, - time_den: 0, - width: 0, - height: 0, - format: 0, - channel_count: 0, - linesize: 0, - data_size: 0, - colorspace: [0; OAK_IPC_COLORSPACE_CAP], - } - } -} - -/// `sizeof(oak_frame_slot_meta)` (8+8+8 + 4*6 + 128). -const FRAME_SLOT_META_SIZE: usize = 176; - -// --------------------------------------------------------------------------- -// SpscRingBuffer -// --------------------------------------------------------------------------- - -/// A lock-free single-producer / single-consumer ring buffer of `u32` -/// indices, living in shared memory — the port of -/// `engine/include/oakengine/spscringbuffer.h`. -/// -/// Layout (offsets from the buffer base, matching the C++ class): -/// -/// ```text -/// 0 head_ u32 producer cursor (relaxed read, release write) -/// 4 tail_ u32 consumer cursor (relaxed read, release write) -/// 8 capacity_ u32 slot count (written once by create()) -/// 12 slots u32[capacity] -/// ``` -/// -/// One slot is always left empty to disambiguate full and empty, so a -/// buffer with `capacity` slots holds at most `capacity - 1` live entries. -/// The payload is a `u32` slot index — never a pointer. -/// -/// `SpscRingBuffer` is a thin view over a raw pointer; it is `Copy` and -/// owns nothing. All methods are `unsafe` because they read and write the -/// shared segment concurrently with a peer process. -#[derive(Clone, Copy)] -pub struct SpscRingBuffer { - /// Base of the ring header (`head_` at offset 0). - base: *mut u8, -} - -// The shared memory the ring lives in is usable from any thread of the -// local process; synchronization with the peer is the ring's own atomics. -unsafe impl Send for SpscRingBuffer {} -unsafe impl Sync for SpscRingBuffer {} - -impl SpscRingBuffer { - /// `sizeof(SpscRingBuffer)` — header bytes before the slot array. - pub const HEADER_BYTES: usize = 12; - - /// Total bytes required for the header plus `capacity` index slots - /// (`SpscRingBuffer::bytes_needed`). - pub fn bytes_needed(capacity: u32) -> usize { - Self::HEADER_BYTES + capacity as usize * 4 - } - - /// In-place construct a ring header at `mem` with `capacity` index - /// slots. `mem` must provide at least [`Self::bytes_needed`] bytes and - /// be suitably aligned (mmap-backed segments are). Done exactly once by - /// whichever process owns the segment's creation; the peer uses - /// [`Self::attach`] instead. - /// - /// # Safety - /// `mem` must be a valid, writable, aligned buffer of at least - /// [`Self::bytes_needed`] bytes, and must not be concurrently written - /// during this call. - pub unsafe fn create(mem: *mut u8, capacity: u32) -> SpscRingBuffer { - let ring = SpscRingBuffer { base: mem }; - unsafe { - ring.store_capacity(capacity); - ring.head().store(0, Ordering::Relaxed); - ring.tail().store(0, Ordering::Relaxed); - for i in 0..capacity as usize { - *ring.slot_ptr(i) = 0; - } - } - ring - } - - /// Re-interpret already-initialized shared memory as a ring buffer - /// (peer-process side). No writes are performed. - /// - /// # Safety - /// `mem` must point to a buffer previously initialized by - /// [`Self::create`] (or an ABI-identical C++ side) that stays mapped - /// for as long as this view is used. - pub unsafe fn attach(mem: *mut u8) -> SpscRingBuffer { - SpscRingBuffer { base: mem } - } - - /// The ring's capacity (slot count). - /// - /// # Safety - /// `self` must point at a live ring (created or attached). - pub unsafe fn capacity(&self) -> u32 { - unsafe { (self.base.add(8) as *const u32).read() } - } - - /// Producer side: enqueue an index. Returns false if the buffer is full. - /// - /// # Safety - /// Exactly one producer may call this concurrently with exactly one - /// consumer calling [`Self::pop`]; the ring must be live. - pub unsafe fn push(&self, value: u32) -> bool { - unsafe { - let head = self.head().load(Ordering::Relaxed); - let next = self.increment(head); - if next == self.tail().load(Ordering::Acquire) { - return false; - } - *self.slot_ptr(head as usize) = value; - self.head().store(next, Ordering::Release); - } - true - } - - /// Consumer side: dequeue an index into `out`. Returns false if the - /// buffer is empty. - /// - /// # Safety - /// Exactly one consumer may call this concurrently with exactly one - /// producer calling [`Self::push`]; the ring must be live. - pub unsafe fn pop(&self, out: &mut u32) -> bool { - unsafe { - let tail = self.tail().load(Ordering::Relaxed); - if tail == self.head().load(Ordering::Acquire) { - return false; - } - *out = *self.slot_ptr(tail as usize); - self.tail().store(self.increment(tail), Ordering::Release); - } - true - } - - /// Approximate number of entries currently queued; may be stale the - /// instant it returns. For metrics/backpressure, not correctness. - /// - /// # Safety - /// The ring must be live. - pub unsafe fn size_approx(&self) -> u32 { - unsafe { - let head = self.head().load(Ordering::Acquire); - let tail = self.tail().load(Ordering::Acquire); - let cap = self.capacity(); - (head + cap - tail) % cap - } - } - - /// Approximate empty check (see [`Self::size_approx`]). - /// - /// # Safety - /// The ring must be live. - pub unsafe fn is_empty_approx(&self) -> bool { - unsafe { self.head().load(Ordering::Acquire) == self.tail().load(Ordering::Acquire) } - } - - #[inline] - fn increment(&self, index: u32) -> u32 { - // `capacity_` is small; this avoids requiring a power-of-two capacity. - unsafe { (index + 1) % self.capacity() } - } - - #[inline] - unsafe fn head(&self) -> &AtomicU32 { - unsafe { &*(self.base as *const AtomicU32) } - } - - #[inline] - unsafe fn tail(&self) -> &AtomicU32 { - unsafe { &*(self.base.add(4) as *const AtomicU32) } - } - - #[inline] - unsafe fn store_capacity(&self, capacity: u32) { - unsafe { *(self.base.add(8) as *mut u32) = capacity }; - } - - #[inline] - unsafe fn slot_ptr(&self, index: usize) -> *mut u32 { - unsafe { self.base.add(Self::HEADER_BYTES + index * 4) as *mut u32 } - } -} - -// --------------------------------------------------------------------------- -// FrameSlotPool -// --------------------------------------------------------------------------- - -/// Pool header written by create() and read back by attach(). Field-for- -/// field the C++ `FrameSlotPool::Header` (offsets: 0,4,8,16,24,32,40; -/// 48 bytes total). -#[repr(C)] -struct PoolHeader { - magic: u32, - slot_count: u32, - slot_data_bytes: u64, - free_ring_offset: u64, - ready_ring_offset: u64, - meta_offset: u64, - data_offset: u64, -} - -const POOL_HEADER_SIZE: usize = 48; - -/// A fixed-size pool of equal-sized frame slots in shared memory with -/// lock-free hand-off — the port of the C++ `FrameSlotPool` -/// (`engine/src/oliveimpl/render/ipc/frameslotpool.{h,cpp}`). -/// -/// One pool models a single direction of frame flow. It does NOT own the -/// memory; it is a view over a mapped [`SharedMemoryRegion`] (or any -/// ABI-identical segment). Lifecycle: the filler `acquire`s a free slot, -/// writes meta + pixels, then `publish`es it; the drainer `consume`s the -/// next ready slot, reads it, and `release`s it back to the free ring. -/// -/// [`FrameSlotPool`] is `Clone` — the clone is another view of the same -/// segment (the C++ `copy()`), useful to hand both sides a handle without -/// owning the mapping twice. -pub struct FrameSlotPool { - /// Segment base. - base: *mut u8, - /// The pool header at `base + 0`. - header: *mut PoolHeader, - /// Free-ring view (filler pops, drainer pushes). - free_ring: SpscRingBuffer, - /// Ready-ring view (filler pushes, drainer pops). - ready_ring: SpscRingBuffer, - /// Metadata array at `base + meta_offset`. - meta: *mut FrameSlotMeta, - /// Pixel data blocks at `base + data_offset`. - data: *mut u8, -} - -// Views into shared memory are safe to share within the process; the rings -// carry their own synchronization. -unsafe impl Send for FrameSlotPool {} -unsafe impl Sync for FrameSlotPool {} - -impl Clone for FrameSlotPool { - fn clone(&self) -> FrameSlotPool { - FrameSlotPool { - base: self.base, - header: self.header, - free_ring: self.free_ring, - ready_ring: self.ready_ring, - meta: self.meta, - data: self.data, - } - } -} - -impl FrameSlotPool { - /// Total bytes a region must provide to back a pool of - /// `slot_count` x `slot_data_bytes` - /// (`FrameSlotPool::bytes_needed`). - pub fn bytes_needed(slot_count: u32, slot_data_bytes: usize) -> usize { - let ring_cap = slot_count + 1; - let mut total = align_up(POOL_HEADER_SIZE, K_ALIGN); - let ring_bytes = align_up(SpscRingBuffer::bytes_needed(ring_cap), K_ALIGN); - total += ring_bytes; // free ring - total += ring_bytes; // ready ring - total += align_up(FRAME_SLOT_META_SIZE * slot_count as usize, K_ALIGN); // metadata - total += align_up(slot_data_bytes, K_ALIGN) * slot_count as usize; // pixel data - total - } - - /// Lay out and initialize a brand-new pool over `mem` (owner side, once). - /// - /// Writes the header, initializes both rings, seeds the free ring with - /// every slot index and zeroes the metadata. `mem` must provide at - /// least [`Self::bytes_needed`] bytes of writable, aligned memory (an - /// mmap-backed segment) and must outlive the returned pool. - /// - /// # Safety - /// `mem` must be a valid, writable, aligned buffer of at least - /// [`Self::bytes_needed`] bytes, not concurrently written during this - /// call. - pub unsafe fn create(mem: *mut u8, slot_count: u32, slot_data_bytes: usize) -> FrameSlotPool { - let ring_cap = slot_count + 1; - let free_off = align_up(POOL_HEADER_SIZE, K_ALIGN); - let ready_off = free_off + align_up(SpscRingBuffer::bytes_needed(ring_cap), K_ALIGN); - let meta_off = ready_off + align_up(SpscRingBuffer::bytes_needed(ring_cap), K_ALIGN); - let data_off = meta_off + align_up(FRAME_SLOT_META_SIZE * slot_count as usize, K_ALIGN); - - let pool = unsafe { - FrameSlotPool { - base: mem, - header: mem as *mut PoolHeader, - free_ring: SpscRingBuffer::create(mem.add(free_off), ring_cap), - ready_ring: SpscRingBuffer::create(mem.add(ready_off), ring_cap), - meta: mem.add(meta_off) as *mut FrameSlotMeta, - data: mem.add(data_off), - } - }; - unsafe { - (*pool.header).magic = FRAMEPOOL_MAGIC; - (*pool.header).slot_count = slot_count; - (*pool.header).slot_data_bytes = slot_data_bytes as u64; - (*pool.header).free_ring_offset = free_off as u64; - (*pool.header).ready_ring_offset = ready_off as u64; - (*pool.header).meta_offset = meta_off as u64; - (*pool.header).data_offset = data_off as u64; - } - // `ptr::write_bytes` counts in elements of T, so cast to bytes. - unsafe { - ptr::write_bytes( - pool.meta as *mut u8, - 0, - slot_count as usize * std::mem::size_of::(), - ); - } - // Seed the free ring with every slot index so the filler can - // acquire() immediately. - for i in 0..slot_count { - unsafe { pool.free_ring.push(i) }; - } - pool - } - - /// Map an existing, already-initialized pool (peer side). - /// - /// Reads the geometry from the in-memory header written by - /// [`Self::create`]; the returned pool reports `is_valid() == false` - /// when the magic does not match. - /// - /// # Safety - /// `mem` must point to a mapped segment that either contains a pool - /// initialized by [`Self::create`] (or an ABI-identical C++ side) or is - /// an arbitrary buffer whose first 4 bytes we must be able to read. - pub unsafe fn attach(mem: *mut u8) -> FrameSlotPool { - if mem.is_null() { - return FrameSlotPool::invalid(); - } - let header = mem as *mut PoolHeader; - // SAFETY: `mem` is a live mapping of at least the header size. - if unsafe { (*header).magic } != FRAMEPOOL_MAGIC { - return FrameSlotPool::invalid(); - } - let pool = unsafe { - FrameSlotPool { - base: mem, - header, - free_ring: SpscRingBuffer::attach(mem.add((*header).free_ring_offset as usize)), - ready_ring: SpscRingBuffer::attach(mem.add((*header).ready_ring_offset as usize)), - meta: mem.add((*header).meta_offset as usize) as *mut FrameSlotMeta, - data: mem.add((*header).data_offset as usize), - } - }; - pool - } - - /// An invalid pool (attach on a non-pool segment). - fn invalid() -> FrameSlotPool { - FrameSlotPool { - base: ptr::null_mut(), - header: ptr::null_mut(), - free_ring: SpscRingBuffer { - base: ptr::null_mut(), - }, - ready_ring: SpscRingBuffer { - base: ptr::null_mut(), - }, - meta: ptr::null_mut(), - data: ptr::null_mut(), - } - } - - /// True when the pool was attached to a segment containing a valid pool - /// header. - pub fn is_valid(&self) -> bool { - !self.header.is_null() - } - - /// Number of slots in the pool (0 for an invalid pool). - pub fn slot_count(&self) -> u32 { - if self.is_valid() { - unsafe { (*self.header).slot_count } - } else { - 0 - } - } - - /// Bytes available in every slot's pixel-data block (0 for invalid). - pub fn slot_data_bytes(&self) -> usize { - if self.is_valid() { - unsafe { (*self.header).slot_data_bytes as usize } - } else { - 0 - } - } - - /// Byte stride between consecutive slot data blocks. - fn slot_stride(&self) -> usize { - align_up(self.slot_data_bytes(), K_ALIGN) - } - - // ---- Filler side ---- - - /// Take ownership of a free slot. Returns false (leaving `index` - /// untouched) if none is free. - /// - /// # Safety - /// The pool must be a valid view of a live segment. - pub unsafe fn acquire(&self, index: &mut u32) -> bool { - unsafe { self.free_ring.pop(index) } - } - - /// Pointer to a slot's pixel data block (`slot_data_bytes` available). - /// - /// # Safety - /// `index` must be in `0..slot_count`; the pool must be a valid view of - /// a live segment. - pub unsafe fn slot_data(&self, index: u32) -> *mut u8 { - unsafe { self.data.add(index as usize * self.slot_stride()) } - } - - /// Mutable metadata for a slot. The filler writes this before - /// [`Self::publish`]. The returned pointer addresses shared memory; it - /// is borrowed, not owned. - /// - /// # Safety - /// `index` must be in `0..slot_count`; the pool must be a valid view of - /// a live segment. - pub unsafe fn meta(&self, index: u32) -> *mut FrameSlotMeta { - unsafe { self.meta.add(index as usize) } - } - - /// Publish a filled slot to the drainer. Must follow a successful - /// [`Self::acquire`] of `index`. Returns false if the ready ring is - /// full (the filler must then release the slot and retry later). - /// - /// # Safety - /// `index` must be a slot previously acquired and not yet released. - pub unsafe fn publish(&self, index: u32) -> bool { - unsafe { self.ready_ring.push(index) } - } - - // ---- Drainer side ---- - - /// Take the next published slot. Returns false if nothing is ready. - /// - /// # Safety - /// The pool must be a valid view of a live segment. - pub unsafe fn consume(&self, index: &mut u32) -> bool { - unsafe { self.ready_ring.pop(index) } - } - - /// Return a consumed slot to the free pool for reuse. Must follow a - /// successful [`Self::consume`] of `index`. Returns false if the free - /// ring is full (the drainer must not release the slot yet). - /// - /// # Safety - /// `index` must be a slot previously consumed and not yet re-acquired. - pub unsafe fn release(&self, index: u32) -> bool { - unsafe { self.free_ring.push(index) } - } - - /// Immutable metadata for a slot (drainer side). - /// - /// # Safety - /// `index` must be in `0..slot_count`; the pool must be a valid view of - /// a live segment. - pub unsafe fn meta_const(&self, index: u32) -> *const FrameSlotMeta { - unsafe { self.meta.add(index as usize) } - } - - /// Immutable pixel data for a slot. - /// - /// # Safety - /// `index` must be in `0..slot_count`; the pool must be a valid view of - /// a live segment. - pub unsafe fn slot_data_const(&self, index: u32) -> *const u8 { - unsafe { self.data.add(index as usize * self.slot_stride()) } - } -} - -// --------------------------------------------------------------------------- -// SharedMemoryRegion -// --------------------------------------------------------------------------- - -/// A named, fixed-size POSIX shared-memory segment mapped into the process -/// address space — the port of the C++ `SharedMemoryRegion` -/// (`engine/render/ipc/sharedmemoryregion.cpp`). -/// -/// One process opens the segment in [`ShmMode::Create`] (owner: fails if -/// the name already exists, zeroes the mapping, unlinks on close); the -/// peer opens the same key in [`ShmMode::Attach`]. The mapping is a raw -/// contiguous byte range; the ring buffers and frame slot pools are laid -/// out inside it. Nothing here is locked — synchronization is entirely the -/// caller's responsibility via the lock-free structures placed in the -/// mapping. -pub struct SharedMemoryRegion { - /// The key the region was opened with (no leading slash). - key: String, - /// Requested mapping size in bytes. - size: usize, - /// The mmap'd data pointer; null when invalid. - data: *mut u8, - /// File descriptor from `shm_open` (-1 when invalid). - fd: i32, - /// Open mode. - mode: ShmMode, - /// Human-readable reason of the last failed open. - error: String, - /// The platform-prefixed name actually passed to `shm_open`. - shm_name: String, -} - -impl SharedMemoryRegion { - /// An empty (invalid) region. - pub fn new() -> SharedMemoryRegion { - SharedMemoryRegion { - key: String::new(), - size: 0, - data: ptr::null_mut(), - fd: -1, - mode: ShmMode::Attach, - error: String::new(), - shm_name: String::new(), - } - } - - /// Build a unique segment key for a worker, e.g. - /// "olive-rw--" (`SharedMemoryRegion::make_key`). - /// Centralized so the owner and the spawned worker agree on the same - /// name. - pub fn make_key(owner_pid: i64, worker_index: i32) -> String { - format!("olive-rw-{owner_pid}-{worker_index}") - } - - /// Open the segment identified by `key` with the given `size` in bytes. - /// - /// `key` is a short identifier (no leading slash needed; the platform - /// prefix is added internally). Returns true on success; on failure - /// [`Self::error`] carries a human-readable reason. An existing region - /// is closed first. - pub fn open(&mut self, key: &str, size: usize, mode: ShmMode) -> bool { - self.close(); - self.key = key.to_string(); - self.size = size; - self.mode = mode; - - // POSIX shared-memory names must start with a single slash and - // contain no others. - let shm_name = format!("/{}", key.replace('/', "_")); - let name_c = match std::ffi::CString::new(shm_name.clone()) { - Ok(c) => c, - Err(_) => { - self.error = format!("invalid shm key {key:?} (contains NUL)"); - return false; - } - }; - self.shm_name = shm_name; - - let mut oflag = libc::O_RDWR; - if mode == ShmMode::Create { - oflag |= libc::O_CREAT | libc::O_EXCL; - // Clear any stale segment left by a crashed previous run with - // the same name. - unsafe { libc::shm_unlink(name_c.as_ptr()) }; - } - - let fd = unsafe { libc::shm_open(name_c.as_ptr(), oflag, 0o600) }; - if fd < 0 { - self.error = format!( - "shm_open({}) failed: {}", - self.shm_name, - std::io::Error::last_os_error() - ); - return false; - } - self.fd = fd; - - if mode == ShmMode::Create { - if unsafe { libc::ftruncate(fd, size as libc::off_t) } != 0 { - self.error = format!("ftruncate failed: {}", std::io::Error::last_os_error()); - self.close(); - return false; - } - } else { - // mmap() succeeds even beyond the real segment size and only - // faults (SIGBUS) on access, so verify the segment is large - // enough up front. - let mut st: libc::stat = unsafe { std::mem::zeroed() }; - if unsafe { libc::fstat(fd, &mut st) } != 0 { - self.error = format!("fstat failed: {}", std::io::Error::last_os_error()); - self.close(); - return false; - } - if (st.st_size as usize) < size { - self.error = format!( - "shared memory segment is {} bytes, smaller than the requested {}", - st.st_size, size - ); - self.close(); - return false; - } - } - - let data = unsafe { - libc::mmap( - ptr::null_mut(), - size, - libc::PROT_READ | libc::PROT_WRITE, - libc::MAP_SHARED, - fd, - 0, - ) - }; - if data == libc::MAP_FAILED { - self.error = format!("mmap failed: {}", std::io::Error::last_os_error()); - self.close(); - return false; - } - self.data = data as *mut u8; - self.error.clear(); - - if mode == ShmMode::Create { - unsafe { ptr::write_bytes(self.data, 0, size) }; - } - true - } - - /// Unmap and (if owner) unlink the segment. Also called by `Drop`. - pub fn close(&mut self) { - if !self.data.is_null() { - unsafe { libc::munmap(self.data as *mut std::ffi::c_void, self.size) }; - self.data = ptr::null_mut(); - } - if self.fd >= 0 { - unsafe { libc::close(self.fd) }; - self.fd = -1; - } - if self.mode == ShmMode::Create && !self.shm_name.is_empty() { - // Only the owner unlinks, so the name is freed once both sides - // have unmapped. - if let Ok(c) = std::ffi::CString::new(self.shm_name.clone()) { - unsafe { libc::shm_unlink(c.as_ptr()) }; - } - self.shm_name.clear(); - } - self.size = 0; - } - - /// True when the region holds a live mapping. - pub fn is_valid(&self) -> bool { - !self.data.is_null() - } - - /// The mapped data pointer (null when invalid). - pub fn data(&self) -> *mut u8 { - self.data - } - - /// The mapping size in bytes. - pub fn size(&self) -> usize { - self.size - } - - /// The key the region was opened with. - pub fn key(&self) -> &str { - &self.key - } - - /// Human-readable reason of the last failed open. - pub fn error(&self) -> &str { - &self.error - } -} - -impl Default for SharedMemoryRegion { - fn default() -> Self { - SharedMemoryRegion::new() - } -} - -impl Drop for SharedMemoryRegion { - fn drop(&mut self) { - self.close(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ---- Control-plane protocol ------------------------------------------ - - #[test] - fn handshake_wire_format_matches_cpp_field_names() { - let hs = HandshakeMsg { - protocol_version: 1, - shm_key: "olive-rw-1234-0-out".into(), - input_shm_key: "".into(), - input_slots: 0, - output_slots: 6, - slot_data_bytes: 4096, - input_slot_data_bytes: 0, - }; - let value = hs.to_json(); - // Key order is not part of the contract (JSON objects; the C++ - // QJsonObject is hash-ordered too), but the names must match the - // C++ serializer exactly. - assert_eq!(value["type"], "handshake"); - assert_eq!(value["protocol_version"], 1); - assert_eq!(value["shm_key"], "olive-rw-1234-0-out"); - assert_eq!(value["input_shm_key"], ""); - assert_eq!(value["input_slots"], 0); - assert_eq!(value["output_slots"], 6); - assert_eq!(value["slot_data_bytes"], 4096); - assert_eq!(value["input_slot_data_bytes"], 0); - // And the serialized line must parse back to the same object. - let round: serde_json::Value = - serde_json::from_str(&serde_json::to_string(&value).unwrap()).unwrap(); - assert_eq!(round, value); - } - - #[test] - fn render_frame_parse_accepts_cpp_field_names() { - let json = r#"{"type":"render_frame","ticket":42,"node":"abcd","time_num":1,"time_den":24,"width":1920,"height":1080,"format":-1,"channels":0,"mode":0,"input_slot":-1,"input_slots":[],"has_color_transform":false,"color_output":"","color_view":"","color_look":""}"#; - let m: RenderFrameMsg = serde_json::from_str(json).unwrap(); - assert_eq!(m.ticket, 42); - assert_eq!(m.node, "abcd"); - assert_eq!(m.time_num, 1); - assert_eq!(m.time_den, 24); - assert_eq!(m.width, 1920); - assert_eq!(m.input_slot, -1); - } - - #[test] - fn render_frame_defaults_on_missing_fields() { - // The C++ parser defaults missing fields (QJsonValue defaults); - // serde(default) mirrors that. - let m: RenderFrameMsg = - serde_json::from_str(r#"{"type":"render_frame","ticket":7}"#).unwrap(); - assert_eq!(m.ticket, 7); - assert_eq!(m.time_den, 0); - assert!(m.node.is_empty()); - assert!(!m.has_color_transform); - } - - #[test] - fn error_message_carries_ticket_only_when_nonzero() { - assert_eq!( - error_message("boom", None), - json!({ "type": "error", "message": "boom" }) - ); - assert_eq!( - error_message("boom", Some(0)), - json!({ "type": "error", "message": "boom" }) - ); - assert_eq!( - error_message("boom", Some(9)), - json!({ "type": "error", "message": "boom", "ticket": 9 }) - ); - } - - #[test] - fn write_message_emits_one_json_line() { - let mut buf = Vec::new(); - write_message(&mut buf, &json!({ "type": "shutdown" })).unwrap(); - assert_eq!(String::from_utf8(buf).unwrap(), "{\"type\":\"shutdown\"}\n"); - } - - // ---- Shared-memory transport ----------------------------------------- - - /// A unique, temporary POSIX segment key for a test (pid + counter), so - /// parallel test runs never collide. - fn test_key(name: &str) -> String { - static COUNTER: AtomicU32 = AtomicU32::new(0); - let n = COUNTER.fetch_add(1, Ordering::Relaxed); - SharedMemoryRegion::make_key(i64::from(std::process::id()), (n & 0x7FFF) as i32) - + &format!("-{name}") - } - - /// Create one segment and map it a second time — the in-process - /// equivalent of two processes sharing a segment. Returns - /// `(owner_region, peer_region)`; both must be kept alive for the - /// whole test (the peer is an attach that does not unlink). - fn two_mappings(key: &str, size: usize) -> (SharedMemoryRegion, SharedMemoryRegion) { - let mut owner = SharedMemoryRegion::new(); - assert!( - owner.open(key, size, ShmMode::Create), - "create failed: {}", - owner.error() - ); - let mut peer = SharedMemoryRegion::new(); - assert!( - peer.open(key, size, ShmMode::Attach), - "attach failed: {}", - peer.error() - ); - (owner, peer) - } - - // ---- SpscRingBuffer ------------------------------------------------- - - #[test] - fn ring_bytes_needed_matches_cpp_layout() { - // 12 header bytes + capacity * 4. - assert_eq!(SpscRingBuffer::bytes_needed(4), 12 + 16); - assert_eq!(SpscRingBuffer::bytes_needed(5), 12 + 20); - assert_eq!(SpscRingBuffer::bytes_needed(0), 12); - } - - #[test] - fn ring_empty_full_and_single_entry() { - let key = test_key("ring-empty"); - let size = SpscRingBuffer::bytes_needed(4); - let (owner, peer) = two_mappings(&key, size); - // SAFETY: both mappings are live and at least `size` bytes. - let prod = unsafe { SpscRingBuffer::create(owner.data(), 4) }; - let cons = unsafe { SpscRingBuffer::attach(peer.data()) }; - - assert!(unsafe { cons.is_empty_approx() }); - let mut v = 99; - assert!(!unsafe { cons.pop(&mut v) }); - assert_eq!(v, 99); - - assert!(unsafe { prod.push(7) }); - assert!(!unsafe { cons.is_empty_approx() }); - assert_eq!(unsafe { cons.size_approx() }, 1); - assert!(unsafe { cons.pop(&mut v) }); - assert_eq!(v, 7); - assert!(unsafe { cons.is_empty_approx() }); - } - - #[test] - fn ring_capacity_minus_one_live_entries() { - // A ring of capacity N holds at most N-1 entries (one slot is - // always left empty to tell full from empty). - let key = test_key("ring-cap"); - let size = SpscRingBuffer::bytes_needed(4); - let (owner, peer) = two_mappings(&key, size); - // SAFETY: live mappings. - let prod = unsafe { SpscRingBuffer::create(owner.data(), 4) }; - let cons = unsafe { SpscRingBuffer::attach(peer.data()) }; - - for i in 0..3 { - assert!(unsafe { prod.push(i) }); - } - // The 4th push must fail: head would collide with tail. - assert!(!unsafe { prod.push(99) }); - - let mut v = 0; - for expected in 0..3 { - assert!(unsafe { cons.pop(&mut v) }); - assert_eq!(v, expected); - } - assert!(!unsafe { cons.pop(&mut v) }); - } - - #[test] - fn ring_wraparound_preserves_order() { - // Fill, drain, then wrap past the end of the slot array: cursors - // are modulo-capacity, order must be preserved across the wrap. - let key = test_key("ring-wrap"); - let size = SpscRingBuffer::bytes_needed(4); - let (owner, peer) = two_mappings(&key, size); - // SAFETY: live mappings. - let prod = unsafe { SpscRingBuffer::create(owner.data(), 4) }; - let cons = unsafe { SpscRingBuffer::attach(peer.data()) }; - - for i in 0..3 { - assert!(unsafe { prod.push(i) }); - } - let mut v = 0; - for _ in 0..3 { - assert!(unsafe { cons.pop(&mut v) }); - } - // Ring is empty again; push past the wrap point. - for i in 3..6 { - assert!(unsafe { prod.push(i) }); - } - for expected in 3..6 { - assert!(unsafe { cons.pop(&mut v) }); - assert_eq!(v, expected); - } - } - - // ---- FrameSlotPool -------------------------------------------------- - - #[test] - fn framepool_bytes_needed_matches_cpp_offsets() { - // Recompute by hand with the C++ layout: header 64, each ring - // align_up(12 + 4*(n+1), 64), meta align_up(176*n, 64), data - // align_up(slot_bytes, 64) * n. - let check = |n: u32, slot: usize| { - let ring = align_up(12 + 4 * (n as usize + 1), 64); - let expected = - 64 + ring + ring + align_up(176 * n as usize, 64) + align_up(slot, 64) * n as usize; - assert_eq!(FrameSlotPool::bytes_needed(n, slot), expected); - }; - check(4, 4096); - check(6, 1_000_000); - check(1, 64); - check(3, 100); - } - - #[test] - fn framepool_create_attach_two_processes_both_directions() { - // "Two processes": two mappings of the same segment. Owner creates - // the pool; the peer attaches. A filler on one side and a drainer - // on the other exchange slots in both directions. - let key = test_key("pool-bidi"); - let slots = 4u32; - let slot_bytes = 64usize; - let size = FrameSlotPool::bytes_needed(slots, slot_bytes); - let (owner, peer) = two_mappings(&key, size); - - // SAFETY: both mappings are live and sized by bytes_needed. - let filler = unsafe { FrameSlotPool::create(owner.data(), slots, slot_bytes) }; - let drainer = unsafe { FrameSlotPool::attach(peer.data()) }; - - assert!(filler.is_valid()); - assert!(drainer.is_valid()); - assert_eq!(drainer.slot_count(), slots); - assert_eq!(drainer.slot_data_bytes(), slot_bytes); - - // Filler acquires every slot exactly once (seeded free ring), then - // the free ring is empty. - let mut got = Vec::new(); - for _ in 0..slots { - let mut s = 0; - assert!(unsafe { filler.acquire(&mut s) }); - got.push(s); - } - got.sort_unstable(); - assert_eq!(got, vec![0, 1, 2, 3]); - let mut extra = 0; - assert!(!unsafe { filler.acquire(&mut extra) }); - // Drainer sees nothing ready yet. - assert!(!unsafe { drainer.consume(&mut extra) }); - - // Filler writes pixels + meta into two slots and publishes them. - for (i, slot) in [0u32, 2u32].iter().enumerate() { - // SAFETY: `slot` was acquired above. - let data = unsafe { filler.slot_data(*slot) }; - unsafe { ptr::write_bytes(data, (i * 40 + 1) as u8, slot_bytes) }; - // SAFETY: slot in range. - let meta = unsafe { &mut *filler.meta(*slot) }; - meta.id = 100 + *slot as i64; - meta.width = 8; - meta.height = 8; - meta.data_size = slot_bytes as i32; - assert!(unsafe { filler.publish(*slot) }); - } - - // Drainer consumes them through its own mapping and sees the same - // payloads and metadata. - let mut consumed = Vec::new(); - for _ in 0..2 { - let mut s = 0; - assert!(unsafe { drainer.consume(&mut s) }); - // SAFETY: s was consumed. - let data = unsafe { drainer.slot_data_const(s) }; - let meta = unsafe { &*drainer.meta_const(s) }; - assert_eq!(meta.id, 100 + s as i64); - assert_eq!(meta.width, 8); - assert_eq!(meta.data_size, slot_bytes as i32); - // SAFETY: slot_bytes readable in the slot block. - let first = unsafe { *data }; - assert_eq!(first, ((s as usize / 2) * 40 + 1) as u8); - consumed.push(s); - } - consumed.sort_unstable(); - assert_eq!(consumed, vec![0, 2]); - assert!(!unsafe { drainer.consume(&mut extra) }); - - // Drainer releases the slots back; the filler can acquire them - // again — the full round trip through both rings. - for s in consumed { - assert!(unsafe { drainer.release(s) }); - } - let mut s = 0; - assert!(unsafe { filler.acquire(&mut s) }); - assert_eq!(s, 0); - } - - #[test] - fn framepool_wraparound_and_full_edges() { - // Small pool: cycle every slot many times, verifying the rings' - // modulo behavior end to end. - let key = test_key("pool-wrap"); - let slots = 3u32; - let slot_bytes = 32usize; - let size = FrameSlotPool::bytes_needed(slots, slot_bytes); - let (owner, peer) = two_mappings(&key, size); - - // SAFETY: live mappings. - let filler = unsafe { FrameSlotPool::create(owner.data(), slots, slot_bytes) }; - let drainer = unsafe { FrameSlotPool::attach(peer.data()) }; - - for cycle in 0..4u32 { - let mut published = Vec::new(); - for _ in 0..slots { - let mut s = 0; - assert!(unsafe { filler.acquire(&mut s) }, "cycle {cycle}"); - // SAFETY: acquired slot. - unsafe { ptr::write_bytes(filler.slot_data(s), cycle as u8, slot_bytes) }; - // SAFETY: slot in range. - let meta = unsafe { &mut *filler.meta(s) }; - meta.id = i64::from(cycle * 100 + s); - assert!(unsafe { filler.publish(s) }); - published.push(s); - } - // Pool is full on the filler side. - let mut x = 0; - assert!(!unsafe { filler.acquire(&mut x) }); - - // Drain everything on the drainer side. - let mut consumed = Vec::new(); - for _ in 0..slots { - let mut s = 0; - assert!(unsafe { drainer.consume(&mut s) }); - // SAFETY: consumed slot. - let meta = unsafe { &*drainer.meta_const(s) }; - assert_eq!(meta.id, i64::from(cycle * 100 + s)); - // SAFETY: 1 byte readable. - assert_eq!(unsafe { *drainer.slot_data_const(s) }, cycle as u8); - consumed.push(s); - } - assert!(!unsafe { drainer.consume(&mut x) }); - consumed.sort_unstable(); - assert_eq!(consumed, vec![0, 1, 2]); - - for s in consumed { - assert!(unsafe { drainer.release(s) }); - } - } - } - - #[test] - fn framepool_attach_rejects_wrong_magic() { - let key = test_key("pool-badmagic"); - let size = FrameSlotPool::bytes_needed(2, 16); - let (owner, _peer) = two_mappings(&key, size); - // Overwrite the header area with garbage — no pool magic. - // SAFETY: owner mapping is live. - unsafe { ptr::write_bytes(owner.data(), 0xAB, 64) }; - // SAFETY: buffer is live. - let pool = unsafe { FrameSlotPool::attach(owner.data()) }; - assert!(!pool.is_valid()); - assert_eq!(pool.slot_count(), 0); - assert_eq!(pool.slot_data_bytes(), 0); - } - - #[test] - fn framepool_pool_over_reused_segment_is_consistent() { - // A pool that has been cycled fully and then attached fresh reports - // the same geometry as bytes_needed computed it. - let key = test_key("pool-geometry"); - let slots = 5u32; - let slot_bytes = 1000usize; - let size = FrameSlotPool::bytes_needed(slots, slot_bytes); - let (owner, peer) = two_mappings(&key, size); - // SAFETY: live mappings. - let _ = unsafe { FrameSlotPool::create(owner.data(), slots, slot_bytes) }; - let attached = unsafe { FrameSlotPool::attach(peer.data()) }; - assert!(attached.is_valid()); - assert_eq!(attached.slot_count(), slots); - assert_eq!(attached.slot_data_bytes(), slot_bytes); - // Slot stride is 64-aligned (matches the C++ data layout). - // SAFETY: valid pool. - let s0 = unsafe { attached.slot_data(0) }; - let s1 = unsafe { attached.slot_data(1) }; - assert_eq!(s1 as usize - s0 as usize, align_up(slot_bytes, K_ALIGN)); - } - - // ---- SharedMemoryRegion --------------------------------------------- - - #[test] - fn region_create_attach_write_visibility() { - let key = test_key("region-vis"); - let size = 4096usize; - let (mut owner, mut peer) = two_mappings(&key, size); - assert!(owner.is_valid()); - assert!(peer.is_valid()); - assert_eq!(owner.size(), size); - assert_eq!(peer.size(), size); - assert_eq!(owner.key(), key); - assert_eq!(peer.key(), key); - - // Owner writes; peer sees it through its own mapping. - // SAFETY: both mappings are live with `size` bytes. - unsafe { - let dst = owner.data() as *mut u32; - *dst = 0xDEADBEEF; - } - // SAFETY: peer mapping live. - let seen = unsafe { *(peer.data() as *const u32) }; - assert_eq!(seen, 0xDEADBEEF); - - // Peer writes back; owner sees it. - // SAFETY: peer mapping live. - unsafe { - let dst = peer.data() as *mut u32; - *dst = 0x12345678; - } - // SAFETY: owner mapping live. - assert_eq!(unsafe { *(owner.data() as *const u32) }, 0x12345678); - - // Closing the ATTACH side does not unlink: while the owner lives, - // a third mapping can still open the name. - peer.close(); - assert!(!peer.is_valid()); - let mut third = SharedMemoryRegion::new(); - assert!(third.open(&key, size, ShmMode::Attach), "{}", third.error()); - assert!(third.is_valid()); - third.close(); - - // Closing the OWNER unlinks the segment; further attaches fail. - owner.close(); - assert!(!owner.is_valid()); - let mut fourth = SharedMemoryRegion::new(); - assert!(!fourth.open(&key, size, ShmMode::Attach)); - } - - #[test] - fn region_create_replaces_stale_segment() { - // Mirrors the C++: Create unlinks any stale segment with the same - // name first (crash cleanup), so a second Create SUCCEEDS and owns - // a fresh, zeroed segment. - let key = test_key("region-exists"); - let size = 128usize; - let (owner, _peer) = two_mappings(&key, size); - assert!(owner.is_valid()); - // SAFETY: owner mapping live. - unsafe { *(owner.data() as *mut u32) = 0xCAFEBABE }; - - let mut second = SharedMemoryRegion::new(); - assert!( - second.open(&key, size, ShmMode::Create), - "{}", - second.error() - ); - assert!(second.is_valid()); - // The replacement segment is fresh (zeroed by create). - // SAFETY: second mapping live. - assert_eq!(unsafe { *(second.data() as *const u32) }, 0); - } - - #[test] - fn region_attach_fails_when_segment_too_small() { - // macOS rounds shm segment sizes up to a 16 KiB minimum, so use - // sizes above that to exercise the size check. - let key = test_key("region-small"); - let (owner, _peer) = two_mappings(&key, 4096); - assert!(owner.is_valid()); - - // Attaching with a larger size than the segment must fail (the - // fstat check, mirroring the C++). - let mut big = SharedMemoryRegion::new(); - assert!(!big.open(&key, 65536, ShmMode::Attach)); - assert!(!big.is_valid()); - assert!(!big.error().is_empty()); - } - - #[test] - fn region_make_key_format() { - assert_eq!(SharedMemoryRegion::make_key(4242, 3), "olive-rw-4242-3"); - assert_eq!(SharedMemoryRegion::make_key(1, 0), "olive-rw-1-0"); - } - - #[test] - fn region_keys_are_isolation_safe() { - // Keys with slashes are flattened to a single-slash POSIX name. - let key = "a/b/c"; - let size = 64usize; - let (owner, peer) = two_mappings(key, size); - assert!(owner.is_valid()); - assert!(peer.is_valid()); - // The actual POSIX name is "/a_b_c". - // SAFETY: mapping live. - unsafe { *(owner.data() as *mut u32) = 7 }; - // SAFETY: peer mapping live. - assert_eq!(unsafe { *(peer.data() as *const u32) }, 7); - } -} +//! Render-worker IPC shim (M15 S1): the NDJSON protocol and the +//! shared-memory frame-slot transport now live in the oakrender crate +//! (`oakrender::ipc`) so both ends of the pipe link one copy — the +//! main-process dispatcher creates the segments and the worker attaches +//! to them. This module re-exports the whole surface, keeping the +//! worker-side `crate::ipc::` paths unchanged. The facade (oakengine) +//! still keeps its own copy for the frozen `oakengine_ipc_*` C ABI. + +pub use oakrender::ipc::*; diff --git a/crates/oak-worker/src/worker.rs b/crates/oak-worker/src/worker.rs index 650ca3f6a..c9aaf75d6 100644 --- a/crates/oak-worker/src/worker.rs +++ b/crates/oak-worker/src/worker.rs @@ -23,43 +23,59 @@ //! backend through the oakrender crate's direct Rust API //! ([`oakrender::backend::DisplayRenderer`]), falling back to the //! direct OpenGL renderer exactly like the C++ `create_renderer()` -//! chain. +//! chain. The headless `"cpu"` backend (M15 S1) skips the renderer +//! entirely — the render path is CPU evaluation + decode, driven +//! through `render_batch`. //! - **The session.** [`WorkerSession`] holds the renderer, the -//! shared-memory frame-slot pools ([`crate::ipc::FrameSlotPool`]) and -//! the shutdown flag, and answers one NDJSON control message at a time. +//! loaded node graph, the shared-memory frame-slot pools +//! ([`crate::ipc::FrameSlotPool`]) and the shutdown flag, and answers +//! one NDJSON control message at a time. //! - **The main loop.** [`worker_main`] creates the session, loads the -//! runtime config, writes the startup handshake, and serves the -//! stdin/stdout NDJSON loop until a `shutdown` message or EOF. +//! runtime config (including the oakplugin render executor), writes +//! the startup handshake, and serves the stdin/stdout NDJSON loop +//! until a `shutdown` message or EOF. //! -//! The control-plane protocol is the same NDJSON the C++ worker speaks -//! (`engine/render/ipc/ipcmessage.cpp`): one compact JSON object per line, -//! `"type"`-dispatched ([`crate::ipc`]), with `handshake` carrying the -//! shared-memory geometry the worker attaches to via the real -//! [`crate::ipc`] transport. `load_graph`/`render_frame` reproduce the -//! C++ validation and then answer with the documented "not yet available" -//! errors (the oaknode graph crate is still a skeleton). +//! Real rendering landed in M15 S1: `load_graph` deserializes the graph +//! snapshot file (oaknode project XML, with the minimal +//! `{"project_copy":N}` payload fallback); `render_frame` and +//! `render_batch` render through [`oakrender::eval`] (generated frames, +//! footage decode, montage compositing) directly into the main-assigned +//! shm slots and publish `frame_ready` / `frame_failed` (protocol v2). use std::io::{self, BufRead, Write}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; -use serde_json::Value; +use serde_json::{json, Value}; +use oakcore_rs::{PixelFormat, Rational}; use oakrender::backend::{BackendKind, DisplayRenderer}; +use oakrender::eval; +use oakrender::ticket::{MontageClip, VideoTicketParams}; use crate::ipc::{ - error_message, write_message, FrameSlotPool, HandshakeMsg, LoadGraphMsg, RenderFrameMsg, - SharedMemoryRegion, ShmMode, TYPE_CANCEL, TYPE_HANDSHAKE, TYPE_LOAD_GRAPH, TYPE_RENDER_FRAME, - TYPE_SHUTDOWN, + error_message, write_message, BatchTicketSpec, FrameSlotPool, FrameSlotMeta, HandshakeMsg, + LoadGraphMsg, RenderBatchMsg, RenderFrameMsg, SharedMemoryRegion, ShmMode, TYPE_CANCEL, + TYPE_HANDSHAKE, TYPE_LOAD_GRAPH, TYPE_RENDER_BATCH, TYPE_RENDER_FRAME, TYPE_SHUTDOWN, + SLOT_FORMAT_BGRA8, }; use crate::{log_error, PROTOCOL_VERSION}; -/// Why `load_graph` answers "not yet available" (after the real file checks). -const GRAPH_STUB: &str = "load_graph: node-graph deserialization is not yet available in the \ - Rust worker (the oaknode crate is a todo!() skeleton; see worker/rust/README.md)"; - -/// Why `render_frame` answers "not yet available". -const RENDER_STUB: &str = "render_frame: frame rendering is not yet available in the Rust \ - worker (no node-graph or render-pipeline backing; the shm frame-slot transport is \ - attached but there is no graph to render; see worker/rust/README.md)"; +/// A loaded graph snapshot (M15 S1): the snapshot file path plus what it +/// deserialized into — a full oaknode project, or only the copied-project +/// identity (the minimal `{"project_copy":N}` payload the +/// [`oakrender::worker::GraphSnapshotStore`] writes before the app wires +/// full graph uploads in S2). +struct LoadedGraph { + /// Snapshot file path (S2: graph_update diffing is path-based). + #[allow(dead_code)] + path: String, + /// The deserialized project (S2: node-graph render path; today only + /// montage/footage/generate tickets use the loaded context). + #[allow(dead_code)] + project: Option>>, + project_copy: u64, +} // --------------------------------------------------------------------------- // Renderer (backend selection) @@ -71,6 +87,13 @@ pub fn is_no_backend(backend: &str) -> bool { backend.is_empty() || backend.eq_ignore_ascii_case("none") } +/// Whether `backend` is the M15 headless CPU render mode: like "none" +/// (no GPU renderer) but the session stays fully operational — frames +/// render through the CPU evaluation path ([`oakrender::eval`]). +pub fn is_cpu_backend(backend: &str) -> bool { + backend.eq_ignore_ascii_case("cpu") +} + /// A live, initialized oakrender display renderer (destroyed on drop). pub struct Renderer { /// The oakrender crate's value-typed display renderer (single-lib @@ -148,6 +171,10 @@ pub struct WorkerSession { output_pool: Option, input_region: Option, input_pool: Option, + graph: Option, + /// Reusable F32 staging buffer (BGRA8 slot conversion; the F32 + /// pipeline renders there before the end-of-pipe format convert). + f32_scratch: Vec, } impl WorkerSession { @@ -155,8 +182,10 @@ impl WorkerSession { /// `oakengine_worker_session_create()`: "none"/"" skips renderer /// creation, anything else initializes the render backend through the /// oakrender crate's direct Rust API (dynamic -> OpenGL fallback). + /// The M15 `"cpu"` backend is the headless render mode: no renderer, + /// CPU evaluation + decode via [`oakrender::eval`]. pub fn create(backend: &str) -> Result { - let renderer = if is_no_backend(backend) { + let renderer = if is_no_backend(backend) || is_cpu_backend(backend) { None } else { Some(Renderer::create(backend)?) @@ -169,6 +198,8 @@ impl WorkerSession { output_pool: None, input_region: None, input_pool: None, + graph: None, + f32_scratch: Vec::new(), }) } @@ -198,8 +229,14 @@ impl WorkerSession { "runtime: color-manager default config failed ({e}); continuing" )); } + // M15 S1: the plugin execution stack lives in the worker process + // (OFX crashes take down this process, not the editor — design + // §3.6). oakplugin installs its render driver into the oakrender + // executor slot. + log_error("runtime: installing oakplugin render executor"); + oakplugin::node_factory::install_render_executor(); log_error( - "runtime: config / node factory / frame manager / disk manager / project \ + "runtime: config / frame manager / disk manager / project \ serializer have no Rust backing in the worker binary; skipped", ); self.runtime_initialized = true; @@ -335,12 +372,23 @@ impl WorkerSession { self.input_pool = Some(input_pool); } - // Success: no response (worker.cpp leaves `response` untouched). - None + // Success: protocol v2 answers the geometry handshake with the + // capability announcement (worker.cpp left the response empty; + // main now waits for hello_caps to mark the worker alive). + Some(json!({ + "type": crate::ipc::TYPE_HELLO_CAPS, + "protocol_version": PROTOCOL_VERSION, + "formats": [PixelFormat::F32 as i32, SLOT_FORMAT_BGRA8], + "max_slot_bytes": hs.slot_data_bytes, + })) } - /// `load_graph`: the file checks are real (mirror worker.cpp - /// `load_graph()`); the deserialization is the documented stub. + /// `load_graph` (M15 S1): the file checks mirror worker.cpp; the + /// payload is deserialized for real — an oaknode project XML + /// ([`oaknode::serializer::load`]) or the minimal + /// `{"project_copy":N}` identity payload written by the snapshot + /// store before full graph uploads land in S2. Success answers + /// nothing (v1 semantics); failures answer an `error` message. fn handle_load_graph(&mut self, msg: &Value) -> Option { let load: LoadGraphMsg = match serde_json::from_value(msg.clone()) { Ok(l) => l, @@ -361,19 +409,466 @@ impl WorkerSession { load.path, md.len() )); - Some(error_message(GRAPH_STUB, None)) + let content = match std::fs::read_to_string(&load.path) { + Ok(c) => c, + Err(e) => { + return Some(error_message( + &format!("graph file unreadable: {e}"), + None, + )) + } + }; + match oaknode::serializer::load(&content) { + Ok(project) => { + self.graph = Some(LoadedGraph { + path: load.path.clone(), + project: Some(project), + project_copy: 0, + }); + log_error("LoadGraph: oaknode project deserialized"); + None + } + Err(graph_err) => { + // Fallback: the snapshot store's minimal payload + // `{"project_copy":N}` (identity-only graph context). + if let Ok(v) = serde_json::from_str::(&content) { + if let Some(pc) = v.get("project_copy").and_then(Value::as_u64) { + self.graph = Some(LoadedGraph { + path: load.path.clone(), + project: None, + project_copy: pc, + }); + log_error(&format!( + "LoadGraph: identity-only snapshot (project_copy {pc})" + )); + return None; + } + } + Some(error_message( + &format!("graph deserialization failed: {graph_err}"), + None, + )) + } + } } } } - /// `render_frame`: the graph/render pipeline has no Rust backing, so a - /// render request is answered with a clear error carrying the ticket. + /// `render_frame` (v1 single-frame path, M15 S1 real): generate the + /// frame through [`oakrender::eval`], write it into an acquired shm + /// slot and answer `frame_ready`. The v1 message carries no montage + /// or footage fields, so this path renders the pipeline's generated + /// frame; montage/footage tickets arrive via `render_batch`. fn handle_render_frame(&mut self, msg: &Value) -> Option { let render: RenderFrameMsg = match serde_json::from_value(msg.clone()) { Ok(r) => r, Err(_) => return Some(error_message("invalid render_frame message", None)), }; - Some(error_message(RENDER_STUB, Some(render.ticket))) + let pool = match self.output_pool.as_ref() { + Some(p) if p.is_valid() => p, + _ => { + return Some(error_message( + "render_frame: no shared-memory pool attached", + Some(render.ticket), + )) + } + }; + let (w, h) = if render.width > 0 && render.height > 0 { + (render.width, render.height) + } else { + ( + oakrender::frame::VideoParamsPod::DEFAULT_WIDTH, + oakrender::frame::VideoParamsPod::DEFAULT_HEIGHT, + ) + }; + let format = if render.format < 0 { + PixelFormat::F32 + } else { + match render.format { + f if f == PixelFormat::U8 as i32 => PixelFormat::U8, + f if f == PixelFormat::U10 as i32 => PixelFormat::U10, + f if f == PixelFormat::U16 as i32 => PixelFormat::U16, + f if f == PixelFormat::F16 as i32 => PixelFormat::F16, + f if f == PixelFormat::F32 as i32 => PixelFormat::F32, + other => { + return Some(error_message( + &format!("render_frame: unsupported format {other}"), + Some(render.ticket), + )) + } + } + }; + let time = Rational::new(render.time_num, render.time_den); + let frame = match eval::generate_frame(time, (w, h), format) { + Ok(f) => f, + Err(e) => { + return Some(error_message( + &format!("render_frame: generation failed: {e}"), + Some(render.ticket), + )) + } + }; + + let slot = match self.acquire_slot(pool) { + Some(s) => s, + None => { + return Some(error_message( + "render_frame: no free shm slot", + Some(render.ticket), + )) + } + }; + // Write meta + pixels into the slot, then publish. + let data_size = frame.data.len(); + if data_size > pool.slot_data_bytes() { + return Some(error_message( + "render_frame: frame larger than the shm slot", + Some(render.ticket), + )); + } + // SAFETY: `slot` was acquired; the slot block and meta are live + // shared memory of the attached pool. + unsafe { + std::ptr::copy_nonoverlapping(frame.data.as_ptr(), pool.slot_data(slot), data_size); + let meta = &mut *pool.meta(slot); + *meta = FrameSlotMeta::default(); + meta.id = render.ticket; + meta.time_num = render.time_num; + meta.time_den = render.time_den; + meta.width = w; + meta.height = h; + meta.format = format as i32; + meta.channel_count = 4; + meta.linesize = frame.linesize_bytes() as i32; + meta.data_size = data_size as i32; + if !pool.publish(slot) { + return Some(error_message( + "render_frame: ready ring full", + Some(render.ticket), + )); + } + } + Some(json!({ + "type": crate::ipc::TYPE_FRAME_READY, + "ticket": render.ticket, + "slot": slot, + })) + } + + /// `render_batch` (protocol v2; M15 S1): claim confirmation followed + /// by in-order rendering of every ticket into its main-assigned shm + /// slot. Responses stream to `out`: one `batch_accepted`, then one + /// `frame_ready` or `frame_failed` per ticket. Crashes the process + /// deliberately when the crash-mode environment asks for it (the + /// crash-isolation test hook). + fn handle_render_batch_stream( + &mut self, + line: &str, + out: &mut impl Write, + ) -> io::Result<()> { + let batch: RenderBatchMsg = match serde_json::from_str(line) { + Ok(b) => b, + Err(_) => { + return write_message(out, &error_message("invalid render_batch message", None)) + } + }; + + // Explicit claim confirmation (design §3.3): these tickets are + // owned by this worker now — no work stealing. The `type` tag is + // built by hand because [`BatchAcceptedMsg`] only carries the + // payload fields. + let accepted = json!({ + "type": crate::ipc::TYPE_BATCH_ACCEPTED, + "batch_id": batch.batch_id, + "tickets": batch.tickets.iter().map(|t| t.ticket).collect::>(), + }); + write_message(out, &accepted)?; + out.flush()?; + + for spec in &batch.tickets { + // Crash-isolation test hook: OAK_WORKER_CRASH_ON_TICKET= + // segfaults while rendering ticket n. A marker file (env + // OAK_WORKER_CRASH_MARKER) makes the crash one-shot so the + // restarted worker renders the frame for real. + self.maybe_crash_for_testing(spec.ticket); + + let response = match self.render_ticket_to_slot(spec) { + Ok(slot) => json!({ + "type": crate::ipc::TYPE_FRAME_READY, + "ticket": spec.ticket, + "slot": slot, + }), + Err(e) => { + log_error(&format!( + "render_batch: ticket {} failed: {e}", + spec.ticket + )); + json!({ + "type": crate::ipc::TYPE_FRAME_FAILED, + "ticket": spec.ticket, + "error": e, + }) + } + }; + write_message(out, &response)?; + out.flush()?; + } + Ok(()) + } + + /// The crash-mode test hook (see [`Self::handle_render_batch_stream`]). + fn maybe_crash_for_testing(&self, ticket: i64) { + let Ok(want) = std::env::var("OAK_WORKER_CRASH_ON_TICKET") else { + return; + }; + let Ok(crash_on) = want.parse::() else { + return; + }; + if crash_on != ticket { + return; + } + let marker = std::env::var("OAK_WORKER_CRASH_MARKER").ok(); + let should_crash = match &marker { + Some(path) => !std::path::Path::new(path).exists(), + None => true, + }; + if !should_crash { + return; + } + if let Some(path) = &marker { + let _ = std::fs::write(path, b"crashed"); + } + log_error(&format!("crash mode: dying on ticket {ticket}")); + // Raise SIGSEGV like a real plugin crash; abort as the fallback. + unsafe { libc::raise(libc::SIGSEGV) }; + std::process::abort(); + } + + /// Acquire a free output slot, polling until one appears (the free + /// ring is the only filler-side entry point; flow control). `None` + /// on shutdown or after the 30 s safety deadline. + fn acquire_slot(&self, pool: &FrameSlotPool) -> Option { + let deadline = Instant::now() + Duration::from_secs(30); + let mut slot = 0u32; + loop { + if self.shutdown_requested { + return None; + } + // SAFETY: valid attached pool; the worker is the filler, so + // popping the free ring is its SPSC role. + if unsafe { pool.acquire(&mut slot) } { + return Some(slot); + } + if Instant::now() > deadline { + return None; + } + std::thread::sleep(Duration::from_millis(1)); + } + } + + /// Render one batch ticket into its main-assigned slot (acquire, + /// render, publish). Returns the published slot index. + fn render_ticket_to_slot(&mut self, spec: &BatchTicketSpec) -> Result { + // Clone the pool view (a cheap mapping-shared copy) so the render + // path below can borrow `self` mutably (scratch buffer). + let pool = match self.output_pool.clone() { + Some(p) if p.is_valid() => p, + _ => return Err("no shared-memory pool attached".to_string()), + }; + + // Flow control: acquire through the free ring. Main seeds and + // releases slots in assignment order, so the pop yields exactly + // the assigned slot — anything else is a protocol violation. + let acquired = self + .acquire_slot(&pool) + .ok_or_else(|| "no free shm slot (shutdown or timeout)".to_string())?; + if acquired != spec.slot as u32 { + return Err(format!( + "slot assignment mismatch: acquired {acquired}, assigned {}", + spec.slot + )); + } + let slot = acquired; + + let result = self.render_spec_pixels(spec, &pool); + match result { + Ok(()) => { + // SAFETY: `slot` was acquired above and rendered into. + let published = unsafe { pool.publish(slot) }; + if !published { + Err("ready ring full".to_string()) + } else { + Ok(slot) + } + } + // The slot was acquired but never published; main recycles it + // when it sees frame_failed (the worker cannot push back to + // the free ring — that is the drainer's SPSC role). + Err(e) => Err(e), + } + } + + /// Render `spec` into the slot's data block and fill the slot meta. + fn render_spec_pixels(&mut self, spec: &BatchTicketSpec, pool: &FrameSlotPool) -> Result<(), String> { + let w = spec.width; + let h = spec.height; + if w <= 0 || h <= 0 { + return Err(format!("bad render size {w}x{h}")); + } + let time = Rational::new(spec.time_num, spec.time_den); + let params = self.ticket_params(spec, time); + + let bgra8 = spec.format == SLOT_FORMAT_BGRA8; + let (dst_bpp, dst_linesize) = if bgra8 { (4, w * 4) } else { (16, w * 16) }; + let dst_need = (h as usize) * (dst_linesize as usize); + if dst_need > pool.slot_data_bytes() { + return Err(format!( + "frame {}x{} needs {dst_need} bytes, slot holds {}", + w, + h, + pool.slot_data_bytes() + )); + } + let _ = dst_bpp; + + // SAFETY: `spec.slot` was acquired by render_ticket_to_slot; the + // block is live shared memory of the attached pool. + let dst = unsafe { + std::slice::from_raw_parts_mut(pool.slot_data(spec.slot as u32), pool.slot_data_bytes()) + }; + + if !bgra8 { + // F32 RGBA: render straight into the slot (no staging copy). + render_f32_into(spec, ¶ms, time, (w, h), &mut dst[..dst_need])?; + } else { + // BGRA8: render the F32 pipeline frame into the session + // scratch, then convert into the slot (the end-of-pipe format + // convert is not an extra frame copy, design §3.1). + let f32_need = (w as usize) * (h as usize) * 16; + if self.f32_scratch.len() < f32_need { + self.f32_scratch.resize(f32_need, 0); + } + render_f32_into(spec, ¶ms, time, (w, h), &mut self.f32_scratch[..f32_need])?; + convert_f32_rgba_to_bgra8(&self.f32_scratch[..f32_need], &mut dst[..dst_need]); + } + + // Slot meta (fresh each publish). + // SAFETY: slot in range of the attached pool. + unsafe { + let meta = &mut *pool.meta(spec.slot as u32); + *meta = FrameSlotMeta::default(); + meta.id = spec.ticket; + meta.time_num = spec.time_num; + meta.time_den = spec.time_den; + meta.width = w; + meta.height = h; + meta.format = spec.format; + meta.channel_count = spec.channels.max(4); + meta.linesize = dst_linesize; + meta.data_size = dst_need as i32; + } + Ok(()) + } + + /// Map a wire ticket spec to the eval producer's ticket params. + fn ticket_params(&self, spec: &BatchTicketSpec, time: Rational) -> VideoTicketParams { + let footage = if spec.footage_file.is_empty() { + None + } else { + Some((spec.footage_file.clone(), spec.footage_stream)) + }; + let montage: Vec = spec + .montage + .iter() + .map(|c| MontageClip { + filename: c.filename.clone(), + stream_index: c.stream_index, + in_time: Rational::new(c.in_num, c.in_den), + out_time: Rational::new(c.out_num, c.out_den), + media_in: Rational::new(c.media_in_num, c.media_in_den), + gain: c.gain, + }) + .collect(); + VideoTicketParams { + viewer: self + .graph + .as_ref() + .map(|g| g.project_copy) + .unwrap_or(0), + time, + force_size: Some((spec.width, spec.height)), + force_format: Some(PixelFormat::F32), + cache: None, + cache_dir: None, + cache_id: None, + cache_timebase: None, + footage, + montage, + } + } +} + +/// Render the F32 RGBA pipeline frame for `spec` into `dst` +/// (`(w*h*16)` bytes): generated transparent black, footage decode, +/// or montage composite — through [`oakrender::eval`]. +fn render_f32_into( + spec: &BatchTicketSpec, + params: &VideoTicketParams, + time: Rational, + size: (i32, i32), + dst: &mut [u8], +) -> Result<(), String> { + let (w, h) = size; + let stride = w * 16; + if !params.montage.is_empty() { + return eval::render_montage_frame_into(time, params, (w, h), dst, stride) + .map_err(|e| format!("montage render: {e}")); + } + if !spec.footage_file.is_empty() { + let decoded = eval::render_footage_frame( + &spec.footage_file, + spec.footage_stream, + time, + (w, h), + PixelFormat::F32, + ) + .map_err(|e| format!("footage decode: {e}"))?; + let oakrender::texture::Texture::Cpu(frame) = &decoded else { + return Err("decode produced a GPU texture".to_string()); + }; + let src_stride = frame.linesize_bytes() as usize; + let row_bytes = (w as usize) * 16; + if frame.data.len() < src_stride * (h as usize) || dst.len() < row_bytes * (h as usize) { + return Err("decoded frame geometry mismatch".to_string()); + } + for y in 0..h as usize { + dst[y * row_bytes..(y + 1) * row_bytes] + .copy_from_slice(&frame.data[y * src_stride..y * src_stride + row_bytes]); + } + return Ok(()); + } + // Generated frame: transparent black. + dst[..(h as usize) * (stride as usize)].fill(0); + Ok(()) +} + +/// Convert F32 RGBA (`src`, 16 bytes/px) to 8-bit BGRA (`dst`, 4 +/// bytes/px) with clamping — the worker-side end-of-pipe convert for +/// BGRA8 preview slots. +fn convert_f32_rgba_to_bgra8(src: &[u8], dst: &mut [u8]) { + let to_u8 = |v: f32| -> u8 { (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8 }; + let pixels = dst.len() / 4; + for i in 0..pixels { + let s = &src[i * 16..i * 16 + 16]; + let r = f32::from_le_bytes(s[0..4].try_into().unwrap()); + let g = f32::from_le_bytes(s[4..8].try_into().unwrap()); + let b = f32::from_le_bytes(s[8..12].try_into().unwrap()); + let a = f32::from_le_bytes(s[12..16].try_into().unwrap()); + let d = &mut dst[i * 4..i * 4 + 4]; + d[0] = to_u8(b); + d[1] = to_u8(g); + d[2] = to_u8(r); + d[3] = to_u8(a); } } @@ -391,7 +886,9 @@ impl WorkerSession { pub fn worker_main(backend: &str) -> i32 { // 1. Session creation initializes the render backend through the // oakrender crate's direct Rust API - // (oakengine_worker_session_create()). + // (oakengine_worker_session_create()). The M15 "cpu" backend is + // headless: no renderer, CPU evaluation + decode only. + let cpu_mode = is_cpu_backend(backend); let mut session = match WorkerSession::create(backend) { Ok(s) => s, Err(msg) => { @@ -399,14 +896,14 @@ pub fn worker_main(backend: &str) -> i32 { return 1; } }; - if !session.has_renderer() { + if !session.has_renderer() && !cpu_mode { // Mirrors oakengine_worker_main(): without a renderer the worker // cannot do anything, so it exits 1. ("--backend none" lands here.) log_error("no renderer initialized"); return 1; } - // 2. Runtime services (config load etc.). + // 2. Runtime services (config load, plugin executor install). if !session.initialize_runtime() { return 1; } @@ -443,6 +940,21 @@ pub fn worker_main(backend: &str) -> i32 { // Blank lines are skipped silently (read_message() semantics). continue; } + // Protocol v2: render_batch streams its responses (one + // batch_accepted + one frame_ready/frame_failed per ticket). + if serde_json::from_str::(&line) + .ok() + .and_then(|v| v.get("type").and_then(Value::as_str).map(str::to_string)) + .as_deref() + == Some(TYPE_RENDER_BATCH) + { + if let Err(e) = session.handle_render_batch_stream(&line, &mut out) { + log_error(&format!("failed to serve render_batch: {e}")); + exit_code = 1; + break; + } + continue; + } if let Some(response) = session.handle_line(&line) { if let Err(e) = write_message(&mut out, &response) { log_error(&format!("failed to write response: {e}")); @@ -607,8 +1119,20 @@ mod tests { fn handshake_attaches_real_output_pool() { let mut s = WorkerSession::create("none").unwrap(); let (hs, out_region, _in) = parent_side(4, 4096, false); - let resp = s.handle_line(&hs.to_string()); - assert!(resp.is_none(), "unexpected error: {resp:?}"); + let resp = s.handle_line(&hs.to_string()).expect("hello_caps response"); + // Protocol v2: a successful attach answers the geometry handshake + // with the capability announcement. + assert_eq!(resp["type"], crate::ipc::TYPE_HELLO_CAPS); + assert_eq!(resp["protocol_version"], PROTOCOL_VERSION); + let formats: Vec = resp["formats"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_i64().unwrap()) + .collect(); + assert!(formats.contains(&(PixelFormat::F32 as i64))); + assert!(formats.contains(&(SLOT_FORMAT_BGRA8 as i64))); + assert_eq!(resp["max_slot_bytes"], 4096); // The session now holds a real attached pool with the parent's // geometry. let out_pool = s.output_pool.as_ref().unwrap(); @@ -648,8 +1172,8 @@ mod tests { fn handshake_attaches_input_pool_too() { let mut s = WorkerSession::create("none").unwrap(); let (hs, _out, _in) = parent_side(2, 256, true); - let resp = s.handle_line(&hs.to_string()); - assert!(resp.is_none(), "unexpected error: {resp:?}"); + let resp = s.handle_line(&hs.to_string()).expect("hello_caps response"); + assert_eq!(resp["type"], crate::ipc::TYPE_HELLO_CAPS); assert!(s.input_pool.is_some()); let in_pool = s.input_pool.as_ref().unwrap(); assert_eq!(in_pool.slot_count(), 2); @@ -722,7 +1246,7 @@ mod tests { } #[test] - fn load_graph_checks_are_real_then_stub() { + fn load_graph_file_checks_then_real_deserialization() { let mut s = WorkerSession::create("none").unwrap(); let missing = "/definitely/not/a/real/graph.ove"; @@ -747,32 +1271,150 @@ mod tests { ); let _ = std::fs::remove_file(&empty); + // A real oaknode project round-trips through the serializer. + let project = oaknode::project::Project::new(); + let xml = oaknode::serializer::save(&project.lock().unwrap_or_else(|e| e.into_inner())) + .expect("serialize empty project"); let real = std::env::temp_dir().join("oak_worker_main_test_graph.ove"); - std::fs::write(&real, b"").unwrap(); + std::fs::write(&real, &xml).unwrap(); + let resp = s.handle_line( + &json!({ "type": "load_graph", "path": real.display().to_string() }).to_string(), + ); + assert!(resp.is_none(), "unexpected error: {resp:?}"); + let graph = s.graph.as_ref().expect("graph loaded"); + assert!(graph.project.is_some(), "full project deserialized"); + let _ = std::fs::remove_file(&real); + + // The minimal identity-only payload (`{"project_copy":N}`) loads as + // a copied-project context. + let ident = std::env::temp_dir().join("oak_worker_main_test_identity.ove"); + std::fs::write(&ident, r#"{"project_copy":7}"#).unwrap(); + let resp = s.handle_line( + &json!({ "type": "load_graph", "path": ident.display().to_string() }).to_string(), + ); + assert!(resp.is_none(), "unexpected error: {resp:?}"); + let graph = s.graph.as_ref().expect("graph loaded"); + assert!(graph.project.is_none()); + assert_eq!(graph.project_copy, 7); + let _ = std::fs::remove_file(&ident); + + // Garbage that is neither project XML nor identity JSON fails + // explainably. + let bad = std::env::temp_dir().join("oak_worker_main_test_bad.ove"); + std::fs::write(&bad, b"definitely not a graph").unwrap(); let resp = s .handle_line( - &json!({ "type": "load_graph", "path": real.display().to_string() }).to_string(), + &json!({ "type": "load_graph", "path": bad.display().to_string() }).to_string(), ) .unwrap(); assert!(resp["message"] .as_str() .unwrap() - .contains("node-graph deserialization is not yet available")); - let _ = std::fs::remove_file(&real); + .starts_with("graph deserialization failed: ")); + let _ = std::fs::remove_file(&bad); } #[test] - fn render_frame_reports_stub_with_ticket() { + fn render_frame_without_pool_reports_error_with_ticket() { let mut s = WorkerSession::create("none").unwrap(); let resp = s .handle_line(r#"{"type":"render_frame","ticket":123,"node":"abc"}"#) .unwrap(); assert_eq!(resp["type"], "error"); assert_eq!(resp["ticket"], 123); - assert!(resp["message"] - .as_str() + assert_eq!( + resp["message"], + "render_frame: no shared-memory pool attached" + ); + } + + #[test] + fn render_frame_v1_renders_generated_frame_into_slot() { + let mut s = WorkerSession::create("none").unwrap(); + let (hs, out_region, _in) = parent_side(4, 4 * 4 * 16, false); + let resp = s.handle_line(&hs.to_string()).expect("hello_caps response"); + assert_eq!(resp["type"], crate::ipc::TYPE_HELLO_CAPS); + + // The v1 single-frame path renders the pipeline's generated frame + // (transparent black F32; format -1 = pipeline default) into an + // acquired slot and reports it. + let resp = s + .handle_line( + r#"{"type":"render_frame","ticket":123,"time_num":1,"time_den":2,"width":4,"height":4,"format":-1}"#, + ) + .unwrap(); + assert_eq!(resp["type"], "frame_ready", "unexpected: {resp}"); + assert_eq!(resp["ticket"], 123); + let slot = resp["slot"].as_i64().unwrap() as u32; + + // The parent (drainer) consumes the published slot and sees the + // meta the worker wrote. + let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) }; + let mut consumed = 0; + assert!(unsafe { parent_pool.consume(&mut consumed) }); + assert_eq!(consumed, slot); + let meta = unsafe { &*parent_pool.meta_const(consumed) }; + assert_eq!(meta.id, 123); + assert_eq!(meta.width, 4); + assert_eq!(meta.height, 4); + assert_eq!(meta.format, PixelFormat::F32 as i32); + assert_eq!(meta.data_size, 4 * 4 * 16); + // Generated frame: transparent black. + let data = unsafe { std::slice::from_raw_parts(parent_pool.slot_data_const(consumed), 4 * 4 * 16) }; + assert!(data.iter().all(|&b| b == 0)); + unsafe { parent_pool.release(consumed) }; + } + + #[test] + fn render_batch_stream_renders_generated_frames_and_reports_failures() { + let mut s = WorkerSession::create("none").unwrap(); + // Slots sized for 8x8 BGRA8. + let (hs, out_region, _in) = parent_side(4, 8 * 8 * 4, false); + let resp = s.handle_line(&hs.to_string()).expect("hello_caps response"); + assert_eq!(resp["type"], crate::ipc::TYPE_HELLO_CAPS); + + let batch = json!({ + "type": "render_batch", + "batch_id": 9, + "tickets": [ + { "ticket": 1, "slot": 0, "time_num": 0, "time_den": 1, "width": 8, "height": 8, "format": SLOT_FORMAT_BGRA8, "channels": 4 }, + { "ticket": 2, "slot": 1, "time_num": 0, "time_den": 1, "width": 0, "height": 8, "format": SLOT_FORMAT_BGRA8, "channels": 4 }, + ], + }); + let mut out: Vec = Vec::new(); + s.handle_render_batch_stream(&batch.to_string(), &mut out) + .unwrap(); + let lines: Vec = String::from_utf8(out) .unwrap() - .contains("frame rendering is not yet available")); + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + assert_eq!(lines.len(), 3, "accepted + one reply per ticket"); + assert_eq!(lines[0]["type"], "batch_accepted"); + assert_eq!(lines[0]["batch_id"], 9); + assert_eq!(lines[1]["type"], "frame_ready"); + assert_eq!(lines[1]["ticket"], 1); + assert_eq!(lines[1]["slot"], 0); + assert_eq!(lines[2]["type"], "frame_failed"); + assert_eq!(lines[2]["ticket"], 2); + + // The rendered slot holds opaque black BGRA8 (generated transparent + // black F32 converted: alpha 0). + let parent_pool = unsafe { FrameSlotPool::attach(out_region.data()) }; + let mut consumed = 0; + assert!(unsafe { parent_pool.consume(&mut consumed) }); + assert_eq!(consumed, 0); + let meta = unsafe { &*parent_pool.meta_const(consumed) }; + assert_eq!(meta.id, 1); + assert_eq!(meta.format, SLOT_FORMAT_BGRA8); + assert_eq!(meta.linesize, 8 * 4); + assert_eq!(meta.data_size, 8 * 8 * 4); + unsafe { parent_pool.release(consumed) }; + + // The failed ticket acquired slot 1 but never published it, so it + // is still owned by the filler side (not in the ready ring) — the + // ready ring is empty now. + assert!(!unsafe { parent_pool.consume(&mut consumed) }); } // ---- oak-worker's in-process session tests (M14 R2: folded from the diff --git a/crates/oak-worker/tests/procpool_integration.rs b/crates/oak-worker/tests/procpool_integration.rs new file mode 100644 index 000000000..198bfd652 --- /dev/null +++ b/crates/oak-worker/tests/procpool_integration.rs @@ -0,0 +1,315 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! M15 S1 end-to-end: real oak-worker processes driven by the +//! main-process [`ProcessDispatcher`] — spawn, handshake, batched +//! renders into shared-memory slots, crash isolation with restart and +//! re-dispatch, and the zero-copy main-process guarantee. +//! +//! These tests spawn actual `oak-worker` child processes (located through +//! `CARGO_BIN_EXE_oak-worker`), so they double as the binary-resolution +//! regression gate for [`DispatcherConfig::worker_bin`]. +//! +//! All tests in this file serialize on [`TEST_LOCK`]: they spawn worker +//! processes that inherit the process environment, and the crash test +//! sets crash-hook variables that must not leak into sibling runs. + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use oakcore_rs::{PixelFormat, Rational}; +use oakrender::ipc::SLOT_FORMAT_BGRA8; +use oakrender::procpool::{ + main_heap_frame_copies, reset_main_heap_frame_copies, DispatcherConfig, ProcessDispatcher, +}; +use oakrender::ticket::{TicketPayload, TicketResult, VideoTicketParams}; +use oakrender::worker::{Job, JobDispatch}; + +/// Serialize every test in this file (shared process environment + +/// real child processes). +static TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn lock_test() -> std::sync::MutexGuard<'static, ()> { + TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn worker_bin() -> std::path::PathBuf { + env!("CARGO_BIN_EXE_oak-worker").into() +} + +fn config(workers: usize, slots: u32) -> DispatcherConfig { + DispatcherConfig { + worker_bin: Some(worker_bin()), + workers, + slots_per_worker: slots, + width: 64, + height: 64, + slot_format: SLOT_FORMAT_BGRA8, + batch_size: 4, + graph_snapshot: None, + handshake_timeout_ms: 30_000, + } +} + +fn params(time: Rational, footage: Option<(String, i32)>) -> Arc { + Arc::new(VideoTicketParams { + viewer: 1, + time, + force_size: Some((64, 64)), + force_format: Some(PixelFormat::F32), + cache: None, + cache_dir: None, + cache_id: None, + cache_timebase: None, + footage, + montage: Vec::new(), + }) +} + +/// Submit `count` generated-frame tickets; returns the shared results +/// sink (completions land there from the dispatcher's poll pump). +fn submit( + dispatcher: &ProcessDispatcher, + results: &Arc>>, + count: usize, + footage: Option<(String, i32)>, +) { + for i in 0..count { + let results = results.clone(); + let footage = footage.clone(); + let job = Job { + node_identity: 1, + time: Rational::new(i as i64, 25), + params: params(Rational::new(i as i64, 25), footage), + // Never invoked on the process backend (workers render from + // the wire spec); must still be a valid producer. + produce: Arc::new(|_, _| { + Err(oakrender::error::Error::Failed( + "process backend does not use the in-process producer".into(), + )) + }), + done: Box::new(move |result| { + results.lock().unwrap_or_else(|e| e.into_inner()).push(result); + }), + }; + assert!(dispatcher.post(job), "post accepted while alive"); + } +} + +/// Pump the dispatcher until `expect` completions arrive or the deadline +/// passes. +fn pump_until(dispatcher: &ProcessDispatcher, results: &Mutex>, expect: usize) { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + dispatcher.poll(); + if results.lock().unwrap_or_else(|e| e.into_inner()).len() >= expect { + return; + } + if Instant::now() > deadline { + let have = results.lock().unwrap_or_else(|e| e.into_inner()).len(); + panic!("timeout: {have}/{expect} completions"); + } + std::thread::sleep(Duration::from_millis(5)); + } +} + +/// Two real workers render two waves of generated frames into shm slots; +/// the main process never copies frame bytes. Slots are released as +/// frames arrive (the dispatcher's credit-based flow control then keeps +/// the remaining tickets flowing). +#[test] +fn two_workers_render_two_waves_zero_copy() { + let _guard = lock_test(); + let dispatcher = ProcessDispatcher::new(config(2, 4)).expect("dispatcher config"); + dispatcher.start().expect("workers start + handshake"); + assert_eq!(dispatcher.worker_count(), 2); + assert!(dispatcher.is_alive(0)); + assert!(dispatcher.is_alive(1)); + + reset_main_heap_frame_copies(); + + // Wave 1: more tickets than slots in one worker, so both workers and + // the slot-recycling path are exercised. + let results = Arc::new(Mutex::new(Vec::new())); + submit(&dispatcher, &results, 12, None); + + let mut seen_worker = [false; 2]; + let mut completed = 0usize; + let deadline = Instant::now() + Duration::from_secs(60); + while completed < 12 { + dispatcher.poll(); + let drained: Vec = + results.lock().unwrap_or_else(|e| e.into_inner()).drain(..).collect(); + for result in drained { + let payload = result.expect("frame rendered"); + let TicketPayload::ShmFrame(frame) = payload else { + panic!("process backend must deliver ShmFrame payloads"); + }; + assert!(frame.worker < 2); + assert!(frame.slot < 4); + assert_eq!(frame.meta.width, 64); + assert_eq!(frame.meta.height, 64); + assert_eq!(frame.meta.format, SLOT_FORMAT_BGRA8); + assert_eq!(frame.meta.data_size, 64 * 64 * 4); + assert_eq!(frame.meta.linesize, 64 * 4); + // Generated frame (transparent black) converted to BGRA8: all + // zero. Read through the mapping — never `slot_to_vec` (the + // counted copy path). + let pixels = frame.shm.slot_bytes(frame.slot); + assert!( + pixels[..frame.meta.data_size as usize] + .iter() + .all(|&b| b == 0), + "generated frame is transparent black" + ); + seen_worker[frame.worker as usize] = true; + dispatcher.release_frame(&frame); + completed += 1; + } + if Instant::now() > deadline { + panic!("timeout: {completed}/12 completions"); + } + if completed < 12 { + std::thread::sleep(Duration::from_millis(5)); + } + } + // Zero copy: nothing bumped the main-process frame-copy counter. + assert_eq!(main_heap_frame_copies(), 0); + // Both workers participated (interleaved sharded claiming). + assert!(seen_worker[0], "worker 0 rendered at least one frame"); + assert!(seen_worker[1], "worker 1 rendered at least one frame"); + + // Wave 2 through the same recycled slots. + submit(&dispatcher, &results, 8, None); + pump_until(&dispatcher, &results, 8); + for result in results.lock().unwrap().drain(..) { + let payload = result.expect("second wave rendered"); + let TicketPayload::ShmFrame(frame) = payload else { + panic!("ShmFrame payload"); + }; + dispatcher.release_frame(&frame); + } + assert_eq!(main_heap_frame_copies(), 0); + + dispatcher.shutdown(); + assert_eq!(main_heap_frame_copies(), 0); +} + +/// A worker crashing mid-render (SIGSEGV hook) must not take down the +/// main process: the frame is re-queued, the worker restarted and the +/// ticket still completes with a rendered frame. +#[test] +fn crash_isolation_restarts_worker_and_frame_still_renders() { + let _guard = lock_test(); + + // One-shot crash hook: the worker dies with SIGSEGV while rendering + // ticket 1; the marker file it leaves behind makes the restarted + // worker render the re-queued frame for real. + let marker = std::env::temp_dir().join(format!( + "oak-procpool-crash-marker-{}", + std::process::id() + )); + let _ = std::fs::remove_file(&marker); + std::env::set_var("OAK_WORKER_CRASH_ON_TICKET", "1"); + std::env::set_var("OAK_WORKER_CRASH_MARKER", &marker); + struct EnvGuard; + impl Drop for EnvGuard { + fn drop(&mut self) { + std::env::remove_var("OAK_WORKER_CRASH_ON_TICKET"); + std::env::remove_var("OAK_WORKER_CRASH_MARKER"); + } + } + let _env_guard = EnvGuard; + + let dispatcher = ProcessDispatcher::new(config(1, 4)).expect("dispatcher config"); + dispatcher.start().expect("worker starts"); + assert_eq!(dispatcher.worker_count(), 1); + + let results = Arc::new(Mutex::new(Vec::new())); + // The dispatcher's first ticket id is 1 — exactly the crash ticket. + submit(&dispatcher, &results, 4, None); + pump_until(&dispatcher, &results, 4); + + // The crash hit the worker (it restarted at least once)... + assert!( + dispatcher.restarts_of(0) >= 1, + "crashed worker must be restarted (restarts={})", + dispatcher.restarts_of(0) + ); + // ...and every ticket still completed with a real frame. + let mut crashed_ticket_seen = false; + for result in results.lock().unwrap().drain(..) { + let payload = result.expect("frame rendered despite the worker crash"); + let TicketPayload::ShmFrame(frame) = payload else { + panic!("ShmFrame payload"); + }; + if frame.meta.id == 1 { + crashed_ticket_seen = true; + assert_eq!(frame.meta.width, 64); + assert_eq!(frame.meta.data_size, 64 * 64 * 4); + } + dispatcher.release_frame(&frame); + } + assert!(crashed_ticket_seen, "ticket 1 delivered after the restart"); + assert!(marker.exists(), "the crash hook fired exactly once"); + let _ = std::fs::remove_file(&marker); + + dispatcher.shutdown(); +} + +/// Footage decode inside the worker process: a real H.264 frame from +/// `tests/demo.mp4` is decoded, scaled and converted into a BGRA8 slot. +#[test] +fn worker_decodes_real_footage_into_slot() { + let _guard = lock_test(); + let demo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/demo.mp4"); + assert!(demo.exists(), "repo fixture tests/demo.mp4 missing"); + + let dispatcher = ProcessDispatcher::new(config(1, 4)).expect("dispatcher config"); + dispatcher.start().expect("worker starts"); + + let results = Arc::new(Mutex::new(Vec::new())); + submit( + &dispatcher, + &results, + 2, + Some((demo.display().to_string(), 0)), + ); + pump_until(&dispatcher, &results, 2); + + for result in results.lock().unwrap().drain(..) { + let payload = result.expect("footage frame rendered"); + let TicketPayload::ShmFrame(frame) = payload else { + panic!("ShmFrame payload"); + }; + assert_eq!(frame.meta.width, 64); + assert_eq!(frame.meta.height, 64); + assert_eq!(frame.meta.format, SLOT_FORMAT_BGRA8); + // Decoded video is opaque: every BGRA alpha byte is 255. + let pixels = &frame.shm.slot_bytes(frame.slot)[..frame.meta.data_size as usize]; + let alpha_ok = pixels + .chunks_exact(4) + .filter(|px| px[3] == 255) + .count(); + assert!( + alpha_ok as f64 >= 0.99 * (64 * 64) as f64, + "decoded frame must be opaque ({alpha_ok}/4096)" + ); + dispatcher.release_frame(&frame); + } + + dispatcher.shutdown(); +} diff --git a/crates/oak-worker/tests/worker.rs b/crates/oak-worker/tests/worker.rs index a6ee31ab0..fd838e108 100644 --- a/crates/oak-worker/tests/worker.rs +++ b/crates/oak-worker/tests/worker.rs @@ -14,12 +14,13 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! Binary-level tests for `oak-worker`: the process exit contract. The -//! NDJSON control-loop behavior itself is exercised in-process in -//! `src/session.rs` (a real loop test would require a working GPU backend, -//! so it stays out of the unit suite). +//! Binary-level tests for `oak-worker`: the process exit contract and the +//! headless CPU mode's startup behavior. The NDJSON control-loop behavior +//! itself is exercised in-process in `src/worker.rs`; end-to-end runs +//! against the main-process dispatcher live in +//! `tests/procpool_integration.rs`. -use std::process::Command; +use std::process::{Command, Stdio}; fn bin() -> &'static str { env!("CARGO_BIN_EXE_oak-worker") @@ -40,3 +41,31 @@ fn backend_none_exits_one_like_the_cpp_main() { "stderr: {stderr}" ); } + +#[test] +fn backend_cpu_is_headless_but_fully_operational() { + // M15 S1: the "cpu" backend skips the renderer like "none" but the + // session stays up — it writes the startup handshake and exits 0 on + // EOF. (Also exercises the plugin-runtime install in the binary.) + let mut child = Command::new(bin()) + .args(["--backend", "cpu"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn oak-worker"); + // Close stdin right away: EOF ends the control loop. + drop(child.stdin.take()); + let out = child.wait_with_output().expect("wait oak-worker"); + assert_eq!(out.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&out.stdout); + let first = stdout.lines().next().unwrap_or(""); + assert!( + first.contains("\"type\":\"handshake\""), + "startup handshake on stdout, got: {stdout:?}" + ); + assert!( + first.contains("\"protocol_version\":1"), + "protocol version 1, got: {first:?}" + ); +} diff --git a/crates/oakrender/Cargo.toml b/crates/oakrender/Cargo.toml index 6f51cd85c..7bf93b581 100644 --- a/crates/oakrender/Cargo.toml +++ b/crates/oakrender/Cargo.toml @@ -27,6 +27,14 @@ wgpu = "25" ocio-rs = { version = "0.2", features = ["bundled"] } # `std::error::Error` impls for the crate-internal error enum. thiserror = "2" +# M15 S1: physical-memory query for the process-pool worker-count policy +# (sysctlbyname on macOS / sysconf on Linux) in src/procpool.rs. +libc = "0.2" +# M15 S1: the render-worker NDJSON control protocol (src/ipc.rs moved here +# from oak-worker so both ends of the pipe link one copy; src/procpool.rs +# is the main-process side). +serde = { version = "1", features = ["derive"] } +serde_json = "1" [dev-dependencies] # oakcodec is also a plain dependency above; the dev-dependency re-entry diff --git a/crates/oakrender/README.md b/crates/oakrender/README.md index 1dc0bdb42..268e01322 100644 --- a/crates/oakrender/README.md +++ b/crates/oakrender/README.md @@ -70,7 +70,16 @@ src/ color.rs ColorProcessor over ocio-rs + default config + LUT library manager.rs RenderManager singleton + lifecycle + disk cache ticket.rs Ticket arena, params, exactly-once completion delivery - worker.rs Worker pool + process pool (stub) + graph snapshot store + worker.rs Worker pool + frozen pre-M15 ProcessPool facade stub + + graph snapshot store + scheduler.rs M15 PreviewScheduler: interleaved shard claiming, + priority lanes (seek/playback/background), crash reclaim + ipc.rs M15 render-worker IPC (moved from oak-worker): NDJSON + control protocol (v1 + v2 messages) + the POSIX + shared-memory frame-slot transport both pipe ends link + procpool.rs M15 ProcessDispatcher: spawn/handshake oak-worker + processes, main-assigned slot batches, crash detection + + restart, zero-copy ShmFrameRef completions autocacher.rs PreviewAutoCacher eval.rs RenderHooks impl: the CPU evaluation seam backend.rs wgpu device/queue/texture management + DisplayRenderer @@ -86,7 +95,9 @@ tests/ contract + golden tests (common/ has shared helpers) 1. `CHandle` only appears at the facade boundary: the crate's internal calls pass Rust types directly; `handle::make_owned`/`get`/`get_mut` are the facade entry points the oakengine stubs call. -2. No `unsafe` outside `backend.rs` (GPU FFI) and `bridge/`. +2. No `unsafe` outside `backend.rs` (GPU FFI), `bridge/`, and the M15 + process-isolation transport (`ipc.rs` / `procpool.rs`: POSIX shm + + SPSC rings; every block carries its own SAFETY comment). 3. F32 + ACEScg pipeline invariants are asserted in tests, not in comments (see tests/pipeline_test.rs). @@ -106,9 +117,13 @@ tests/ contract + golden tests (common/ has shared helpers) in float. `oakrender_color_processor_create_transform` resolves the destination transform against the default config's reference role until the oakcommon color-transform bridge lands. -- **Worker process isolation** — `ProcessPool` (oakengine_ipc worker - binary) is a stub; `start/post` fail explainably; the crash-isolation - tests are `#[ignore]`. +- **Worker process isolation** — landed in M15 S1: `procpool.rs` + (`ProcessDispatcher`) + `scheduler.rs` + `ipc.rs` drive real + oak-worker processes (spawn, handshake, batched renders into + main-assigned shm slots, crash restart, zero-copy completions); the + end-to-end and crash-isolation tests live in + `crates/oak-worker/tests/procpool_integration.rs`. The frozen pre-M15 + `worker::ProcessPool` facade stub remains for the C ABI. - **Audio rendering** — audio tickets complete with `Error::Failed` (the audio graph path is not implemented); `oakrender_ticket_get_samples` fails explainably. diff --git a/crates/oakrender/src/autocacher.rs b/crates/oakrender/src/autocacher.rs index 20f62ca1d..d4a91a89e 100644 --- a/crates/oakrender/src/autocacher.rs +++ b/crates/oakrender/src/autocacher.rs @@ -314,7 +314,7 @@ mod tests { fn new_cacher() -> (PreviewAutoCacher, WorkerPool) { let mut pool = WorkerPool::new(2); pool.start(); - let arena = Arc::new(TicketArena::new(pool.clone(), frame_producer())); + let arena = Arc::new(TicketArena::new(Arc::new(pool.clone()), frame_producer())); (PreviewAutoCacher::new(arena), pool) } @@ -367,7 +367,7 @@ mod tests { f.allocate(); Ok(crate::ticket::TicketPayload::Video(Texture::wrap_frame(f))) }); - let arena = Arc::new(TicketArena::new(pool.clone(), producer)); + let arena = Arc::new(TicketArena::new(Arc::new(pool.clone()), producer)); (PreviewAutoCacher::new(arena), pool) } @@ -465,7 +465,7 @@ mod tests { let mut c = { let mut pool = WorkerPool::new(1); pool.start(); - let arena = Arc::new(TicketArena::new(pool.clone(), frame_producer())); + let arena = Arc::new(TicketArena::new(Arc::new(pool.clone()), frame_producer())); let c = PreviewAutoCacher::new(arena); pool.shutdown(); c diff --git a/crates/oakrender/src/eval.rs b/crates/oakrender/src/eval.rs index 30c96c3e3..1dcc8367d 100644 --- a/crates/oakrender/src/eval.rs +++ b/crates/oakrender/src/eval.rs @@ -683,28 +683,58 @@ fn render_montage_frame( ) -> Result { let mut acc = generate_frame(time, size, format)?; let stride = acc.linesize_bytes(); + let acc_data = &mut acc.data; + render_montage_frame_into(time, params, size, acc_data, stride as i32)?; + Ok(Texture::wrap_frame(acc)) +} + +/// Composite the montage at `time` directly into `dst` (F32 RGBA rows of +/// `dst_stride` bytes) — the M15 worker seam: the render worker passes a +/// shared-memory slot slice as `dst`, so the composited frame lands in +/// the slot with no staging copy. `dst` is zeroed first (transparent +/// black base). +pub fn render_montage_frame_into( + time: Rational, + params: &crate::ticket::VideoTicketParams, + size: (i32, i32), + dst: &mut [u8], + dst_stride: i32, +) -> Result<()> { let (w, h) = size; - let mut acc32 = acc.data; // decode from bottom clip first + let need = (h as usize).saturating_mul(dst_stride as usize); + if w <= 0 || h <= 0 || dst.len() < need { + return Err(Error::Invalid); + } + // Transparent-black base. + dst[..need].fill(0); + // Decode from the bottom clip first, composite topmost-last. for clip in ¶ms.montage { if time < clip.in_time || time >= clip.out_time { continue; } let media_time = clip.media_in + (time - clip.in_time); - let decoded = render_footage_frame(&clip.filename, clip.stream_index, media_time, (w, h), format)?; + let decoded = render_footage_frame( + &clip.filename, + clip.stream_index, + media_time, + (w, h), + PixelFormat::F32, + )?; let (src_data, src_stride) = match &decoded { Texture::Cpu(src) => (&src.data, src.linesize_bytes() as i32), _ => continue, }; - composite_over(&mut acc32, stride as i32, w, h, src_data, src_stride, clip.gain); + composite_over(dst, dst_stride, w, h, src_data, src_stride, clip.gain); } - acc.data = acc32; - Ok(Texture::wrap_frame(acc)) + Ok(()) } /// `src` over `dst` (premultiplied-ish alpha compositing; F32 RGBA). /// `gain` scales the source RGB (audio-style volume applied to video -/// transparency is ignored here; gain scales color). -fn composite_over( +/// transparency is ignored here; gain scales color). Exposed for the M15 +/// render worker, which composites montage frames directly into +/// shared-memory slots. +pub fn composite_over( dst: &mut [u8], dst_stride: i32, w: i32, diff --git a/crates/oakrender/src/ipc.rs b/crates/oakrender/src/ipc.rs new file mode 100644 index 000000000..37db21cc0 --- /dev/null +++ b/crates/oakrender/src/ipc.rs @@ -0,0 +1,1808 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Render-worker IPC: the control-plane NDJSON protocol and the +//! shared-memory frame-slot transport. The transport is the Rust port of +//! `engine/render/ipc/` + `ipcmessage.cpp`. +//! +//! Ownership moved to oakrender in M15 S1: both ends of the pipe now +//! link this single copy (the main-process [`crate::procpool`] +//! dispatcher creates the segments and speaks the protocol; the +//! oak-worker binary re-exports this module from its `crate::ipc` +//! shim). Before M15 the module lived in the oak-worker binary (M14 +//! R2); the facade still keeps its own copy for the frozen +//! `oakengine_ipc_*` C ABI. +//! +//! Two halves: +//! +//! - **Control plane.** One compact JSON object per line on the stdio +//! pipes (worker.cpp / ipcmessage.cpp `write_message`/`read_message`). +//! Every message carries a `"type"` string; the field names below are +//! the ones the C++ serializers actually emit +//! (`engine/render/ipc/ipcmessage.cpp`): note `ticket` / `node` / +//! `channels` / `slot` — the longer names (`ticket_id`, `node_uuid`, +//! `channel_count`, `output_slot`) exist only on the C POD structs in +//! `ipc.h`. [`write_message`]/[`error_message`] build the wire lines. +//! - **Data plane.** Named shared memory holding the frame-slot pools — +//! the port of `engine/render/ipc/` (`sharedmemoryregion.cpp`, +//! `frameslotpool.cpp`): [`SharedMemoryRegion`] maps a named POSIX +//! segment (`shm_open` + `mmap`, `munmap` + `shm_unlink` on close), +//! and [`FrameSlotPool`] lays out a fixed pool of equal-sized frame +//! slots inside it with lock-free hand-off through two +//! [`SpscRingBuffer`]s of slot indices (free + ready). Each ring is a +//! single-producer/single-consumer structure; the filler owns +//! `free.pop` + `ready.push`, the drainer owns `ready.pop` + +//! `free.push`, so no mutex is ever taken. +//! +//! **The in-memory layout is the version-1 wire protocol** the app and the +//! render worker share, and it never changes: the byte offsets below are +//! copied field-for-field from the C++ implementation (64-byte cache-line +//! alignment, the `Header`/`SpscRingBuffer`/`oak_frame_slot_meta` POD +//! structs). A segment written by the C++ side attaches here and vice +//! versa. +//! +//! This module is deliberately unsafe-heavy and self-contained: it touches +//! raw shared memory and raw POSIX syscalls, and everything else in the +//! crate reaches it through the safe wrapper methods. +//! +//! Message types (M = main/editor, W = worker): +//! handshake M<->W negotiate protocol version + announce shm geometry +//! load_graph M ->W path to a temp file holding the serialized graph +//! render_frame M ->W request a frame render (ticket, node, time, params) +//! frame_ready W ->M a rendered frame is published (slot + ticket) +//! cancel M ->W abandon an in-flight ticket +//! graph_update M ->W reserved (no payload struct yet) +//! shutdown M ->W finish current work and exit cleanly +//! error W ->M worker-side failure report ("message" field) +//! +//! Protocol v2 additions (M15 S1; backward compatible — v1 message names +//! and wire shapes are unchanged, v2 only adds new message types): +//! hello_caps W ->M worker capabilities after a successful shm attach +//! (supported output formats, max slot size) +//! render_batch M ->W a batch of frame tickets with main-assigned slots +//! batch_accepted W ->M the worker claimed the batch (explicit claim) +//! frame_failed W ->M one ticket failed to render (error string) +//! +//! Slot addressing: `render_batch` tickets carry the destination `slot` +//! chosen by the main process; the worker never picks its own slots in +//! batch mode (main-side addressing lets the preview cache live directly +//! on slots). The v1 `render_frame` path keeps worker-side slot acquire. + +#![allow(dead_code)] + +use std::ffi::{c_char, c_int}; +use std::io::{self, Write}; +use std::ptr; +use std::sync::atomic::{AtomicU32, Ordering}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + + +/// `"handshake"`. +pub const TYPE_HANDSHAKE: &str = "handshake"; +/// `"load_graph"`. +pub const TYPE_LOAD_GRAPH: &str = "load_graph"; +/// `"render_frame"`. +pub const TYPE_RENDER_FRAME: &str = "render_frame"; +/// `"frame_ready"`. +pub const TYPE_FRAME_READY: &str = "frame_ready"; +/// `"cancel"`. +pub const TYPE_CANCEL: &str = "cancel"; +/// `"graph_update"`. +pub const TYPE_GRAPH_UPDATE: &str = "graph_update"; +/// `"shutdown"`. +pub const TYPE_SHUTDOWN: &str = "shutdown"; +/// `"error"`. +pub const TYPE_ERROR: &str = "error"; +/// `"hello_caps"` (protocol v2). +pub const TYPE_HELLO_CAPS: &str = "hello_caps"; +/// `"render_batch"` (protocol v2). +pub const TYPE_RENDER_BATCH: &str = "render_batch"; +/// `"batch_accepted"` (protocol v2). +pub const TYPE_BATCH_ACCEPTED: &str = "batch_accepted"; +/// `"frame_failed"` (protocol v2). +pub const TYPE_FRAME_FAILED: &str = "frame_failed"; + +/// Wire-format slot format for 8-bit BGRA frames (M15 S1). The viewer +/// preview path requests BGRA8 so the worker converts its F32 pipeline +/// output at the end of the render (conversion, not an extra copy) and +/// the main process can feed the slot straight to the GPU queue. The +/// value lives outside the `PixelFormat` enum range on purpose: it is a +/// slot wire format, not a pipeline format. +pub const SLOT_FORMAT_BGRA8: i32 = 100; + +/// `handshake` — field-for-field equivalent of `oak_ipc_handshake` +/// (ipc.h). Wire field names match the C++ serializer. +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct HandshakeMsg { + /// Protocol version. + pub protocol_version: i32, + /// Worker->main output shared-memory segment key. + pub shm_key: String, + /// Main->worker input shared-memory segment key (optional). + pub input_shm_key: String, + /// Number of main->worker input frame slots. + pub input_slots: i32, + /// Number of worker->main output frame slots. + pub output_slots: i32, + /// Per-output-slot pixel block size. + pub slot_data_bytes: i64, + /// Per-input-slot pixel block size. + pub input_slot_data_bytes: i64, +} + +impl HandshakeMsg { + /// The worker's startup handshake (`worker.cpp startup_handshake()`). + pub fn to_json(&self) -> Value { + json!({ + "type": TYPE_HANDSHAKE, + "protocol_version": self.protocol_version, + "shm_key": self.shm_key, + "input_shm_key": self.input_shm_key, + "input_slots": self.input_slots, + "output_slots": self.output_slots, + "slot_data_bytes": self.slot_data_bytes, + "input_slot_data_bytes": self.input_slot_data_bytes, + }) + } +} + +/// `render_frame` — request a frame render. Wire names per ipcmessage.cpp: +/// `ticket`, `node`, `channels` (not the ipc.h POD names). +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct RenderFrameMsg { + /// Correlates with the eventual frame_ready. + pub ticket: i64, + /// Viewer node stable uuid in the loaded graph. + pub node: String, + /// Frame timestamp numerator. + pub time_num: i64, + /// Frame timestamp denominator. + pub time_den: i64, + /// Forced output size (0 = graph default). + pub width: i32, + /// Forced output height (0 = graph default). + pub height: i32, + /// Forced PixelFormat (-1 = default). + pub format: i32, + /// Channel count (0 = default). + pub channels: i32, + /// RenderMode. + pub mode: i32, + /// Optional decoded input slot (-1 = none). + pub input_slot: i32, + /// Ordered decoded input slots. + pub input_slots: Vec, + /// Output color transform present? + pub has_color_transform: bool, + /// Color transform targets the display space. + pub color_is_display: bool, + /// Output color space name. + pub color_output: String, + /// Output color view name. + pub color_view: String, + /// Output color look name. + pub color_look: String, +} + +/// `frame_ready` — a rendered frame is published (wire names `ticket`/ +/// `slot`). +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct FrameReadyMsg { + /// Correlates with the render_frame request. + pub ticket: i64, + /// Index into the worker->main output FrameSlotPool. + pub slot: i32, +} + +/// `cancel` — abandon an in-flight ticket by id. +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct CancelMsg { + /// The in-flight ticket id to abandon. + pub ticket: i64, +} + +/// `load_graph` — path to a temporary file holding the serialized graph. +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +#[serde(default)] +pub struct LoadGraphMsg { + /// Path to the temporary file holding the serialized graph. + pub path: String, +} + +// --------------------------------------------------------------------------- +// Protocol v2 messages (M15 S1; additive, v1 wire shapes unchanged) +// --------------------------------------------------------------------------- + +/// `hello_caps` (worker->main) — sent right after a successful handshake +/// shm attach: the output formats the worker can write into slots and the +/// maximum slot size it accepts (main uses both to negotiate geometry). +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct HelloCapsMsg { + /// Protocol version the worker speaks (1; v2 is additive). + pub protocol_version: i32, + /// Slot output formats the worker supports (`PixelFormat` ints plus + /// [`SLOT_FORMAT_BGRA8`]). + pub formats: Vec, + /// Largest slot data block the worker will render into (bytes). + pub max_slot_bytes: i64, +} + +/// One montage clip on the wire (rationals flattened to num/den pairs). +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct WireMontageClip { + /// Footage filename. + pub filename: String, + /// Media stream index. + pub stream_index: i32, + /// Clip in point numerator (sequence time). + pub in_num: i64, + /// Clip in point denominator. + pub in_den: i64, + /// Clip out point numerator (sequence time). + pub out_num: i64, + /// Clip out point denominator. + pub out_den: i64, + /// Media in point numerator. + pub media_in_num: i64, + /// Media in point denominator. + pub media_in_den: i64, + /// Playback gain (1.0 = unity). + pub gain: f32, +} + +/// One frame ticket inside a [`RenderBatchMsg`] — the main process +/// assigns the destination `slot`. +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct BatchTicketSpec { + /// Ticket id (correlates with frame_ready / frame_failed). + pub ticket: i64, + /// Destination slot index in the worker->main output pool. + pub slot: i32, + /// Frame timestamp numerator. + pub time_num: i64, + /// Frame timestamp denominator. + pub time_den: i64, + /// Output width. + pub width: i32, + /// Output height. + pub height: i32, + /// Slot output format (`PixelFormat` int or [`SLOT_FORMAT_BGRA8`]). + pub format: i32, + /// Channel count (4 on the video pipeline). + pub channels: i32, + /// Single-footage decode filename ("" = none). + pub footage_file: String, + /// Single-footage stream index. + pub footage_stream: i32, + /// Sequence montage (ordered topmost-last; empty = none). + pub montage: Vec, +} + +/// `render_batch` (main->worker) — a batch of frame tickets with +/// main-assigned slots, rendered in order. +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct RenderBatchMsg { + /// Batch id (correlates with batch_accepted). + pub batch_id: i64, + /// The tickets, rendered in order. + pub tickets: Vec, +} + +/// `batch_accepted` (worker->main) — explicit claim confirmation for a +/// [`RenderBatchMsg`] (the batch's frames are owned by this worker; no +/// work stealing). +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct BatchAcceptedMsg { + /// The accepted batch id. + pub batch_id: i64, + /// The ticket ids accepted (same order as the batch). + pub tickets: Vec, +} + +/// `frame_failed` (worker->main) — one ticket failed to render; the main +/// process falls back (purple frame) and owns the slot again. +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +#[serde(default)] +pub struct FrameFailedMsg { + /// The failed ticket id. + pub ticket: i64, + /// Human-readable failure reason. + pub error: String, +} + +/// Build a worker-side error report, mirroring `error_message()` in +/// worker.cpp: `{"type":"error","message":...}` plus `"ticket"` when +/// non-zero. +pub fn error_message(message: &str, ticket: Option) -> Value { + match ticket.filter(|t| *t != 0) { + Some(t) => json!({ "type": TYPE_ERROR, "message": message, "ticket": t }), + None => json!({ "type": TYPE_ERROR, "message": message }), + } +} + +/// Write one NDJSON message line (compact JSON + `\n`), the Rust port of +/// `ipcmessage.cpp write_message()`. +pub fn write_message(w: &mut impl Write, msg: &Value) -> io::Result<()> { + let line = + serde_json::to_string(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + w.write_all(line.as_bytes())?; + w.write_all(b"\n") +} + +// --------------------------------------------------------------------------- +// Shared-memory frame-slot transport +// --------------------------------------------------------------------------- + +/// `OAK_IPC_SHM_KEY_CAP` — capacity of shm key strings (ipc.h), incl. NUL. +pub const OAK_IPC_SHM_KEY_CAP: usize = 128; +/// `OAK_IPC_COLORSPACE_CAP` — capacity of `oak_frame_slot_meta::colorspace`. +pub const OAK_IPC_COLORSPACE_CAP: usize = 128; + +/// Byte alignment of every sub-region of a frame slot pool (the C++ +/// `k_align = 64`; cache-line alignment). +const K_ALIGN: usize = 64; + +/// `k_magic = 0x4F4B5350` ("OKSP") — the frame slot pool header magic. +pub const FRAMEPOOL_MAGIC: u32 = 0x4F4B5350; + +/// Round `value` up to the next multiple of `align` (power of two). +const fn align_up(value: usize, align: usize) -> usize { + (value + (align - 1)) & !(align - 1) +} + +/// `OAK_IPC_SHM_MODE_CREATE` / `OAK_IPC_SHM_MODE_ATTACH` (ipc.h). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ShmMode { + /// Create (and own) the segment. Fails if it already exists; the owner + /// unlinks it on close. + Create, + /// Attach to a segment created by the peer. Does not unlink on close. + Attach, +} + +impl ShmMode { + /// Map the C ABI mode integer (`OAK_IPC_SHM_MODE_CREATE` = 0, + /// `OAK_IPC_SHM_MODE_ATTACH` = 1) back to the enum. + fn from_c(v: c_int) -> ShmMode { + match v { + 0 => ShmMode::Create, + _ => ShmMode::Attach, + } + } +} + +/// Per-slot metadata describing the frame currently occupying a slot — +/// field-for-field `oak_frame_slot_meta` from `engine/include/oakengine/ipc.h`. +/// +/// This POD lives in shared memory alongside the pixel data and is part of +/// the version-1 wire protocol; `#[repr(C)]` keeps the C ABI layout. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FrameSlotMeta { + /// Caller-defined tag (ticket id, or footage stream hash). + pub id: i64, + /// Frame timestamp numerator. + pub time_num: i64, + /// Frame timestamp denominator. + pub time_den: i64, + /// Frame width. + pub width: i32, + /// Frame height. + pub height: i32, + /// `PixelFormat::Format` value. + pub format: i32, + /// Channel count. + pub channel_count: i32, + /// Bytes per scanline (stride). + pub linesize: i32, + /// Valid bytes written into the slot's data block. + pub data_size: i32, + /// Input colorspace name. + pub colorspace: [c_char; OAK_IPC_COLORSPACE_CAP], +} + +impl Default for FrameSlotMeta { + fn default() -> Self { + FrameSlotMeta { + id: 0, + time_num: 0, + time_den: 0, + width: 0, + height: 0, + format: 0, + channel_count: 0, + linesize: 0, + data_size: 0, + colorspace: [0; OAK_IPC_COLORSPACE_CAP], + } + } +} + +/// `sizeof(oak_frame_slot_meta)` (8+8+8 + 4*6 + 128). +const FRAME_SLOT_META_SIZE: usize = 176; + +// --------------------------------------------------------------------------- +// SpscRingBuffer +// --------------------------------------------------------------------------- + +/// A lock-free single-producer / single-consumer ring buffer of `u32` +/// indices, living in shared memory — the port of +/// `engine/include/oakengine/spscringbuffer.h`. +/// +/// Layout (offsets from the buffer base, matching the C++ class): +/// +/// ```text +/// 0 head_ u32 producer cursor (relaxed read, release write) +/// 4 tail_ u32 consumer cursor (relaxed read, release write) +/// 8 capacity_ u32 slot count (written once by create()) +/// 12 slots u32[capacity] +/// ``` +/// +/// One slot is always left empty to disambiguate full and empty, so a +/// buffer with `capacity` slots holds at most `capacity - 1` live entries. +/// The payload is a `u32` slot index — never a pointer. +/// +/// `SpscRingBuffer` is a thin view over a raw pointer; it is `Copy` and +/// owns nothing. All methods are `unsafe` because they read and write the +/// shared segment concurrently with a peer process. +#[derive(Clone, Copy)] +pub struct SpscRingBuffer { + /// Base of the ring header (`head_` at offset 0). + base: *mut u8, +} + +// The shared memory the ring lives in is usable from any thread of the +// local process; synchronization with the peer is the ring's own atomics. +unsafe impl Send for SpscRingBuffer {} +unsafe impl Sync for SpscRingBuffer {} + +impl SpscRingBuffer { + /// `sizeof(SpscRingBuffer)` — header bytes before the slot array. + pub const HEADER_BYTES: usize = 12; + + /// Total bytes required for the header plus `capacity` index slots + /// (`SpscRingBuffer::bytes_needed`). + pub fn bytes_needed(capacity: u32) -> usize { + Self::HEADER_BYTES + capacity as usize * 4 + } + + /// In-place construct a ring header at `mem` with `capacity` index + /// slots. `mem` must provide at least [`Self::bytes_needed`] bytes and + /// be suitably aligned (mmap-backed segments are). Done exactly once by + /// whichever process owns the segment's creation; the peer uses + /// [`Self::attach`] instead. + /// + /// # Safety + /// `mem` must be a valid, writable, aligned buffer of at least + /// [`Self::bytes_needed`] bytes, and must not be concurrently written + /// during this call. + pub unsafe fn create(mem: *mut u8, capacity: u32) -> SpscRingBuffer { + let ring = SpscRingBuffer { base: mem }; + unsafe { + ring.store_capacity(capacity); + ring.head().store(0, Ordering::Relaxed); + ring.tail().store(0, Ordering::Relaxed); + for i in 0..capacity as usize { + *ring.slot_ptr(i) = 0; + } + } + ring + } + + /// Re-interpret already-initialized shared memory as a ring buffer + /// (peer-process side). No writes are performed. + /// + /// # Safety + /// `mem` must point to a buffer previously initialized by + /// [`Self::create`] (or an ABI-identical C++ side) that stays mapped + /// for as long as this view is used. + pub unsafe fn attach(mem: *mut u8) -> SpscRingBuffer { + SpscRingBuffer { base: mem } + } + + /// The ring's capacity (slot count). + /// + /// # Safety + /// `self` must point at a live ring (created or attached). + pub unsafe fn capacity(&self) -> u32 { + unsafe { (self.base.add(8) as *const u32).read() } + } + + /// Producer side: enqueue an index. Returns false if the buffer is full. + /// + /// # Safety + /// Exactly one producer may call this concurrently with exactly one + /// consumer calling [`Self::pop`]; the ring must be live. + pub unsafe fn push(&self, value: u32) -> bool { + unsafe { + let head = self.head().load(Ordering::Relaxed); + let next = self.increment(head); + if next == self.tail().load(Ordering::Acquire) { + return false; + } + *self.slot_ptr(head as usize) = value; + self.head().store(next, Ordering::Release); + } + true + } + + /// Consumer side: dequeue an index into `out`. Returns false if the + /// buffer is empty. + /// + /// # Safety + /// Exactly one consumer may call this concurrently with exactly one + /// producer calling [`Self::push`]; the ring must be live. + pub unsafe fn pop(&self, out: &mut u32) -> bool { + unsafe { + let tail = self.tail().load(Ordering::Relaxed); + if tail == self.head().load(Ordering::Acquire) { + return false; + } + *out = *self.slot_ptr(tail as usize); + self.tail().store(self.increment(tail), Ordering::Release); + } + true + } + + /// Approximate number of entries currently queued; may be stale the + /// instant it returns. For metrics/backpressure, not correctness. + /// + /// # Safety + /// The ring must be live. + pub unsafe fn size_approx(&self) -> u32 { + unsafe { + let head = self.head().load(Ordering::Acquire); + let tail = self.tail().load(Ordering::Acquire); + let cap = self.capacity(); + (head + cap - tail) % cap + } + } + + /// Approximate empty check (see [`Self::size_approx`]). + /// + /// # Safety + /// The ring must be live. + pub unsafe fn is_empty_approx(&self) -> bool { + unsafe { self.head().load(Ordering::Acquire) == self.tail().load(Ordering::Acquire) } + } + + #[inline] + fn increment(&self, index: u32) -> u32 { + // `capacity_` is small; this avoids requiring a power-of-two capacity. + unsafe { (index + 1) % self.capacity() } + } + + #[inline] + unsafe fn head(&self) -> &AtomicU32 { + unsafe { &*(self.base as *const AtomicU32) } + } + + #[inline] + unsafe fn tail(&self) -> &AtomicU32 { + unsafe { &*(self.base.add(4) as *const AtomicU32) } + } + + #[inline] + unsafe fn store_capacity(&self, capacity: u32) { + unsafe { *(self.base.add(8) as *mut u32) = capacity }; + } + + #[inline] + unsafe fn slot_ptr(&self, index: usize) -> *mut u32 { + unsafe { self.base.add(Self::HEADER_BYTES + index * 4) as *mut u32 } + } +} + +// --------------------------------------------------------------------------- +// FrameSlotPool +// --------------------------------------------------------------------------- + +/// Pool header written by create() and read back by attach(). Field-for- +/// field the C++ `FrameSlotPool::Header` (offsets: 0,4,8,16,24,32,40; +/// 48 bytes total). +#[repr(C)] +struct PoolHeader { + magic: u32, + slot_count: u32, + slot_data_bytes: u64, + free_ring_offset: u64, + ready_ring_offset: u64, + meta_offset: u64, + data_offset: u64, +} + +const POOL_HEADER_SIZE: usize = 48; + +/// A fixed-size pool of equal-sized frame slots in shared memory with +/// lock-free hand-off — the port of the C++ `FrameSlotPool` +/// (`engine/src/oliveimpl/render/ipc/frameslotpool.{h,cpp}`). +/// +/// One pool models a single direction of frame flow. It does NOT own the +/// memory; it is a view over a mapped [`SharedMemoryRegion`] (or any +/// ABI-identical segment). Lifecycle: the filler `acquire`s a free slot, +/// writes meta + pixels, then `publish`es it; the drainer `consume`s the +/// next ready slot, reads it, and `release`s it back to the free ring. +/// +/// [`FrameSlotPool`] is `Clone` — the clone is another view of the same +/// segment (the C++ `copy()`), useful to hand both sides a handle without +/// owning the mapping twice. +pub struct FrameSlotPool { + /// Segment base. + base: *mut u8, + /// The pool header at `base + 0`. + header: *mut PoolHeader, + /// Free-ring view (filler pops, drainer pushes). + free_ring: SpscRingBuffer, + /// Ready-ring view (filler pushes, drainer pops). + ready_ring: SpscRingBuffer, + /// Metadata array at `base + meta_offset`. + meta: *mut FrameSlotMeta, + /// Pixel data blocks at `base + data_offset`. + data: *mut u8, +} + +// Views into shared memory are safe to share within the process; the rings +// carry their own synchronization. +unsafe impl Send for FrameSlotPool {} +unsafe impl Sync for FrameSlotPool {} + +impl Clone for FrameSlotPool { + fn clone(&self) -> FrameSlotPool { + FrameSlotPool { + base: self.base, + header: self.header, + free_ring: self.free_ring, + ready_ring: self.ready_ring, + meta: self.meta, + data: self.data, + } + } +} + +impl FrameSlotPool { + /// Total bytes a region must provide to back a pool of + /// `slot_count` x `slot_data_bytes` + /// (`FrameSlotPool::bytes_needed`). + pub fn bytes_needed(slot_count: u32, slot_data_bytes: usize) -> usize { + let ring_cap = slot_count + 1; + let mut total = align_up(POOL_HEADER_SIZE, K_ALIGN); + let ring_bytes = align_up(SpscRingBuffer::bytes_needed(ring_cap), K_ALIGN); + total += ring_bytes; // free ring + total += ring_bytes; // ready ring + total += align_up(FRAME_SLOT_META_SIZE * slot_count as usize, K_ALIGN); // metadata + total += align_up(slot_data_bytes, K_ALIGN) * slot_count as usize; // pixel data + total + } + + /// Lay out and initialize a brand-new pool over `mem` (owner side, once). + /// + /// Writes the header, initializes both rings, seeds the free ring with + /// every slot index and zeroes the metadata. `mem` must provide at + /// least [`Self::bytes_needed`] bytes of writable, aligned memory (an + /// mmap-backed segment) and must outlive the returned pool. + /// + /// # Safety + /// `mem` must be a valid, writable, aligned buffer of at least + /// [`Self::bytes_needed`] bytes, not concurrently written during this + /// call. + pub unsafe fn create(mem: *mut u8, slot_count: u32, slot_data_bytes: usize) -> FrameSlotPool { + let ring_cap = slot_count + 1; + let free_off = align_up(POOL_HEADER_SIZE, K_ALIGN); + let ready_off = free_off + align_up(SpscRingBuffer::bytes_needed(ring_cap), K_ALIGN); + let meta_off = ready_off + align_up(SpscRingBuffer::bytes_needed(ring_cap), K_ALIGN); + let data_off = meta_off + align_up(FRAME_SLOT_META_SIZE * slot_count as usize, K_ALIGN); + + let pool = unsafe { + FrameSlotPool { + base: mem, + header: mem as *mut PoolHeader, + free_ring: SpscRingBuffer::create(mem.add(free_off), ring_cap), + ready_ring: SpscRingBuffer::create(mem.add(ready_off), ring_cap), + meta: mem.add(meta_off) as *mut FrameSlotMeta, + data: mem.add(data_off), + } + }; + unsafe { + (*pool.header).magic = FRAMEPOOL_MAGIC; + (*pool.header).slot_count = slot_count; + (*pool.header).slot_data_bytes = slot_data_bytes as u64; + (*pool.header).free_ring_offset = free_off as u64; + (*pool.header).ready_ring_offset = ready_off as u64; + (*pool.header).meta_offset = meta_off as u64; + (*pool.header).data_offset = data_off as u64; + } + // `ptr::write_bytes` counts in elements of T, so cast to bytes. + unsafe { + ptr::write_bytes( + pool.meta as *mut u8, + 0, + slot_count as usize * std::mem::size_of::(), + ); + } + // Seed the free ring with every slot index so the filler can + // acquire() immediately. + for i in 0..slot_count { + unsafe { pool.free_ring.push(i) }; + } + pool + } + + /// Map an existing, already-initialized pool (peer side). + /// + /// Reads the geometry from the in-memory header written by + /// [`Self::create`]; the returned pool reports `is_valid() == false` + /// when the magic does not match. + /// + /// # Safety + /// `mem` must point to a mapped segment that either contains a pool + /// initialized by [`Self::create`] (or an ABI-identical C++ side) or is + /// an arbitrary buffer whose first 4 bytes we must be able to read. + pub unsafe fn attach(mem: *mut u8) -> FrameSlotPool { + if mem.is_null() { + return FrameSlotPool::invalid(); + } + let header = mem as *mut PoolHeader; + // SAFETY: `mem` is a live mapping of at least the header size. + if unsafe { (*header).magic } != FRAMEPOOL_MAGIC { + return FrameSlotPool::invalid(); + } + let pool = unsafe { + FrameSlotPool { + base: mem, + header, + free_ring: SpscRingBuffer::attach(mem.add((*header).free_ring_offset as usize)), + ready_ring: SpscRingBuffer::attach(mem.add((*header).ready_ring_offset as usize)), + meta: mem.add((*header).meta_offset as usize) as *mut FrameSlotMeta, + data: mem.add((*header).data_offset as usize), + } + }; + pool + } + + /// An invalid pool (attach on a non-pool segment). + fn invalid() -> FrameSlotPool { + FrameSlotPool { + base: ptr::null_mut(), + header: ptr::null_mut(), + free_ring: SpscRingBuffer { + base: ptr::null_mut(), + }, + ready_ring: SpscRingBuffer { + base: ptr::null_mut(), + }, + meta: ptr::null_mut(), + data: ptr::null_mut(), + } + } + + /// True when the pool was attached to a segment containing a valid pool + /// header. + pub fn is_valid(&self) -> bool { + !self.header.is_null() + } + + /// Number of slots in the pool (0 for an invalid pool). + pub fn slot_count(&self) -> u32 { + if self.is_valid() { + unsafe { (*self.header).slot_count } + } else { + 0 + } + } + + /// Bytes available in every slot's pixel-data block (0 for invalid). + pub fn slot_data_bytes(&self) -> usize { + if self.is_valid() { + unsafe { (*self.header).slot_data_bytes as usize } + } else { + 0 + } + } + + /// Byte stride between consecutive slot data blocks. + fn slot_stride(&self) -> usize { + align_up(self.slot_data_bytes(), K_ALIGN) + } + + // ---- Filler side ---- + + /// Take ownership of a free slot. Returns false (leaving `index` + /// untouched) if none is free. + /// + /// # Safety + /// The pool must be a valid view of a live segment. + pub unsafe fn acquire(&self, index: &mut u32) -> bool { + unsafe { self.free_ring.pop(index) } + } + + /// Pointer to a slot's pixel data block (`slot_data_bytes` available). + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn slot_data(&self, index: u32) -> *mut u8 { + unsafe { self.data.add(index as usize * self.slot_stride()) } + } + + /// Mutable metadata for a slot. The filler writes this before + /// [`Self::publish`]. The returned pointer addresses shared memory; it + /// is borrowed, not owned. + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn meta(&self, index: u32) -> *mut FrameSlotMeta { + unsafe { self.meta.add(index as usize) } + } + + /// Publish a filled slot to the drainer. Must follow a successful + /// [`Self::acquire`] of `index`. Returns false if the ready ring is + /// full (the filler must then release the slot and retry later). + /// + /// # Safety + /// `index` must be a slot previously acquired and not yet released. + pub unsafe fn publish(&self, index: u32) -> bool { + unsafe { self.ready_ring.push(index) } + } + + // ---- Drainer side ---- + + /// Take the next published slot. Returns false if nothing is ready. + /// + /// # Safety + /// The pool must be a valid view of a live segment. + pub unsafe fn consume(&self, index: &mut u32) -> bool { + unsafe { self.ready_ring.pop(index) } + } + + /// Return a consumed slot to the free pool for reuse. Must follow a + /// successful [`Self::consume`] of `index`. Returns false if the free + /// ring is full (the drainer must not release the slot yet). + /// + /// # Safety + /// `index` must be a slot previously consumed and not yet re-acquired. + pub unsafe fn release(&self, index: u32) -> bool { + unsafe { self.free_ring.push(index) } + } + + /// Immutable metadata for a slot (drainer side). + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn meta_const(&self, index: u32) -> *const FrameSlotMeta { + unsafe { self.meta.add(index as usize) } + } + + /// Immutable pixel data for a slot. + /// + /// # Safety + /// `index` must be in `0..slot_count`; the pool must be a valid view of + /// a live segment. + pub unsafe fn slot_data_const(&self, index: u32) -> *const u8 { + unsafe { self.data.add(index as usize * self.slot_stride()) } + } +} + +// --------------------------------------------------------------------------- +// SharedMemoryRegion +// --------------------------------------------------------------------------- + +/// A named, fixed-size POSIX shared-memory segment mapped into the process +/// address space — the port of the C++ `SharedMemoryRegion` +/// (`engine/render/ipc/sharedmemoryregion.cpp`). +/// +/// One process opens the segment in [`ShmMode::Create`] (owner: fails if +/// the name already exists, zeroes the mapping, unlinks on close); the +/// peer opens the same key in [`ShmMode::Attach`]. The mapping is a raw +/// contiguous byte range; the ring buffers and frame slot pools are laid +/// out inside it. Nothing here is locked — synchronization is entirely the +/// caller's responsibility via the lock-free structures placed in the +/// mapping. +/// +/// **M15 S1 shm spike (macOS):** POSIX `shm_open` + `ftruncate` was +/// verified to back single segments of at least 512 MiB (spiked to 1 GiB) +/// on macOS — the SysV `kern.sysv.shmmax` sysctl (4 MiB default) does NOT +/// constrain POSIX shm, and no `kern.posix.shm.*` size cap applied. The +/// designed 8-slot x 8.3 MiB (BGRA8 1080p) per-worker segments therefore +/// need no temp-file+`mmap(MAP_SHARED)` fallback backend on macOS; the +/// `region_shm_spike_512mb_segment` test below is the regression gate +/// (Linux POSIX shm is unconstrained the same way). +pub struct SharedMemoryRegion { + /// The key the region was opened with (no leading slash). + key: String, + /// Requested mapping size in bytes. + size: usize, + /// The mmap'd data pointer; null when invalid. + data: *mut u8, + /// File descriptor from `shm_open` (-1 when invalid). + fd: i32, + /// Open mode. + mode: ShmMode, + /// Human-readable reason of the last failed open. + error: String, + /// The platform-prefixed name actually passed to `shm_open`. + shm_name: String, +} + +impl SharedMemoryRegion { + /// An empty (invalid) region. + pub fn new() -> SharedMemoryRegion { + SharedMemoryRegion { + key: String::new(), + size: 0, + data: ptr::null_mut(), + fd: -1, + mode: ShmMode::Attach, + error: String::new(), + shm_name: String::new(), + } + } + + /// Build a unique segment key for a worker, e.g. + /// "olive-rw--" (`SharedMemoryRegion::make_key`). + /// Centralized so the owner and the spawned worker agree on the same + /// name. + pub fn make_key(owner_pid: i64, worker_index: i32) -> String { + format!("olive-rw-{owner_pid}-{worker_index}") + } + + /// Best-effort unlink of the segment named by `key` (the same naming + /// as [`Self::open`]). Used by the creator side to clear a stale + /// segment left behind by a crashed previous owner before re-creating + /// it (M15 crash restart). The mapping of a peer still holding the + /// segment stays valid — POSIX unlinks only remove the name. + pub fn unlink_key(key: &str) { + let shm_name = format!("/{}", key.replace('/', "_")); + if let Ok(name_c) = std::ffi::CString::new(shm_name) { + // SAFETY: a NUL-terminated name; unlink is safe whether or not + // the segment exists (ENOENT is ignored by the caller). + unsafe { libc::shm_unlink(name_c.as_ptr()) }; + } + } + + /// Open the segment identified by `key` with the given `size` in bytes. + /// + /// `key` is a short identifier (no leading slash needed; the platform + /// prefix is added internally). Returns true on success; on failure + /// [`Self::error`] carries a human-readable reason. An existing region + /// is closed first. + pub fn open(&mut self, key: &str, size: usize, mode: ShmMode) -> bool { + self.close(); + self.key = key.to_string(); + self.size = size; + self.mode = mode; + + // POSIX shared-memory names must start with a single slash and + // contain no others. + let shm_name = format!("/{}", key.replace('/', "_")); + let name_c = match std::ffi::CString::new(shm_name.clone()) { + Ok(c) => c, + Err(_) => { + self.error = format!("invalid shm key {key:?} (contains NUL)"); + return false; + } + }; + self.shm_name = shm_name; + + let mut oflag = libc::O_RDWR; + if mode == ShmMode::Create { + oflag |= libc::O_CREAT | libc::O_EXCL; + // Clear any stale segment left by a crashed previous run with + // the same name. + unsafe { libc::shm_unlink(name_c.as_ptr()) }; + } + + let fd = unsafe { libc::shm_open(name_c.as_ptr(), oflag, 0o600) }; + if fd < 0 { + self.error = format!( + "shm_open({}) failed: {}", + self.shm_name, + std::io::Error::last_os_error() + ); + return false; + } + self.fd = fd; + + if mode == ShmMode::Create { + if unsafe { libc::ftruncate(fd, size as libc::off_t) } != 0 { + self.error = format!("ftruncate failed: {}", std::io::Error::last_os_error()); + self.close(); + return false; + } + } else { + // mmap() succeeds even beyond the real segment size and only + // faults (SIGBUS) on access, so verify the segment is large + // enough up front. + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } != 0 { + self.error = format!("fstat failed: {}", std::io::Error::last_os_error()); + self.close(); + return false; + } + if (st.st_size as usize) < size { + self.error = format!( + "shared memory segment is {} bytes, smaller than the requested {}", + st.st_size, size + ); + self.close(); + return false; + } + } + + let data = unsafe { + libc::mmap( + ptr::null_mut(), + size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + 0, + ) + }; + if data == libc::MAP_FAILED { + self.error = format!("mmap failed: {}", std::io::Error::last_os_error()); + self.close(); + return false; + } + self.data = data as *mut u8; + self.error.clear(); + + if mode == ShmMode::Create { + unsafe { ptr::write_bytes(self.data, 0, size) }; + } + true + } + + /// Unmap and (if owner) unlink the segment. Also called by `Drop`. + pub fn close(&mut self) { + if !self.data.is_null() { + unsafe { libc::munmap(self.data as *mut std::ffi::c_void, self.size) }; + self.data = ptr::null_mut(); + } + if self.fd >= 0 { + unsafe { libc::close(self.fd) }; + self.fd = -1; + } + if self.mode == ShmMode::Create && !self.shm_name.is_empty() { + // Only the owner unlinks, so the name is freed once both sides + // have unmapped. + if let Ok(c) = std::ffi::CString::new(self.shm_name.clone()) { + unsafe { libc::shm_unlink(c.as_ptr()) }; + } + self.shm_name.clear(); + } + self.size = 0; + } + + /// True when the region holds a live mapping. + pub fn is_valid(&self) -> bool { + !self.data.is_null() + } + + /// The mapped data pointer (null when invalid). + pub fn data(&self) -> *mut u8 { + self.data + } + + /// The mapping size in bytes. + pub fn size(&self) -> usize { + self.size + } + + /// The key the region was opened with. + pub fn key(&self) -> &str { + &self.key + } + + /// Human-readable reason of the last failed open. + pub fn error(&self) -> &str { + &self.error + } +} + +impl Default for SharedMemoryRegion { + fn default() -> Self { + SharedMemoryRegion::new() + } +} + +impl Drop for SharedMemoryRegion { + fn drop(&mut self) { + self.close(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- Control-plane protocol ------------------------------------------ + + #[test] + fn handshake_wire_format_matches_cpp_field_names() { + let hs = HandshakeMsg { + protocol_version: 1, + shm_key: "olive-rw-1234-0-out".into(), + input_shm_key: "".into(), + input_slots: 0, + output_slots: 6, + slot_data_bytes: 4096, + input_slot_data_bytes: 0, + }; + let value = hs.to_json(); + // Key order is not part of the contract (JSON objects; the C++ + // QJsonObject is hash-ordered too), but the names must match the + // C++ serializer exactly. + assert_eq!(value["type"], "handshake"); + assert_eq!(value["protocol_version"], 1); + assert_eq!(value["shm_key"], "olive-rw-1234-0-out"); + assert_eq!(value["input_shm_key"], ""); + assert_eq!(value["input_slots"], 0); + assert_eq!(value["output_slots"], 6); + assert_eq!(value["slot_data_bytes"], 4096); + assert_eq!(value["input_slot_data_bytes"], 0); + // And the serialized line must parse back to the same object. + let round: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&value).unwrap()).unwrap(); + assert_eq!(round, value); + } + + #[test] + fn render_frame_parse_accepts_cpp_field_names() { + let json = r#"{"type":"render_frame","ticket":42,"node":"abcd","time_num":1,"time_den":24,"width":1920,"height":1080,"format":-1,"channels":0,"mode":0,"input_slot":-1,"input_slots":[],"has_color_transform":false,"color_output":"","color_view":"","color_look":""}"#; + let m: RenderFrameMsg = serde_json::from_str(json).unwrap(); + assert_eq!(m.ticket, 42); + assert_eq!(m.node, "abcd"); + assert_eq!(m.time_num, 1); + assert_eq!(m.time_den, 24); + assert_eq!(m.width, 1920); + assert_eq!(m.input_slot, -1); + } + + #[test] + fn render_frame_defaults_on_missing_fields() { + // The C++ parser defaults missing fields (QJsonValue defaults); + // serde(default) mirrors that. + let m: RenderFrameMsg = + serde_json::from_str(r#"{"type":"render_frame","ticket":7}"#).unwrap(); + assert_eq!(m.ticket, 7); + assert_eq!(m.time_den, 0); + assert!(m.node.is_empty()); + assert!(!m.has_color_transform); + } + + #[test] + fn error_message_carries_ticket_only_when_nonzero() { + assert_eq!( + error_message("boom", None), + json!({ "type": "error", "message": "boom" }) + ); + assert_eq!( + error_message("boom", Some(0)), + json!({ "type": "error", "message": "boom" }) + ); + assert_eq!( + error_message("boom", Some(9)), + json!({ "type": "error", "message": "boom", "ticket": 9 }) + ); + } + + #[test] + fn write_message_emits_one_json_line() { + let mut buf = Vec::new(); + write_message(&mut buf, &json!({ "type": "shutdown" })).unwrap(); + assert_eq!(String::from_utf8(buf).unwrap(), "{\"type\":\"shutdown\"}\n"); + } + + // ---- Protocol v2 messages (M15 S1) --------------------------------- + + #[test] + fn hello_caps_wire_roundtrip() { + let caps = HelloCapsMsg { + protocol_version: 1, + formats: vec![4, SLOT_FORMAT_BGRA8], + max_slot_bytes: 8_294_400, + }; + let line = json!({ + "type": TYPE_HELLO_CAPS, + "protocol_version": 1, + "formats": [4, SLOT_FORMAT_BGRA8], + "max_slot_bytes": 8_294_400i64, + }); + // Parse the wire line (the `"type"` field is tolerated by serde's + // default behavior of ignoring unknown fields). + let parsed: HelloCapsMsg = serde_json::from_value(line.clone()).unwrap(); + assert_eq!(parsed, caps); + // And the field names are the canonical wire names. + let value = serde_json::to_value(&caps).unwrap(); + assert_eq!(value["protocol_version"], 1); + assert_eq!(value["formats"], json!([4, SLOT_FORMAT_BGRA8])); + assert_eq!(value["max_slot_bytes"], 8_294_400i64); + } + + #[test] + fn render_batch_wire_roundtrip() { + let batch = RenderBatchMsg { + batch_id: 7, + tickets: vec![ + BatchTicketSpec { + ticket: 41, + slot: 0, + time_num: 3, + time_den: 24, + width: 320, + height: 180, + format: SLOT_FORMAT_BGRA8, + channels: 4, + footage_file: "a.mp4".into(), + footage_stream: 0, + montage: vec![], + }, + BatchTicketSpec { + ticket: 42, + slot: 1, + time_num: 4, + time_den: 24, + width: 320, + height: 180, + format: 4, + channels: 4, + footage_file: String::new(), + footage_stream: 0, + montage: vec![WireMontageClip { + filename: "b.mp4".into(), + stream_index: 1, + in_num: 0, + in_den: 24, + out_num: 48, + out_den: 24, + media_in_num: 10, + media_in_den: 24, + gain: 0.5, + }], + }, + ], + }; + let value = serde_json::to_value(&batch).unwrap(); + assert_eq!(value["batch_id"], 7); + assert_eq!(value["tickets"][0]["ticket"], 41); + assert_eq!(value["tickets"][0]["slot"], 0); + assert_eq!(value["tickets"][0]["format"], SLOT_FORMAT_BGRA8); + assert_eq!(value["tickets"][1]["montage"][0]["filename"], "b.mp4"); + assert_eq!(value["tickets"][1]["montage"][0]["media_in_num"], 10); + // Round-trip back to the struct. + let round: RenderBatchMsg = serde_json::from_value(value).unwrap(); + assert_eq!(round, batch); + // Defaults: a bare ticket parses (missing fields default). + let bare: BatchTicketSpec = + serde_json::from_str(r#"{"ticket":1,"slot":2}"#).unwrap(); + assert_eq!(bare.ticket, 1); + assert_eq!(bare.slot, 2); + assert_eq!(bare.format, 0); + assert!(bare.montage.is_empty()); + } + + #[test] + fn batch_accepted_and_frame_failed_wire_roundtrip() { + let accepted = BatchAcceptedMsg { + batch_id: 9, + tickets: vec![1, 2, 3], + }; + let value = serde_json::to_value(&accepted).unwrap(); + assert_eq!(value["batch_id"], 9); + assert_eq!(value["tickets"], json!([1, 2, 3])); + let round: BatchAcceptedMsg = serde_json::from_value(value).unwrap(); + assert_eq!(round, accepted); + + let failed = FrameFailedMsg { + ticket: 12, + error: "decode failed".into(), + }; + let value = serde_json::to_value(&failed).unwrap(); + assert_eq!(value["ticket"], 12); + assert_eq!(value["error"], "decode failed"); + let round: FrameFailedMsg = serde_json::from_value(value).unwrap(); + assert_eq!(round, failed); + // Wire line with the type tag parses too. + let tagged: FrameFailedMsg = serde_json::from_value(json!({ + "type": TYPE_FRAME_FAILED, + "ticket": 12, + "error": "decode failed", + })) + .unwrap(); + assert_eq!(tagged, failed); + } + + #[test] + fn v2_type_constants_are_stable_wire_names() { + assert_eq!(TYPE_HELLO_CAPS, "hello_caps"); + assert_eq!(TYPE_RENDER_BATCH, "render_batch"); + assert_eq!(TYPE_BATCH_ACCEPTED, "batch_accepted"); + assert_eq!(TYPE_FRAME_FAILED, "frame_failed"); + assert_eq!(TYPE_SHUTDOWN, "shutdown"); + // BGRA8 slot format stays outside the PixelFormat enum range. + assert_eq!(SLOT_FORMAT_BGRA8, 100); + } + + // ---- Shared-memory transport ----------------------------------------- + + /// A unique, temporary POSIX segment key for a test (pid + counter), so + /// parallel test runs never collide. + fn test_key(name: &str) -> String { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + SharedMemoryRegion::make_key(i64::from(std::process::id()), (n & 0x7FFF) as i32) + + &format!("-{name}") + } + + /// Create one segment and map it a second time — the in-process + /// equivalent of two processes sharing a segment. Returns + /// `(owner_region, peer_region)`; both must be kept alive for the + /// whole test (the peer is an attach that does not unlink). + fn two_mappings(key: &str, size: usize) -> (SharedMemoryRegion, SharedMemoryRegion) { + let mut owner = SharedMemoryRegion::new(); + assert!( + owner.open(key, size, ShmMode::Create), + "create failed: {}", + owner.error() + ); + let mut peer = SharedMemoryRegion::new(); + assert!( + peer.open(key, size, ShmMode::Attach), + "attach failed: {}", + peer.error() + ); + (owner, peer) + } + + // ---- SpscRingBuffer ------------------------------------------------- + + #[test] + fn ring_bytes_needed_matches_cpp_layout() { + // 12 header bytes + capacity * 4. + assert_eq!(SpscRingBuffer::bytes_needed(4), 12 + 16); + assert_eq!(SpscRingBuffer::bytes_needed(5), 12 + 20); + assert_eq!(SpscRingBuffer::bytes_needed(0), 12); + } + + #[test] + fn ring_empty_full_and_single_entry() { + let key = test_key("ring-empty"); + let size = SpscRingBuffer::bytes_needed(4); + let (owner, peer) = two_mappings(&key, size); + // SAFETY: both mappings are live and at least `size` bytes. + let prod = unsafe { SpscRingBuffer::create(owner.data(), 4) }; + let cons = unsafe { SpscRingBuffer::attach(peer.data()) }; + + assert!(unsafe { cons.is_empty_approx() }); + let mut v = 99; + assert!(!unsafe { cons.pop(&mut v) }); + assert_eq!(v, 99); + + assert!(unsafe { prod.push(7) }); + assert!(!unsafe { cons.is_empty_approx() }); + assert_eq!(unsafe { cons.size_approx() }, 1); + assert!(unsafe { cons.pop(&mut v) }); + assert_eq!(v, 7); + assert!(unsafe { cons.is_empty_approx() }); + } + + #[test] + fn ring_capacity_minus_one_live_entries() { + // A ring of capacity N holds at most N-1 entries (one slot is + // always left empty to tell full from empty). + let key = test_key("ring-cap"); + let size = SpscRingBuffer::bytes_needed(4); + let (owner, peer) = two_mappings(&key, size); + // SAFETY: live mappings. + let prod = unsafe { SpscRingBuffer::create(owner.data(), 4) }; + let cons = unsafe { SpscRingBuffer::attach(peer.data()) }; + + for i in 0..3 { + assert!(unsafe { prod.push(i) }); + } + // The 4th push must fail: head would collide with tail. + assert!(!unsafe { prod.push(99) }); + + let mut v = 0; + for expected in 0..3 { + assert!(unsafe { cons.pop(&mut v) }); + assert_eq!(v, expected); + } + assert!(!unsafe { cons.pop(&mut v) }); + } + + #[test] + fn ring_wraparound_preserves_order() { + // Fill, drain, then wrap past the end of the slot array: cursors + // are modulo-capacity, order must be preserved across the wrap. + let key = test_key("ring-wrap"); + let size = SpscRingBuffer::bytes_needed(4); + let (owner, peer) = two_mappings(&key, size); + // SAFETY: live mappings. + let prod = unsafe { SpscRingBuffer::create(owner.data(), 4) }; + let cons = unsafe { SpscRingBuffer::attach(peer.data()) }; + + for i in 0..3 { + assert!(unsafe { prod.push(i) }); + } + let mut v = 0; + for _ in 0..3 { + assert!(unsafe { cons.pop(&mut v) }); + } + // Ring is empty again; push past the wrap point. + for i in 3..6 { + assert!(unsafe { prod.push(i) }); + } + for expected in 3..6 { + assert!(unsafe { cons.pop(&mut v) }); + assert_eq!(v, expected); + } + } + + // ---- FrameSlotPool -------------------------------------------------- + + #[test] + fn framepool_bytes_needed_matches_cpp_offsets() { + // Recompute by hand with the C++ layout: header 64, each ring + // align_up(12 + 4*(n+1), 64), meta align_up(176*n, 64), data + // align_up(slot_bytes, 64) * n. + let check = |n: u32, slot: usize| { + let ring = align_up(12 + 4 * (n as usize + 1), 64); + let expected = + 64 + ring + ring + align_up(176 * n as usize, 64) + align_up(slot, 64) * n as usize; + assert_eq!(FrameSlotPool::bytes_needed(n, slot), expected); + }; + check(4, 4096); + check(6, 1_000_000); + check(1, 64); + check(3, 100); + } + + #[test] + fn framepool_create_attach_two_processes_both_directions() { + // "Two processes": two mappings of the same segment. Owner creates + // the pool; the peer attaches. A filler on one side and a drainer + // on the other exchange slots in both directions. + let key = test_key("pool-bidi"); + let slots = 4u32; + let slot_bytes = 64usize; + let size = FrameSlotPool::bytes_needed(slots, slot_bytes); + let (owner, peer) = two_mappings(&key, size); + + // SAFETY: both mappings are live and sized by bytes_needed. + let filler = unsafe { FrameSlotPool::create(owner.data(), slots, slot_bytes) }; + let drainer = unsafe { FrameSlotPool::attach(peer.data()) }; + + assert!(filler.is_valid()); + assert!(drainer.is_valid()); + assert_eq!(drainer.slot_count(), slots); + assert_eq!(drainer.slot_data_bytes(), slot_bytes); + + // Filler acquires every slot exactly once (seeded free ring), then + // the free ring is empty. + let mut got = Vec::new(); + for _ in 0..slots { + let mut s = 0; + assert!(unsafe { filler.acquire(&mut s) }); + got.push(s); + } + got.sort_unstable(); + assert_eq!(got, vec![0, 1, 2, 3]); + let mut extra = 0; + assert!(!unsafe { filler.acquire(&mut extra) }); + // Drainer sees nothing ready yet. + assert!(!unsafe { drainer.consume(&mut extra) }); + + // Filler writes pixels + meta into two slots and publishes them. + for (i, slot) in [0u32, 2u32].iter().enumerate() { + // SAFETY: `slot` was acquired above. + let data = unsafe { filler.slot_data(*slot) }; + unsafe { ptr::write_bytes(data, (i * 40 + 1) as u8, slot_bytes) }; + // SAFETY: slot in range. + let meta = unsafe { &mut *filler.meta(*slot) }; + meta.id = 100 + *slot as i64; + meta.width = 8; + meta.height = 8; + meta.data_size = slot_bytes as i32; + assert!(unsafe { filler.publish(*slot) }); + } + + // Drainer consumes them through its own mapping and sees the same + // payloads and metadata. + let mut consumed = Vec::new(); + for _ in 0..2 { + let mut s = 0; + assert!(unsafe { drainer.consume(&mut s) }); + // SAFETY: s was consumed. + let data = unsafe { drainer.slot_data_const(s) }; + let meta = unsafe { &*drainer.meta_const(s) }; + assert_eq!(meta.id, 100 + s as i64); + assert_eq!(meta.width, 8); + assert_eq!(meta.data_size, slot_bytes as i32); + // SAFETY: slot_bytes readable in the slot block. + let first = unsafe { *data }; + assert_eq!(first, ((s as usize / 2) * 40 + 1) as u8); + consumed.push(s); + } + consumed.sort_unstable(); + assert_eq!(consumed, vec![0, 2]); + assert!(!unsafe { drainer.consume(&mut extra) }); + + // Drainer releases the slots back; the filler can acquire them + // again — the full round trip through both rings. + for s in consumed { + assert!(unsafe { drainer.release(s) }); + } + let mut s = 0; + assert!(unsafe { filler.acquire(&mut s) }); + assert_eq!(s, 0); + } + + #[test] + fn framepool_wraparound_and_full_edges() { + // Small pool: cycle every slot many times, verifying the rings' + // modulo behavior end to end. + let key = test_key("pool-wrap"); + let slots = 3u32; + let slot_bytes = 32usize; + let size = FrameSlotPool::bytes_needed(slots, slot_bytes); + let (owner, peer) = two_mappings(&key, size); + + // SAFETY: live mappings. + let filler = unsafe { FrameSlotPool::create(owner.data(), slots, slot_bytes) }; + let drainer = unsafe { FrameSlotPool::attach(peer.data()) }; + + for cycle in 0..4u32 { + let mut published = Vec::new(); + for _ in 0..slots { + let mut s = 0; + assert!(unsafe { filler.acquire(&mut s) }, "cycle {cycle}"); + // SAFETY: acquired slot. + unsafe { ptr::write_bytes(filler.slot_data(s), cycle as u8, slot_bytes) }; + // SAFETY: slot in range. + let meta = unsafe { &mut *filler.meta(s) }; + meta.id = i64::from(cycle * 100 + s); + assert!(unsafe { filler.publish(s) }); + published.push(s); + } + // Pool is full on the filler side. + let mut x = 0; + assert!(!unsafe { filler.acquire(&mut x) }); + + // Drain everything on the drainer side. + let mut consumed = Vec::new(); + for _ in 0..slots { + let mut s = 0; + assert!(unsafe { drainer.consume(&mut s) }); + // SAFETY: consumed slot. + let meta = unsafe { &*drainer.meta_const(s) }; + assert_eq!(meta.id, i64::from(cycle * 100 + s)); + // SAFETY: 1 byte readable. + assert_eq!(unsafe { *drainer.slot_data_const(s) }, cycle as u8); + consumed.push(s); + } + assert!(!unsafe { drainer.consume(&mut x) }); + consumed.sort_unstable(); + assert_eq!(consumed, vec![0, 1, 2]); + + for s in consumed { + assert!(unsafe { drainer.release(s) }); + } + } + } + + #[test] + fn framepool_attach_rejects_wrong_magic() { + let key = test_key("pool-badmagic"); + let size = FrameSlotPool::bytes_needed(2, 16); + let (owner, _peer) = two_mappings(&key, size); + // Overwrite the header area with garbage — no pool magic. + // SAFETY: owner mapping is live. + unsafe { ptr::write_bytes(owner.data(), 0xAB, 64) }; + // SAFETY: buffer is live. + let pool = unsafe { FrameSlotPool::attach(owner.data()) }; + assert!(!pool.is_valid()); + assert_eq!(pool.slot_count(), 0); + assert_eq!(pool.slot_data_bytes(), 0); + } + + #[test] + fn framepool_pool_over_reused_segment_is_consistent() { + // A pool that has been cycled fully and then attached fresh reports + // the same geometry as bytes_needed computed it. + let key = test_key("pool-geometry"); + let slots = 5u32; + let slot_bytes = 1000usize; + let size = FrameSlotPool::bytes_needed(slots, slot_bytes); + let (owner, peer) = two_mappings(&key, size); + // SAFETY: live mappings. + let _ = unsafe { FrameSlotPool::create(owner.data(), slots, slot_bytes) }; + let attached = unsafe { FrameSlotPool::attach(peer.data()) }; + assert!(attached.is_valid()); + assert_eq!(attached.slot_count(), slots); + assert_eq!(attached.slot_data_bytes(), slot_bytes); + // Slot stride is 64-aligned (matches the C++ data layout). + // SAFETY: valid pool. + let s0 = unsafe { attached.slot_data(0) }; + let s1 = unsafe { attached.slot_data(1) }; + assert_eq!(s1 as usize - s0 as usize, align_up(slot_bytes, K_ALIGN)); + } + + // ---- SharedMemoryRegion --------------------------------------------- + + #[test] + fn region_create_attach_write_visibility() { + let key = test_key("region-vis"); + let size = 4096usize; + let (mut owner, mut peer) = two_mappings(&key, size); + assert!(owner.is_valid()); + assert!(peer.is_valid()); + assert_eq!(owner.size(), size); + assert_eq!(peer.size(), size); + assert_eq!(owner.key(), key); + assert_eq!(peer.key(), key); + + // Owner writes; peer sees it through its own mapping. + // SAFETY: both mappings are live with `size` bytes. + unsafe { + let dst = owner.data() as *mut u32; + *dst = 0xDEADBEEF; + } + // SAFETY: peer mapping live. + let seen = unsafe { *(peer.data() as *const u32) }; + assert_eq!(seen, 0xDEADBEEF); + + // Peer writes back; owner sees it. + // SAFETY: peer mapping live. + unsafe { + let dst = peer.data() as *mut u32; + *dst = 0x12345678; + } + // SAFETY: owner mapping live. + assert_eq!(unsafe { *(owner.data() as *const u32) }, 0x12345678); + + // Closing the ATTACH side does not unlink: while the owner lives, + // a third mapping can still open the name. + peer.close(); + assert!(!peer.is_valid()); + let mut third = SharedMemoryRegion::new(); + assert!(third.open(&key, size, ShmMode::Attach), "{}", third.error()); + assert!(third.is_valid()); + third.close(); + + // Closing the OWNER unlinks the segment; further attaches fail. + owner.close(); + assert!(!owner.is_valid()); + let mut fourth = SharedMemoryRegion::new(); + assert!(!fourth.open(&key, size, ShmMode::Attach)); + } + + #[test] + fn region_create_replaces_stale_segment() { + // Mirrors the C++: Create unlinks any stale segment with the same + // name first (crash cleanup), so a second Create SUCCEEDS and owns + // a fresh, zeroed segment. + let key = test_key("region-exists"); + let size = 128usize; + let (owner, _peer) = two_mappings(&key, size); + assert!(owner.is_valid()); + // SAFETY: owner mapping live. + unsafe { *(owner.data() as *mut u32) = 0xCAFEBABE }; + + let mut second = SharedMemoryRegion::new(); + assert!( + second.open(&key, size, ShmMode::Create), + "{}", + second.error() + ); + assert!(second.is_valid()); + // The replacement segment is fresh (zeroed by create). + // SAFETY: second mapping live. + assert_eq!(unsafe { *(second.data() as *const u32) }, 0); + } + + #[test] + fn region_attach_fails_when_segment_too_small() { + // macOS rounds shm segment sizes up to a 16 KiB minimum, so use + // sizes above that to exercise the size check. + let key = test_key("region-small"); + let (owner, _peer) = two_mappings(&key, 4096); + assert!(owner.is_valid()); + + // Attaching with a larger size than the segment must fail (the + // fstat check, mirroring the C++). + let mut big = SharedMemoryRegion::new(); + assert!(!big.open(&key, 65536, ShmMode::Attach)); + assert!(!big.is_valid()); + assert!(!big.error().is_empty()); + } + + #[test] + fn region_make_key_format() { + assert_eq!(SharedMemoryRegion::make_key(4242, 3), "olive-rw-4242-3"); + assert_eq!(SharedMemoryRegion::make_key(1, 0), "olive-rw-1-0"); + } + + #[test] + fn region_shm_spike_512mb_segment() { + // M15 S1 shm spike: one POSIX shm segment must ftruncate to at + // least 512 MiB (the designed per-worker pool is 8 slots x ~8.3 MB + // BGRA8 1080p ≈ 66 MB, but large F32 4K pools reach the hundreds + // of MB). Verified on macOS: POSIX shm is NOT bounded by the SysV + // shmmax sysctl; segments up to 1 GiB spiked fine, so no + // temp-file+mmap fallback backend is needed. This test is the + // regression gate; a failure here means the platform needs the + // fallback backend inside `SharedMemoryRegion::open`. + let key = test_key("spike-512mb"); + let size = 512usize * 1024 * 1024; + let mut owner = SharedMemoryRegion::new(); + assert!( + owner.open(&key, size, ShmMode::Create), + "512 MiB shm segment must be creatable: {}", + owner.error() + ); + assert!(owner.is_valid()); + assert_eq!(owner.size(), size); + // Touch the first and last byte of the mapping: an overcommitted + // segment would SIGBUS here. + // SAFETY: owner mapping is live with `size` bytes. + unsafe { + *owner.data() = 0xA5; + *owner.data().add(size - 1) = 0x5A; + } + let mut peer = SharedMemoryRegion::new(); + assert!( + peer.open(&key, size, ShmMode::Attach), + "512 MiB shm segment must be attachable: {}", + peer.error() + ); + // SAFETY: peer mapping is live with `size` bytes. + unsafe { + assert_eq!(*peer.data(), 0xA5); + assert_eq!(*peer.data().add(size - 1), 0x5A); + } + peer.close(); + owner.close(); + } + + #[test] + fn region_keys_are_isolation_safe() { + // Keys with slashes are flattened to a single-slash POSIX name. + let key = "a/b/c"; + let size = 64usize; + let (owner, peer) = two_mappings(key, size); + assert!(owner.is_valid()); + assert!(peer.is_valid()); + // The actual POSIX name is "/a_b_c". + // SAFETY: mapping live. + unsafe { *(owner.data() as *mut u32) = 7 }; + // SAFETY: peer mapping live. + assert_eq!(unsafe { *(peer.data() as *const u32) }, 7); + } +} diff --git a/crates/oakrender/src/lib.rs b/crates/oakrender/src/lib.rs index bde4899e6..4c62abf3e 100644 --- a/crates/oakrender/src/lib.rs +++ b/crates/oakrender/src/lib.rs @@ -32,6 +32,9 @@ //! - `backend` — wgpu GPU context + display renderer //! - `copier` — render-side project-copy client (oaknode C ABI) //! - `cancelatom` — the cancellation primitive +//! - `ipc` — render-worker NDJSON protocol + shm frame-slot transport +//! - `scheduler` — preview frame scheduler (interleaved batch claims) +//! - `procpool` — process-isolated render backend (M15) //! - `bridge` — direct-call C ABI bridges (oakcommon/oaknode/oakcodec) //! - `ffi` — the `include/render/*.h` export layer @@ -49,7 +52,10 @@ pub mod error; pub mod eval; pub mod frame; pub mod handle; +pub mod ipc; pub mod manager; +pub mod procpool; +pub mod scheduler; pub mod texture; pub mod ticket; pub mod worker; diff --git a/crates/oakrender/src/manager.rs b/crates/oakrender/src/manager.rs index 38166103d..2688bba10 100644 --- a/crates/oakrender/src/manager.rs +++ b/crates/oakrender/src/manager.rs @@ -31,8 +31,9 @@ use crate::autocacher::PreviewAutoCacher; use crate::backend::BackendKind; use crate::error::{Error, Result}; use crate::eval; +use crate::procpool::{DispatcherConfig, ProcessDispatcher}; use crate::ticket::{TicketArena, TicketId}; -use crate::worker::WorkerPool; +use crate::worker::{JobDispatch, WorkerPool}; static MANAGER: Mutex>> = Mutex::new(None); @@ -40,11 +41,24 @@ fn lock(m: &Mutex) -> MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } +/// The render backend the manager initializes (M15 S1: the thread pool +/// and the process-isolated dispatcher coexist; S2 makes Processes the +/// default and removes the pool). +pub enum RenderBackendChoice { + /// In-process thread pool (the default; C++ parity). + Threads, + /// Process-isolated oak-worker pool (crash isolation + shm frames). + Processes(DispatcherConfig), +} + /// The manager. Created by `oakrender_manager_init` (C ABI), accessed /// internally through [`RenderManager::global`]. pub struct RenderManager { - /// Worker pool. - pub pool: WorkerPool, + /// Video job dispatch (thread pool or process dispatcher, M15). + pub dispatch: Arc, + /// Audio job dispatch — kept on main-process threads until S3 + /// (design §3.7: crash risk is dominated by video plugins). + pub audio_dispatch: Arc, /// Ticket arena. pub tickets: Arc, /// Active GPU backend. @@ -59,23 +73,51 @@ pub struct RenderManager { } impl RenderManager { - /// Initialize the process-wide manager (idempotent; C++ instance() - /// semantics — only the main GUI process does this). + /// Initialize the process-wide manager with the default backend + /// (in-process threads; idempotent; C++ instance() semantics — only + /// the main GUI process does this). pub fn init() -> Result<()> { + Self::init_with_backend(RenderBackendChoice::Threads) + } + + /// Initialize the process-wide manager with an explicit backend + /// (M15 S1: `Threads` keeps the C++ parity path, `Processes` spawns + /// the oak-worker pool). + pub fn init_with_backend(choice: RenderBackendChoice) -> Result<()> { let mut guard = lock(&MANAGER); if guard.is_some() { return Err(Error::State); } let backend = BackendKind::from_user_config(); - let mut pool = WorkerPool::new(0); - pool.start(); let producer: crate::ticket::Producer = Arc::new(|time, params| { eval::render_produced_frame(time, params) .map(crate::ticket::TicketPayload::Video) }); - let tickets = Arc::new(TicketArena::new(pool.clone(), producer)); + let (dispatch, audio_dispatch): (Arc, Arc) = + match choice { + RenderBackendChoice::Threads => { + let mut pool = WorkerPool::new(0); + pool.start(); + let pool = Arc::new(pool); + (pool.clone(), pool) + } + RenderBackendChoice::Processes(config) => { + let dispatcher = ProcessDispatcher::new(config)?; + dispatcher.start()?; + // Audio stays on main-process threads (design §3.7). + let mut audio = WorkerPool::new(2); + audio.start(); + (dispatcher, Arc::new(audio)) + } + }; + let tickets = Arc::new(TicketArena::new_with_audio( + dispatch.clone(), + audio_dispatch.clone(), + producer, + )); *guard = Some(Arc::new(RenderManager { - pool, + dispatch, + audio_dispatch, tickets, backend, requested_backend: backend, @@ -99,15 +141,16 @@ impl RenderManager { guard } - /// Shut down: cancel tickets, drain pool, release backend. + /// Shut down: cancel tickets, drain both dispatch backends. pub fn shutdown() { let manager = lock(&MANAGER).take(); if let Some(manager) = manager { manager.tickets.cancel_all(); - // Drop the manager (releases the pool clone) after the pool is - // drained; the drain delivers queued completions. - let mut pool = manager.pool.clone(); - pool.shutdown(); + // Drain after the cancels so queued completions fire. Both + // dispatches are idempotent (the Threads backend shares one + // Arc for video + audio). + manager.dispatch.shutdown(); + manager.audio_dispatch.shutdown(); drop(manager); } } diff --git a/crates/oakrender/src/procpool.rs b/crates/oakrender/src/procpool.rs new file mode 100644 index 000000000..0a7b86117 --- /dev/null +++ b/crates/oakrender/src/procpool.rs @@ -0,0 +1,1337 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The process-isolated render backend (M15 S1): the main-process side +//! of the oak-worker pool — spawn, handshake, NDJSON control, shared +//! memory creation, crash detection and restart, and the ticket-facing +//! [`JobDispatch`] implementation (design doc §3.1–§3.4). +//! +//! ```text +//! TicketArena --Job--> ProcessDispatcher +//! | scheduler.claim_batch (interleaved shards) +//! v +//! WorkerHandle x N ---- stdio NDJSON (control plane) +//! shm segment render_batch { tickets, slots } +//! FrameSlotPool <---> oak-worker process +//! | +//! frame_ready(ticket, slot) +//! v +//! Completion(Ok(TicketPayload::ShmFrame(ShmFrameRef))) +//! ``` +//! +//! Model: +//! - **Single-threaded control plane.** All dispatcher state lives in +//! one mutex-guarded [`Inner`] pumped by [`ProcessDispatcher::poll`] +//! (non-blocking try_recv + try_wait). The mutex guards control +//! structures only — frame bytes never pass through it: workers +//! write pixels straight into the shm slots and consumers read them +//! from the mapping via [`ShmFrameRef`] (zero copy; the only +//! counted copy path is [`ShmRegionView::slot_to_vec`]). +//! - **Slot addressing.** The dispatcher assigns destination slots +//! (main-side addressing, design §3.1); the worker renders into the +//! given slot and publishes it through the ready ring. Free-slot +//! bookkeeping mirrors the free SPSC ring in FIFO order, so the +//! worker's `acquire` always pops exactly the assigned slot. +//! - **Crash isolation.** Stdout EOF or a non-zero exit marks the +//! worker dead: its claimed frames are re-queued to the scheduler +//! (any healthy worker may claim them), the child is reaped, the +//! segment recreated and the process respawned (bounded restarts). +//! - **Coexistence.** S1 keeps the in-process [`crate::worker::WorkerPool`] +//! alive; [`crate::manager::RenderManager`] picks the backend at +//! init. S2 deletes the thread pool. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::io::Write as _; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +use crate::error::{Error, Result}; +use crate::ipc::{ + write_message, BatchAcceptedMsg, FrameFailedMsg, FrameReadyMsg, FrameSlotMeta, FrameSlotPool, + HandshakeMsg, HelloCapsMsg, RenderBatchMsg, BatchTicketSpec, SharedMemoryRegion, ShmMode, + WireMontageClip, SLOT_FORMAT_BGRA8, TYPE_BATCH_ACCEPTED, TYPE_ERROR, TYPE_FRAME_FAILED, + TYPE_FRAME_READY, TYPE_HANDSHAKE, TYPE_HELLO_CAPS, +}; +use crate::scheduler::{FrameKey, FramePriority, FrameRequest, PreviewScheduler}; +use crate::ticket::{Completion, TicketPayload, TicketResult, VideoTicketParams}; +use crate::worker::{Job, JobDispatch}; + +/// Protocol version spoken by the dispatcher (v1 base; v2 messages are +/// additive — the oak-worker handshake check stays `== 1`). +pub const DISPATCH_PROTOCOL_VERSION: i32 = 1; + +/// Restart attempts per worker before its tickets fail permanently. +const MAX_RESTARTS: u32 = 5; + +/// Default slots per worker segment (design §3.1: 8 slots starting). +pub const DEFAULT_SLOTS_PER_WORKER: u32 = 8; + +/// Frame bytes copied into main-process heap buffers. The playback path +/// is zero-copy by construction (completions carry [`ShmFrameRef`]s, +/// never pixel `Vec`s); only [`ShmRegionView::slot_to_vec`] bumps this. +/// Tests assert it stays 0 on the preview path. +static MAIN_FRAME_COPIES: AtomicU64 = AtomicU64::new(0); + +/// The main-process frame-copy counter (zero-copy assertion; design +/// §3.5). +pub fn main_heap_frame_copies() -> u64 { + MAIN_FRAME_COPIES.load(Ordering::Relaxed) +} + +/// Reset the copy counter (tests). +pub fn reset_main_heap_frame_copies() { + MAIN_FRAME_COPIES.store(0, Ordering::Relaxed); +} + +// --------------------------------------------------------------------------- +// ShmRegionView — one worker segment as seen from the main process +// --------------------------------------------------------------------------- + +/// Owned view of the FrameSlotMeta currently in a slot (the shm POD +/// copied out, colorspace as a string). +#[derive(Clone, Debug, PartialEq)] +pub struct ShmFrameMeta { + /// Caller tag (ticket id). + pub id: i64, + /// Frame timestamp numerator. + pub time_num: i64, + /// Frame timestamp denominator. + pub time_den: i64, + /// Frame width. + pub width: i32, + /// Frame height. + pub height: i32, + /// Slot wire format (`PixelFormat` int or [`SLOT_FORMAT_BGRA8`]). + pub format: i32, + /// Channel count. + pub channel_count: i32, + /// Bytes per scanline. + pub linesize: i32, + /// Valid bytes in the slot. + pub data_size: i32, + /// Input colorspace name. + pub colorspace: String, +} + +impl ShmFrameMeta { + fn from_pod(pod: &FrameSlotMeta) -> ShmFrameMeta { + let colorspace = { + // SAFETY: the POD char array is NUL-padded by the worker. + let cstr = unsafe { std::ffi::CStr::from_ptr(pod.colorspace.as_ptr()) }; + cstr.to_string_lossy().into_owned() + }; + ShmFrameMeta { + id: pod.id, + time_num: pod.time_num, + time_den: pod.time_den, + width: pod.width, + height: pod.height, + format: pod.format, + channel_count: pod.channel_count, + linesize: pod.linesize, + data_size: pod.data_size, + colorspace, + } + } +} + +/// A worker's shared-memory segment + frame-slot pool, owned by the +/// main process (creator side). Shared through an `Arc` so delivered +/// [`ShmFrameRef`]s keep the mapping alive across worker restarts. +pub struct ShmRegionView { + region: SharedMemoryRegion, + pool: FrameSlotPool, +} + +// The segment mapping is usable from any local thread; cross-process +// synchronization lives in the rings' atomics. +unsafe impl Send for ShmRegionView {} +unsafe impl Sync for ShmRegionView {} + +impl ShmRegionView { + /// Create (and initialize) a segment of `slots` x `slot_bytes` under + /// `key`. A stale segment under the same name (left by a crashed + /// previous owner) is unlinked and the create retried once. + fn create(key: &str, slots: u32, slot_bytes: usize) -> Result> { + let mut region = SharedMemoryRegion::new(); + let bytes = FrameSlotPool::bytes_needed(slots, slot_bytes); + if !region.open(key, bytes, ShmMode::Create) { + SharedMemoryRegion::unlink_key(key); + if !region.open(key, bytes, ShmMode::Create) { + return Err(Error::Failed(format!( + "create shm segment {key}: {}", + region.error() + ))); + } + } + // SAFETY: `region` is a live mapping of exactly `bytes` bytes. + let pool = unsafe { FrameSlotPool::create(region.data(), slots, slot_bytes) }; + Ok(Arc::new(ShmRegionView { region, pool })) + } + + /// The segment key. + pub fn key(&self) -> &str { + self.region.key() + } + + /// Slot count. + pub fn slot_count(&self) -> u32 { + self.pool.slot_count() + } + + /// Per-slot data capacity. + pub fn slot_data_bytes(&self) -> usize { + self.pool.slot_data_bytes() + } + + /// Zero-copy read of a slot's pixel block (borrowed from the live + /// mapping; valid until this view drops). + pub fn slot_bytes(&self, slot: u32) -> &[u8] { + let len = self.pool.slot_data_bytes(); + // SAFETY: `slot` is in range for the pool's lifetime and the + // mapping outlives &self. + unsafe { std::slice::from_raw_parts(self.pool.slot_data_const(slot), len) } + } + + /// Copy a slot's pixel block into a heap buffer (the one counted + /// copy path — long-term caches that must outlive the slot). + pub fn slot_to_vec(&self, slot: u32) -> Vec { + MAIN_FRAME_COPIES.fetch_add(1, Ordering::Relaxed); + self.slot_bytes(slot).to_vec() + } + + /// The slot's metadata, copied out of shm. + pub fn meta_copy(&self, slot: u32) -> ShmFrameMeta { + // SAFETY: `slot` is in range; the meta POD is fully initialized + // by the pool create/attach. + let pod = unsafe { &*self.pool.meta_const(slot) }; + ShmFrameMeta::from_pod(pod) + } + + /// The pool view (dispatcher ring operations). + pub(crate) fn pool(&self) -> &FrameSlotPool { + &self.pool + } +} + +/// Zero-copy handle to a rendered frame in a worker segment: what a +/// video ticket completion carries on the process backend. No frame +/// bytes travel inside — the consumer reads them from the mapping with +/// [`ShmRegionView::slot_bytes`] and releases the slot through +/// [`ProcessDispatcher::release_frame`] when done (slot release = +/// cache eviction, design §3.1). +#[derive(Clone)] +pub struct ShmFrameRef { + /// Worker index owning the segment. + pub worker: u32, + /// Slot index in that segment. + pub slot: u32, + /// Frame metadata (copied at delivery). + pub meta: ShmFrameMeta, + /// The segment view (keeps the mapping alive). + pub shm: Arc, +} + +impl std::fmt::Debug for ShmFrameRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShmFrameRef") + .field("worker", &self.worker) + .field("slot", &self.slot) + .field("meta", &self.meta) + .finish() + } +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/// Bytes one slot needs for `width` x `height` at a wire format +/// ([`oakcore_rs::PixelFormat`] int or [`SLOT_FORMAT_BGRA8`]). +pub fn slot_bytes_for(width: i32, height: i32, format: i32) -> usize { + let pixels = (width.max(0) as usize).saturating_mul(height.max(0) as usize); + let bytes_per_pixel = if format == SLOT_FORMAT_BGRA8 { + 4 + } else { + let fmt = match format { + 0 => oakcore_rs::PixelFormat::U8, + 1 => oakcore_rs::PixelFormat::U10, + 2 => oakcore_rs::PixelFormat::U16, + 3 => oakcore_rs::PixelFormat::F16, + 4 => oakcore_rs::PixelFormat::F32, + _ => oakcore_rs::PixelFormat::F32, + }; + fmt.bytes_per_channel() * 4 + }; + pixels.saturating_mul(bytes_per_pixel) +} + +/// The worker-count policy (design doc S1 item 3): +/// `max(1, min(logical_cores - 2, memory_budget / per_worker_slots))`, +/// with a memory budget of one quarter of physical RAM. +pub fn default_worker_count(slots_per_worker: u32, slot_bytes: usize) -> usize { + let cores = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + let by_cores = cores.saturating_sub(2).max(1); + let mem = physical_memory_bytes().unwrap_or(8u64 << 30); + let budget = mem / 4; + let per_worker = (slots_per_worker as usize).saturating_mul(slot_bytes).max(1); + let by_mem = (budget as usize / per_worker).max(1); + by_cores.min(by_mem).max(1) +} + +/// Physical memory in bytes (macOS `hw.memsize`, Linux `sysconf`). +fn physical_memory_bytes() -> Option { + #[cfg(target_os = "macos")] + { + let mut size: u64 = 0; + let mut len = std::mem::size_of::(); + let name = b"hw.memsize\0"; + let rc = unsafe { + libc::sysctlbyname( + name.as_ptr() as *const libc::c_char, + &mut size as *mut u64 as *mut libc::c_void, + &mut len, + std::ptr::null_mut(), + 0, + ) + }; + if rc == 0 { + Some(size) + } else { + None + } + } + #[cfg(target_os = "linux")] + { + unsafe { + let pages = libc::sysconf(libc::_SC_PHYS_PAGES); + let page = libc::sysconf(libc::_SC_PAGESIZE); + if pages > 0 && page > 0 { + Some(pages as u64 * page as u64) + } else { + None + } + } + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + None + } +} + +/// Dispatcher configuration. +#[derive(Clone, Debug)] +pub struct DispatcherConfig { + /// Path to the oak-worker binary. `None` = `$OAK_WORKER_BIN`, else + /// `oak-worker` next to the current executable. + pub worker_bin: Option, + /// Worker process count. `0` = the [`default_worker_count`] policy. + pub workers: usize, + /// Output slots per worker segment. `0` = 8. + pub slots_per_worker: u32, + /// Frame width of the segment geometry. `0` = 1920. + pub width: i32, + /// Frame height of the segment geometry. `0` = 1080. + pub height: i32, + /// Slot wire format: an `oakcore_rs::PixelFormat` int or + /// [`SLOT_FORMAT_BGRA8`]. Default BGRA8 (the viewer preview path). + pub slot_format: i32, + /// Batch size `B`. `0` = `max(1, 120 / workers)`. + pub batch_size: usize, + /// Graph snapshot path sent to every worker via `load_graph` after + /// the handshake (`None` = no graph). + pub graph_snapshot: Option, + /// Handshake timeout per (re)spawn. + pub handshake_timeout_ms: u64, +} + +impl Default for DispatcherConfig { + fn default() -> Self { + Self { + worker_bin: None, + workers: 0, + slots_per_worker: 0, + width: 0, + height: 0, + slot_format: SLOT_FORMAT_BGRA8, + batch_size: 0, + graph_snapshot: None, + handshake_timeout_ms: 10_000, + } + } +} + +impl DispatcherConfig { + fn normalize(&self) -> DispatcherConfig { + let mut c = self.clone(); + c.slots_per_worker = if c.slots_per_worker == 0 { + DEFAULT_SLOTS_PER_WORKER + } else { + c.slots_per_worker + }; + c.width = if c.width == 0 { 1920 } else { c.width }; + c.height = if c.height == 0 { 1080 } else { c.height }; + c + } +} + +// --------------------------------------------------------------------------- +// WorkerHandle +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WorkerState { + /// Spawned, handshake in flight. + Starting, + /// Handshaken, accepting batches. + Alive, + /// Exited / EOF detected; a restart is pending. + Dead, + /// Restart budget exhausted; tickets fail permanently. + PermanentlyDead, +} + +enum WorkerEvent { + Line { + worker: usize, + generation: u64, + line: String, + }, + Eof { + worker: usize, + generation: u64, + }, +} + +struct WorkerHandle { + index: usize, + /// Spawn generation (increments on every restart): reader-thread + /// events carry the generation of the child they read from, so a + /// late EOF from a dead child cannot kill its replacement. + generation: u64, + state: WorkerState, + child: Option, + stdin: Option, + shm: Arc, + /// FIFO mirror of the shm free ring's contents (the credit). + free_slots: VecDeque, + /// Dispatched ticket -> assigned slot (awaiting frame_ready). + outstanding: HashMap, + /// Slots delivered to consumers, awaiting release_frame. + held: HashSet, + startup_seen: bool, + graph_sent: bool, + caps: Option, + restarts: u32, + spawned_at: Instant, + accepted_batches: u64, +} + +impl WorkerHandle { + fn shell(index: usize, generation: u64, shm: Arc, slots: u32) -> WorkerHandle { + WorkerHandle { + index, + generation, + state: WorkerState::Starting, + child: None, + stdin: None, + shm, + free_slots: (0..slots).collect(), + outstanding: HashMap::new(), + held: HashSet::new(), + startup_seen: false, + graph_sent: false, + caps: None, + restarts: 0, + spawned_at: Instant::now(), + accepted_batches: 0, + } + } +} + +// --------------------------------------------------------------------------- +// ProcessDispatcher +// --------------------------------------------------------------------------- + +struct PendingTicket { + key: FrameKey, + params: Arc, + done: Option, +} + +struct Inner { + config: DispatcherConfig, + bin: PathBuf, + slots: u32, + slot_bytes: usize, + workers: Vec, + scheduler: PreviewScheduler, + tickets: HashMap, + next_ticket: i64, + events_rx: mpsc::Receiver, + events_tx: mpsc::Sender, + started: bool, + shutting_down: bool, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The process-isolated dispatcher (design doc §2 ProcessDispatcher). +/// Cloning shares one dispatcher; the control plane is a single mutex +/// pumped by [`ProcessDispatcher::poll`] — frame bytes never touch it. +pub struct ProcessDispatcher { + inner: Mutex, +} + +impl ProcessDispatcher { + /// Build a dispatcher from `config` (does not spawn; call + /// [`ProcessDispatcher::start`]). + pub fn new(config: DispatcherConfig) -> Result> { + let config = config.normalize(); + let slots = config.slots_per_worker; + let slot_bytes = slot_bytes_for(config.width, config.height, config.slot_format); + let workers = if config.workers == 0 { + default_worker_count(slots, slot_bytes) + } else { + config.workers + }; + let bin = resolve_worker_bin(&config)?; + let batch_size = config.batch_size; + let (events_tx, events_rx) = mpsc::channel(); + Ok(Arc::new(ProcessDispatcher { + inner: Mutex::new(Inner { + config, + bin, + slots, + slot_bytes, + workers: Vec::new(), + scheduler: PreviewScheduler::new(workers, batch_size), + tickets: HashMap::new(), + next_ticket: 1, + events_rx, + events_tx, + started: false, + shutting_down: false, + }), + })) + } + + /// Spawn all workers and wait for the handshakes (bounded by + /// `handshake_timeout_ms`). + pub fn start(&self) -> Result<()> { + let timeout = { + let mut inner = lock(&self.inner); + if inner.started { + return Err(Error::State); + } + inner.started = true; + let count = inner.scheduler.workers(); + for i in 0..count { + self.spawn_worker(&mut inner, i)?; + } + Duration::from_millis(inner.config.handshake_timeout_ms) + }; + let deadline = Instant::now() + timeout; + loop { + self.poll(); + { + let inner = lock(&self.inner); + if inner + .workers + .iter() + .all(|w| matches!(w.state, WorkerState::Alive)) + { + return Ok(()); + } + if inner + .workers + .iter() + .any(|w| matches!(w.state, WorkerState::PermanentlyDead)) + { + return Err(Error::Failed("worker failed to start permanently".into())); + } + } + if Instant::now() > deadline { + return Err(Error::Failed( + "render workers did not finish the startup handshake in time".into(), + )); + } + std::thread::sleep(Duration::from_millis(2)); + } + } + + /// The configured worker count. + pub fn worker_count(&self) -> usize { + lock(&self.inner).scheduler.workers() + } + + /// True when worker `i` is alive (handshaken). + pub fn is_alive(&self, worker: usize) -> bool { + lock(&self.inner) + .workers + .get(worker) + .map(|w| matches!(w.state, WorkerState::Alive)) + .unwrap_or(false) + } + + /// Restart count of worker `i` (crash-isolation metric). + pub fn restarts_of(&self, worker: usize) -> u32 { + lock(&self.inner) + .workers + .get(worker) + .map(|w| w.restarts) + .unwrap_or(0) + } + + /// Batches accepted by worker `i` (claim-confirmation metric). + pub fn accepted_batches_of(&self, worker: usize) -> u64 { + lock(&self.inner) + .workers + .get(worker) + .map(|w| w.accepted_batches) + .unwrap_or(0) + } + + /// The segment view of worker `i` (tests / S2 cache integration). + pub fn shm_of(&self, worker: usize) -> Option> { + lock(&self.inner).workers.get(worker).map(|w| w.shm.clone()) + } + + /// Pump the control plane: drain worker events, restart the dead, + /// claim + dispatch batches. Non-blocking; call from the UI tick (or + /// after any submit/release). Completions fire after the lock drops. + pub fn poll(&self) { + let mut fired: Vec<(Completion, TicketResult)> = Vec::new(); + { + let mut inner = lock(&self.inner); + self.pump(&mut inner, &mut fired); + } + for (done, result) in fired { + done(result); + } + } + + /// Release a consumed frame back to its worker's free pool (slot + /// release = cache eviction). Stale refs (worker restarted since) + /// are ignored — their segment is already gone. + pub fn release_frame(&self, frame: &ShmFrameRef) { + let mut inner = lock(&self.inner); + let Some(handle) = inner.workers.get_mut(frame.worker as usize) else { + return; + }; + if !Arc::ptr_eq(&handle.shm, &frame.shm) { + return; // stale ref: the segment was recreated + } + if !handle.held.remove(&frame.slot) { + return; // double release + } + handle.free_slots.push_back(frame.slot); + // SAFETY: the pool is a live view of the worker's segment; the + // dispatcher is the drainer, so pushing to the free ring is its + // SPSC role. + unsafe { handle.shm.pool().release(frame.slot) }; + } + + /// Cancel one frame request (pending or in flight). The completion + /// fires with `Error::State` exactly once; a late frame_ready for an + /// in-flight cancel recycles the slot silently. + pub fn cancel_frame(&self, key: &FrameKey) { + let mut fired: Vec<(Completion, TicketResult)> = Vec::new(); + { + let mut inner = lock(&self.inner); + if !inner.scheduler.cancel_key(key) { + return; + } + // Find the ticket behind the key and deliver the cancellation. + let ticket = inner + .tickets + .iter() + .find(|(_, pt)| &pt.key == key) + .map(|(id, _)| *id); + if let Some(id) = ticket { + if let Some(pt) = inner.tickets.get_mut(&id) { + if let Some(done) = pt.done.take() { + fired.push((done, Err(Error::State))); + } + } + } + } + for (done, result) in fired { + done(result); + } + } + + // ---- internals ------------------------------------------------------ + + fn pump(&self, inner: &mut Inner, fired: &mut Vec<(Completion, TicketResult)>) { + // 1. Drain worker events (non-blocking). Events from a previous + // spawn generation (a dead child's reader) are dropped so a + // late EOF cannot kill the replacement worker. + while let Ok(ev) = inner.events_rx.try_recv() { + match ev { + WorkerEvent::Line { + worker, + generation, + line, + } => { + let current = inner + .workers + .get(worker) + .map(|w| w.generation) + .unwrap_or(u64::MAX); + if current != generation { + continue; + } + self.on_line(inner, worker, &line, fired); + } + WorkerEvent::Eof { worker, generation } => { + if let Some(handle) = inner.workers.get_mut(worker) { + if handle.generation != generation { + continue; + } + if !matches!(handle.state, WorkerState::PermanentlyDead) { + handle.state = WorkerState::Dead; + } + } + } + } + } + + // 2. Restart dead workers / handshake timeouts. + let timeout = Duration::from_millis(inner.config.handshake_timeout_ms); + for i in 0..inner.workers.len() { + let action = { + let handle = &inner.workers[i]; + match handle.state { + WorkerState::Dead => true, + WorkerState::Starting => handle.spawned_at.elapsed() > timeout, + _ => false, + } + }; + if action { + self.restart_worker(inner, i, fired); + } + } + + // 3. Interleaved batch claims + dispatch (free slots = credit). + for i in 0..inner.workers.len() { + if matches!(inner.workers[i].state, WorkerState::Alive) { + self.dispatch_to(inner, i); + } + } + } + + fn on_line( + &self, + inner: &mut Inner, + worker: usize, + line: &str, + fired: &mut Vec<(Completion, TicketResult)>, + ) { + let msg: Value = match serde_json::from_str::(line) { + Ok(v) if v.is_object() => v, + _ => return, + }; + let typ = msg.get("type").and_then(Value::as_str).unwrap_or(""); + let handle = match inner.workers.get_mut(worker) { + Some(h) => h, + None => return, + }; + match typ { + TYPE_HANDSHAKE => { + // The worker's startup handshake: answer with the shm + // geometry (protocol v1 flow). + handle.startup_seen = true; + let hs = HandshakeMsg { + protocol_version: DISPATCH_PROTOCOL_VERSION, + shm_key: handle.shm.key().to_string(), + input_shm_key: String::new(), + input_slots: 0, + output_slots: handle.shm.slot_count() as i32, + slot_data_bytes: handle.shm.slot_data_bytes() as i64, + input_slot_data_bytes: 0, + }; + if self.send_json(handle, &hs.to_json()).is_err() { + handle.state = WorkerState::Dead; + } + } + TYPE_HELLO_CAPS => { + if let Ok(caps) = serde_json::from_value::(msg) { + handle.caps = Some(caps); + handle.state = WorkerState::Alive; + // One load_graph right after the first handshake. + if !handle.graph_sent { + if let Some(path) = inner.config.graph_snapshot.clone() { + handle.graph_sent = true; + if self + .send_json(handle, &json!({ "type": "load_graph", "path": path })) + .is_err() + { + handle.state = WorkerState::Dead; + } + } + } + } + } + TYPE_BATCH_ACCEPTED => { + if let Ok(accepted) = serde_json::from_value::(msg) { + let _ = accepted; + handle.accepted_batches += 1; + } + } + TYPE_FRAME_READY => { + if let Ok(ready) = serde_json::from_value::(msg) { + self.on_frame_ready(inner, worker, ready.ticket, ready.slot, fired); + } + } + TYPE_FRAME_FAILED => { + if let Ok(failed) = serde_json::from_value::(msg) { + self.on_frame_failed(inner, worker, failed.ticket, &failed.error, fired); + } + } + TYPE_ERROR => { + let ticket = msg.get("ticket").and_then(Value::as_i64); + let message = msg + .get("message") + .and_then(Value::as_str) + .unwrap_or("(no message)") + .to_string(); + match ticket { + Some(t) => self.on_frame_failed(inner, worker, t, &message, fired), + None => { + // A session-level error (e.g. load_graph or shm + // attach failed): recycle the worker. + eprintln!("procpool: worker {worker} error: {message}"); + if matches!(handle.state, WorkerState::Starting) { + handle.state = WorkerState::Dead; + } + } + } + } + _ => {} + } + } + + fn on_frame_ready( + &self, + inner: &mut Inner, + worker: usize, + ticket: i64, + slot: i32, + fired: &mut Vec<(Completion, TicketResult)>, + ) { + let handle = match inner.workers.get_mut(worker) { + Some(h) => h, + None => return, + }; + if handle.outstanding.remove(&ticket).is_none() { + return; // late / duplicate / post-restart frame + } + // Drain the ready ring in lockstep (the SPSC hand-off contract); + // frame_ready is authoritative about the slot. + let mut ring_slot = 0; + // SAFETY: live pool view; the dispatcher is the ready-ring + // consumer. + let popped = unsafe { handle.shm.pool().consume(&mut ring_slot) }; + if !popped || ring_slot != slot as u32 { + eprintln!( + "procpool: worker {worker} ready-ring out of sync (popped {popped}, ring {ring_slot}, msg {slot})" + ); + } + let meta = handle.shm.meta_copy(slot as u32); + let shm = handle.shm.clone(); + handle.held.insert(slot as u32); + + let pt = inner.tickets.get_mut(&ticket); + match pt { + Some(pt) => { + let key = pt.key; + inner.scheduler.frame_done(&key); + if let Some(done) = pt.done.take() { + fired.push(( + done, + Ok(TicketPayload::ShmFrame(ShmFrameRef { + worker: worker as u32, + slot: slot as u32, + meta, + shm, + })), + )); + } else { + // Cancelled while in flight: recycle the slot now. + self.recycle_slot(inner, worker, slot as u32); + } + } + None => { + self.recycle_slot(inner, worker, slot as u32); + } + } + } + + fn on_frame_failed( + &self, + inner: &mut Inner, + worker: usize, + ticket: i64, + error: &str, + fired: &mut Vec<(Completion, TicketResult)>, + ) { + let slot = { + let handle = match inner.workers.get_mut(worker) { + Some(h) => h, + None => return, + }; + handle.outstanding.remove(&ticket) + }; + let Some(slot) = slot else { return }; + // The worker acquired the slot but never published it: the + // dispatcher (drainer) hands it back to the free pool. + self.recycle_slot(inner, worker, slot); + if let Some(pt) = inner.tickets.get_mut(&ticket) { + inner.scheduler.frame_failed(&pt.key); + if let Some(done) = pt.done.take() { + fired.push((done, Err(Error::Failed(format!("render failed: {error}"))))); + } + } + } + + /// Return a slot to the worker's free pool (queue + ring). + fn recycle_slot(&self, inner: &mut Inner, worker: usize, slot: u32) { + let Some(handle) = inner.workers.get_mut(worker) else { + return; + }; + handle.held.remove(&slot); + handle.free_slots.push_back(slot); + // SAFETY: live pool view; drainer-side free-ring push. + unsafe { handle.shm.pool().release(slot) }; + } + + fn dispatch_to(&self, inner: &mut Inner, worker: usize) { + loop { + let credit = inner.workers[worker].free_slots.len(); + if credit == 0 { + return; + } + let Some(batch) = inner.scheduler.claim_batch(worker, credit) else { + return; + }; + let mut wire_tickets = Vec::with_capacity(batch.frames.len()); + for req in &batch.frames { + let ticket = req.payload; + let slot = match inner.workers[worker].free_slots.pop_front() { + Some(s) => s, + None => break, // credit accounting drifted; stop cleanly + }; + inner.workers[worker].outstanding.insert(ticket, slot); + let Some(pt) = inner.tickets.get(&ticket) else { + continue; + }; + wire_tickets.push(build_ticket_spec( + ticket, + slot, + &pt.params, + inner.config.slot_format, + )); + } + let msg = RenderBatchMsg { + batch_id: batch.batch_id as i64, + tickets: wire_tickets, + }; + // The `type` tag is added by hand: [`RenderBatchMsg`] only + // carries the payload fields (it is the parse-side struct). + let mut value = match serde_json::to_value(&msg) { + Ok(v) => v, + Err(_) => return, + }; + if let Some(obj) = value.as_object_mut() { + obj.insert( + "type".to_string(), + Value::String(crate::ipc::TYPE_RENDER_BATCH.to_string()), + ); + } + if self.send_json(&mut inner.workers[worker], &value).is_err() { + inner.workers[worker].state = WorkerState::Dead; + return; + } + } + } + + fn send_json(&self, handle: &mut WorkerHandle, msg: &Value) -> Result<()> { + let stdin = handle.stdin.as_mut().ok_or(Error::State)?; + write_message(stdin, msg).map_err(|e| Error::Failed(format!("worker stdin: {e}")))?; + stdin + .flush() + .map_err(|e| Error::Failed(format!("worker stdin flush: {e}"))) + } + + fn spawn_worker(&self, inner: &mut Inner, index: usize) -> Result<()> { + // One segment generation per (re)spawn: the key carries the restart + // count so a restart never reuses the previous name — dropping the + // old handle unlinks the OLD segment by name and must not remove + // the freshly created one (SharedMemoryRegion::close unlinks by + // name for Create-mode regions). + let generation = inner + .workers + .get(index) + .map(|w| w.restarts as u64) + .unwrap_or(0); + let key = format!( + "{}-g{generation}", + SharedMemoryRegion::make_key(std::process::id() as i64, index as i32) + ); + let shm = ShmRegionView::create(&key, inner.slots, inner.slot_bytes)?; + + let mut child = Command::new(&inner.bin) + .args(["--backend", "cpu"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| Error::Failed(format!("spawn oak-worker: {e}")))?; + let stdin = child.stdin.take(); + let stdout = child + .stdout + .take() + .ok_or_else(|| Error::Failed("oak-worker stdout not piped".into()))?; + + // Reader thread: stdout lines -> event channel (control plane). + // Events carry the spawn generation so stale events from a dead + // child are dropped after a restart. + let tx = inner.events_tx.clone(); + std::thread::Builder::new() + .name(format!("oak-worker-{index}-reader")) + .spawn(move || { + use std::io::BufRead; + let mut reader = std::io::BufReader::new(stdout); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => { + let _ = tx.send(WorkerEvent::Eof { + worker: index, + generation, + }); + return; + } + Ok(_) => { + let _ = tx.send(WorkerEvent::Line { + worker: index, + generation, + line: line.trim_end().to_string(), + }); + } + Err(_) => { + let _ = tx.send(WorkerEvent::Eof { + worker: index, + generation, + }); + return; + } + } + } + }) + .map_err(|e| Error::Failed(format!("spawn reader thread: {e}")))?; + + let mut handle = WorkerHandle::shell(index, generation, shm, inner.slots); + handle.child = Some(child); + handle.stdin = stdin; + handle.spawned_at = Instant::now(); + if index < inner.workers.len() { + // Restart path: keep the restart counter. + handle.restarts = inner.workers[index].restarts; + inner.workers[index] = handle; + } else { + inner.workers.push(handle); + } + Ok(()) + } + + fn restart_worker( + &self, + inner: &mut Inner, + worker: usize, + fired: &mut Vec<(Completion, TicketResult)>, + ) { + // Reap the child and drop the pipes. + let restarts = { + let handle = &mut inner.workers[worker]; + if let Some(mut child) = handle.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + handle.stdin = None; + handle.startup_seen = false; + handle.graph_sent = false; + handle.caps = None; + handle.held.clear(); + handle.outstanding.clear(); + handle.restarts += 1; + handle.restarts + }; + + // Crash recovery (design §3.2): every claimed frame of this + // worker — un-started batches and un-finished frames alike — is + // re-queued; any healthy worker may claim it. + let reclaimed = inner.scheduler.worker_crashed(worker); + + if restarts > MAX_RESTARTS { + // Restart budget exhausted: the worker stays down and its + // frames fail permanently (main paints the fallback). + inner.workers[worker].state = WorkerState::PermanentlyDead; + for req in reclaimed { + inner.scheduler.cancel_key(&req.key); + if let Some(pt) = inner.tickets.get_mut(&req.payload) { + if let Some(done) = pt.done.take() { + fired.push(( + done, + Err(Error::Failed( + "render worker crashed repeatedly; frame dropped".into(), + )), + )); + } + } + } + return; + } + + // Fresh segment + respawn (a Create unlinks any stale segment). + if let Err(e) = self.spawn_worker(inner, worker) { + eprintln!("procpool: worker {worker} respawn failed: {e}"); + inner.workers[worker].state = WorkerState::Dead; + } + } +} + +impl JobDispatch for ProcessDispatcher { + /// Submit one frame job (the ticket-arena seam). The job joins the + /// scheduler as a Seek-priority request and is dispatched on the + /// next pump; the completion fires with + /// `TicketPayload::ShmFrame(ShmFrameRef)` — never a pixel buffer. + fn post(&self, job: Job) -> bool { + { + let mut inner = lock(&self.inner); + if inner.shutting_down { + return false; + } + let id = inner.next_ticket; + inner.next_ticket += 1; + let key = FrameKey { + sequence: job.node_identity, + frame: id, + version: 0, + }; + inner.tickets.insert( + id, + PendingTicket { + key, + params: job.params, + done: Some(job.done), + }, + ); + inner.scheduler.submit(FrameRequest { + key, + priority: FramePriority::Seek, + distance: 0, + payload: id, + }); + } + // Pump once so a live worker picks the frame up immediately. + self.poll(); + true + } + + /// Graceful shutdown: `shutdown` messages, a short drain pumping + /// completions, then kill stragglers; every ticket still open + /// completes with `Error::State`. + fn shutdown(&self) { + let mut fired: Vec<(Completion, TicketResult)> = Vec::new(); + { + let mut inner = lock(&self.inner); + if inner.shutting_down { + return; + } + inner.shutting_down = true; + for i in 0..inner.workers.len() { + let handle = &mut inner.workers[i]; + if matches!(handle.state, WorkerState::Alive | WorkerState::Starting) { + let _ = self.send_json(handle, &json!({ "type": "shutdown" })); + } + } + } + // Drain window: let workers finish in-flight frames and deliver + // the completions. + let deadline = Instant::now() + Duration::from_secs(3); + loop { + { + let mut inner = lock(&self.inner); + self.pump(&mut inner, &mut fired); + let any_running = inner.workers.iter_mut().any(|w| { + w.child + .as_mut() + .map(|c| c.try_wait().ok().flatten().is_none()) + .unwrap_or(false) + }); + if !any_running { + break; + } + } + if Instant::now() > deadline { + break; + } + std::thread::sleep(Duration::from_millis(2)); + // Deliver what pumped so far before the next round. + for (done, result) in fired.drain(..) { + done(result); + } + } + { + let mut inner = lock(&self.inner); + // Kill stragglers and reap. + for w in inner.workers.iter_mut() { + if let Some(mut child) = w.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + w.stdin = None; + } + // Every ticket still open completes with cancellation. + for (_, pt) in inner.tickets.iter_mut() { + if let Some(done) = pt.done.take() { + fired.push((done, Err(Error::State))); + } + } + } + for (done, result) in fired { + done(result); + } + } +} + +/// Resolve the oak-worker binary path. +fn resolve_worker_bin(config: &DispatcherConfig) -> Result { + if let Some(p) = &config.worker_bin { + return Ok(p.clone()); + } + if let Ok(p) = std::env::var("OAK_WORKER_BIN") { + return Ok(PathBuf::from(p)); + } + let exe = std::env::current_exe() + .map_err(|e| Error::Failed(format!("resolve oak-worker: current exe: {e}")))?; + let candidate = exe + .parent() + .ok_or_else(|| Error::Failed("resolve oak-worker: no exe parent".into()))? + .join(format!("oak-worker{}", std::env::consts::EXE_SUFFIX)); + if candidate.exists() { + return Ok(candidate); + } + Err(Error::Failed(format!( + "oak-worker binary not found at {}; set DispatcherConfig::worker_bin or OAK_WORKER_BIN", + candidate.display() + ))) +} + +/// Map ticket params to the wire ticket spec (main assigns `slot`). +fn build_ticket_spec( + ticket: i64, + slot: u32, + params: &VideoTicketParams, + slot_format: i32, +) -> BatchTicketSpec { + let (width, height) = params.render_size(); + let (footage_file, footage_stream) = match ¶ms.footage { + Some((f, s)) => (f.clone(), *s), + None => (String::new(), 0), + }; + let montage = params + .montage + .iter() + .map(|c| WireMontageClip { + filename: c.filename.clone(), + stream_index: c.stream_index, + in_num: c.in_time.numerator(), + in_den: c.in_time.denominator(), + out_num: c.out_time.numerator(), + out_den: c.out_time.denominator(), + media_in_num: c.media_in.numerator(), + media_in_den: c.media_in.denominator(), + gain: c.gain, + }) + .collect(); + BatchTicketSpec { + ticket, + slot: slot as i32, + time_num: params.time.numerator(), + time_den: params.time.denominator(), + width, + height, + format: slot_format, + channels: 4, + footage_file, + footage_stream, + montage, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_bytes_for_formats() { + // F32 RGBA: 16 bytes per pixel. + assert_eq!(slot_bytes_for(1920, 1080, 4), 1920 * 1080 * 16); + // BGRA8: 4 bytes per pixel (the 8.3 MB design figure). + assert_eq!(slot_bytes_for(1920, 1080, SLOT_FORMAT_BGRA8), 1920 * 1080 * 4); + // U8 RGBA. + assert_eq!(slot_bytes_for(64, 64, 0), 64 * 64 * 4); + } + + #[test] + fn worker_count_policy_is_clamped() { + // With absurd slot sizes the memory budget clamps to 1. + let n = default_worker_count(64, 1 << 30); // 64 GiB per worker + assert_eq!(n, 1); + // With tiny slots the core policy dominates (>= 1). + let n = default_worker_count(1, 64); + assert!(n >= 1); + } + + #[test] + fn config_normalization_defaults() { + let c = DispatcherConfig::default().normalize(); + assert_eq!(c.slots_per_worker, DEFAULT_SLOTS_PER_WORKER); + assert_eq!(c.width, 1920); + assert_eq!(c.height, 1080); + assert_eq!(c.slot_format, SLOT_FORMAT_BGRA8); + } + + #[test] + fn copy_counter_counts_only_slot_to_vec() { + reset_main_heap_frame_copies(); + assert_eq!(main_heap_frame_copies(), 0); + } +} diff --git a/crates/oakrender/src/scheduler.rs b/crates/oakrender/src/scheduler.rs new file mode 100644 index 000000000..e51763aa4 --- /dev/null +++ b/crates/oakrender/src/scheduler.rs @@ -0,0 +1,568 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The preview scheduler (M15 S1; design doc §3.2): frame-request +//! ordering, interleaved batch claims, crash re-dispatch and flow +//! control, as pure single-threaded logic. +//! +//! The scheduler knows nothing about processes or shared memory — it +//! turns a stream of [`FrameRequest`]s into [`ClaimedBatch`]es for the +//! [`crate::procpool::ProcessDispatcher`] to hand to workers: +//! +//! - **Interleaved batch claims.** With `W` workers, the pending frame +//! stream is sharded round-robin: worker `i` claims frames whose +//! frame number is `≡ i (mod W)`, in batches of `B ≈ 120 / W` +//! (configurable). Adjacent frame numbers therefore land on +//! different workers and finish at nearly the same time; every +//! frame belongs to exactly one worker (no work stealing). +//! - **Priorities.** Seek/current frame > playback window (nearer the +//! playhead first) > background (export/thumbnails). Within one +//! priority class batches keep ascending frame order. +//! - **Crash recovery.** A crashed worker's claimed frames (its whole +//! un-started batches plus the un-finished frames of started ones) +//! are re-queued and may be claimed by ANY healthy worker — crash +//! re-dispatch is failure recovery, not stealing. +//! - **Flow control.** [`PreviewScheduler::claim_batch`] never claims +//! more frames than the caller's `credit` (the worker's free shm +//! slot count): slots are the credit. +//! - **Cancellation.** Frame keys carry a parameter `version`; +//! submitting a newer version of a key invalidates the older one, +//! and [`PreviewScheduler::cancel_sequence`] drops a whole sequence. +//! +//! All methods are non-blocking; the dispatcher drives them from its +//! poll loop (UI tick). + +use std::collections::HashMap; + +/// A frame request key: `(sequence, frame number, parameter version)`. +/// The version covers graph/proxy/resolution/color-parameter changes — +/// bumping it invalidates outstanding requests for the same frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FrameKey { + /// Sequence identity. + pub sequence: u64, + /// Frame number within the sequence. + pub frame: i64, + /// Parameter version (graph version / proxy tier / resolution tier / + /// color). + pub version: u64, +} + +/// Frame request priority class (lower value = more urgent). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum FramePriority { + /// Seek / current playhead frame (single-frame insert, top priority). + Seek, + /// Playback window frames (ordered by [`FrameRequest::distance`]). + Playback, + /// Background work (export, thumbnails, full-res stills). + Background, +} + +/// One pending frame request. `payload` is opaque to the scheduler (the +/// dispatcher uses it to find the ticket's params + completion). +#[derive(Clone, Debug)] +pub struct FrameRequest

{ + /// The request key. + pub key: FrameKey, + /// Priority class. + pub priority: FramePriority, + /// Distance from the playhead in frames (orders the Playback class; + /// unused by Seek/Background). + pub distance: i64, + /// Caller payload. + pub payload: P, +} + +/// A batch of frames one worker claimed. +#[derive(Clone, Debug)] +pub struct ClaimedBatch

{ + /// Batch identity (unique per scheduler). + pub batch_id: u64, + /// The claiming worker index. + pub worker: usize, + /// The claimed frames in render order (priority class first, then + /// ascending frame number). + pub frames: Vec>, +} + +struct Claim

{ + worker: usize, + batch_id: u64, + request: FrameRequest

, +} + +struct PendingEntry

{ + request: FrameRequest

, + /// True when any worker may claim this frame (crash re-dispatch); + /// false when the interleaved shard rule applies. + any_worker: bool, +} + +/// The scheduler state machine (single-threaded by contract). +pub struct PreviewScheduler

{ + workers: usize, + batch_size: usize, + pending: Vec>, + claimed: HashMap>, + next_batch_id: u64, + /// Total frames re-queued by worker crashes (tests/metrics). + crash_requeued: u64, +} + +impl PreviewScheduler

{ + /// Scheduler for `workers` workers (at least 1). `batch_size` 0 picks + /// the design default `max(1, 120 / workers)`. + pub fn new(workers: usize, batch_size: usize) -> Self { + let workers = workers.max(1); + let batch_size = if batch_size == 0 { + (120 / workers).max(1) + } else { + batch_size + }; + Self { + workers, + batch_size, + pending: Vec::new(), + claimed: HashMap::new(), + next_batch_id: 1, + crash_requeued: 0, + } + } + + /// The configured worker count. + pub fn workers(&self) -> usize { + self.workers + } + + /// The configured batch size. + pub fn batch_size(&self) -> usize { + self.batch_size + } + + /// Submit a frame request. An already-pending request with the same + /// key is replaced; a key already claimed (in flight) is rejected — + /// the dispatcher must cancel/re-version it first. Returns true when + /// the request was accepted. + pub fn submit(&mut self, request: FrameRequest

) -> bool { + if self.claimed.contains_key(&request.key) { + return false; + } + if let Some(entry) = self.pending.iter_mut().find(|e| e.request.key == request.key) { + entry.request = request; + return true; + } + self.pending.push(PendingEntry { + request, + any_worker: false, + }); + true + } + + /// Claim the next batch for `worker`: the worker's interleaved shard + /// (frame number `≡ worker (mod W)`, plus any crash-requeued frames), + /// ordered by priority class / playhead distance / ascending frame, + /// capped at `min(batch_size, credit)`. Returns `None` when nothing + /// is claimable (`credit == 0`, unknown worker, empty shard). + /// + /// Claimed frames never go to another worker while in flight (no + /// stealing). + pub fn claim_batch(&mut self, worker: usize, credit: usize) -> Option> { + if worker >= self.workers || credit == 0 { + return None; + } + let workers = self.workers; + let mut indexes: Vec = self + .pending + .iter() + .enumerate() + .filter(|(_, e)| { + e.any_worker || e.request.key.frame.rem_euclid(workers as i64) == worker as i64 + }) + .map(|(i, _)| i) + .collect(); + if indexes.is_empty() { + return None; + } + indexes.sort_by(|&a, &b| { + let ra = &self.pending[a].request; + let rb = &self.pending[b].request; + ra.priority + .cmp(&rb.priority) + .then(ra.distance.cmp(&rb.distance)) + .then(ra.key.frame.cmp(&rb.key.frame)) + .then(ra.key.sequence.cmp(&rb.key.sequence)) + }); + indexes.truncate(self.batch_size.min(credit)); + + let batch_id = self.next_batch_id; + self.next_batch_id += 1; + + // Collect claimed frames (removal order does not matter; the batch + // keeps the sorted order). + let mut frames: Vec> = Vec::with_capacity(indexes.len()); + let mut marked: Vec = vec![false; self.pending.len()]; + for &i in &indexes { + marked[i] = true; + } + let mut kept: Vec> = Vec::with_capacity(self.pending.len() - indexes.len()); + for (i, entry) in self.pending.drain(..).enumerate() { + if marked[i] { + self.claimed.insert( + entry.request.key, + Claim { + worker, + batch_id, + request: entry.request.clone(), + }, + ); + frames.push(entry.request); + } else { + kept.push(entry); + } + } + self.pending = kept; + frames.sort_by(|a, b| { + a.priority + .cmp(&b.priority) + .then(a.distance.cmp(&b.distance)) + .then(a.key.frame.cmp(&b.key.frame)) + }); + Some(ClaimedBatch { + batch_id, + worker, + frames, + }) + } + + /// Report a claimed frame as rendered (frame_ready). Returns the + /// request when the key was in flight. + pub fn frame_done(&mut self, key: &FrameKey) -> Option> { + self.claimed.remove(key).map(|c| c.request) + } + + /// Report a claimed frame as permanently failed (frame_failed; the + /// main process paints the fallback). The claim is dropped WITHOUT + /// re-dispatch — a render error is not a crash. Returns the request + /// when the key was in flight. + pub fn frame_failed(&mut self, key: &FrameKey) -> Option> { + self.claimed.remove(key).map(|c| c.request) + } + + /// Re-queue every frame claimed by `worker` (crash recovery): its + /// un-started batches and the un-finished frames of started batches + /// all come back as pending, claimable by ANY healthy worker. + /// Returns the re-queued requests. + pub fn worker_crashed(&mut self, worker: usize) -> Vec> { + let mut reclaimed = Vec::new(); + self.claimed.retain(|_, claim| { + if claim.worker == worker { + reclaimed.push(claim.request.clone()); + false + } else { + true + } + }); + for request in reclaimed.iter().cloned() { + self.pending.push(PendingEntry { + request, + any_worker: true, + }); + } + self.crash_requeued += reclaimed.len() as u64; + reclaimed + } + + /// Cancel one key, pending OR claimed (single-frame cancellation; + /// the dispatcher delivers the ticket's `Error::State` itself). + /// Returns true when the key was known. + pub fn cancel_key(&mut self, key: &FrameKey) -> bool { + let before = self.pending.len(); + self.pending.retain(|e| &e.request.key != key); + if self.pending.len() != before { + return true; + } + self.claimed.remove(key).is_some() + } + + /// Cancel every pending AND claimed frame of `sequence` (frame-key + /// invalidation; the `cancel` wire message covers the worker side). + /// Returns the number of dropped requests. + pub fn cancel_sequence(&mut self, sequence: u64) -> usize { + let before_pending = self.pending.len(); + self.pending.retain(|e| e.request.key.sequence != sequence); + let dropped_pending = before_pending - self.pending.len(); + let before_claimed = self.claimed.len(); + self.claimed.retain(|_, c| c.request.key.sequence != sequence); + dropped_pending + (before_claimed - self.claimed.len()) + } + + /// Pending (unclaimed) request count. + pub fn pending_count(&self) -> usize { + self.pending.len() + } + + /// Claimed (in-flight) request count. + pub fn claimed_count(&self) -> usize { + self.claimed.len() + } + + /// The worker currently holding `key` (None when not claimed). + pub fn claimed_worker(&self, key: &FrameKey) -> Option { + self.claimed.get(key).map(|c| c.worker) + } + + /// Total frames re-queued by crashes so far (tests/metrics). + pub fn crash_requeued(&self) -> u64 { + self.crash_requeued + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn req(seq: u64, frame: i64, prio: FramePriority) -> FrameRequest { + FrameRequest { + key: FrameKey { + sequence: seq, + frame, + version: 0, + }, + priority: prio, + distance: frame, + payload: frame as u64, + } + } + + /// Claim until nothing is claimable by any worker; returns + /// (worker, frame) pairs in claim order. + fn claim_all(s: &mut PreviewScheduler) -> Vec<(usize, i64)> { + let mut out = Vec::new(); + loop { + let mut progress = false; + for w in 0..s.workers() { + while let Some(batch) = s.claim_batch(w, 1024) { + for f in &batch.frames { + out.push((w, f.key.frame)); + } + progress = true; + } + } + if !progress { + break; + } + } + out + } + + #[test] + fn no_stealing_every_frame_claimed_exactly_once() { + let mut s: PreviewScheduler = PreviewScheduler::new(3, 4); + for f in 0..40 { + assert!(s.submit(req(1, f, FramePriority::Playback))); + } + let claims = claim_all(&mut s); + assert_eq!(claims.len(), 40, "every frame claimed"); + let mut frames: Vec = claims.iter().map(|(_, f)| *f).collect(); + frames.sort_unstable(); + frames.dedup(); + assert_eq!(frames.len(), 40, "no frame claimed twice (no stealing)"); + assert_eq!(s.pending_count(), 0); + assert_eq!(s.claimed_count(), 40, "all claimed, none completed yet"); + } + + #[test] + fn interleave_adjacent_frames_on_different_workers() { + let mut s: PreviewScheduler = PreviewScheduler::new(4, 2); + for f in 0..16 { + s.submit(req(1, f, FramePriority::Playback)); + } + let claims = claim_all(&mut s); + for (worker, frame) in &claims { + assert_eq!( + frame.rem_euclid(4), + *worker as i64, + "frame {frame} must be claimed by worker {}", + frame.rem_euclid(4) + ); + } + // Adjacent frames are on different workers. + let worker_of: HashMap = claims + .iter() + .map(|(w, f)| (*f, *w)) + .collect(); + for f in 0..15 { + assert_ne!(worker_of[&f], worker_of[&(f + 1)]); + } + } + + #[test] + fn crash_requeues_to_any_healthy_worker() { + let mut s: PreviewScheduler = PreviewScheduler::new(2, 8); + for f in 0..8 { + s.submit(req(1, f, FramePriority::Playback)); + } + // Both workers claim their shards first. + let batch0 = s.claim_batch(0, 8).unwrap(); + assert_eq!(batch0.frames.len(), 4); // frames 0,2,4,6 + let batch1 = s.claim_batch(1, 8).unwrap(); + assert_eq!(batch1.frames.len(), 4); // frames 1,3,5,7 + + // Worker 0 crashes: its frames come back... + let reclaimed = s.worker_crashed(0); + assert_eq!(reclaimed.len(), 4); + assert_eq!(s.claimed_count(), 4, "worker 1 keeps its own batch"); + assert_eq!(s.pending_count(), 4); + assert_eq!(s.crash_requeued(), 4); + + // ...and worker 1 (NOT their shard) can claim them all. + let batch = s.claim_batch(1, 8).unwrap(); + assert_eq!(batch.frames.len(), 4); + let mut frames: Vec = batch.frames.iter().map(|f| f.key.frame).collect(); + frames.sort_unstable(); + assert_eq!(frames, vec![0, 2, 4, 6]); + } + + #[test] + fn priority_seek_beats_playback_beats_background() { + let mut s: PreviewScheduler = PreviewScheduler::new(1, 100); + // All frames on worker 0's shard (W=1): background first, then a + // playback window, then a seek frame submitted last. + for f in 0..5 { + s.submit(req(1, f, FramePriority::Background)); + } + for f in 10..15 { + let mut r = req(1, f, FramePriority::Playback); + r.distance = (f - 12).abs(); + s.submit(r); + } + s.submit(req(1, 100, FramePriority::Seek)); + + let batch = s.claim_batch(0, 100).unwrap(); + let order: Vec<(FramePriority, i64)> = batch + .frames + .iter() + .map(|f| (f.priority, f.key.frame)) + .collect(); + // Seek first... + assert_eq!(order[0], (FramePriority::Seek, 100)); + // ...then playback by playhead distance (12 nearest first)... + assert_eq!( + order[1..6] + .iter() + .map(|(_, f)| *f) + .collect::>(), + vec![12, 11, 13, 10, 14] + ); + // ...then background ascending. + assert_eq!( + order[6..] + .iter() + .map(|(_, f)| *f) + .collect::>(), + vec![0, 1, 2, 3, 4] + ); + } + + #[test] + fn flow_control_credit_limits_batch() { + let mut s: PreviewScheduler = PreviewScheduler::new(1, 100); + for f in 0..10 { + s.submit(req(1, f, FramePriority::Playback)); + } + // Zero credit claims nothing. + assert!(s.claim_batch(0, 0).is_none()); + // Credit 3 claims exactly 3 (free slots are the credit). + let batch = s.claim_batch(0, 3).unwrap(); + assert_eq!(batch.frames.len(), 3); + assert_eq!(s.pending_count(), 7); + } + + #[test] + fn batch_size_caps_the_claim() { + let mut s: PreviewScheduler = PreviewScheduler::new(1, 4); + for f in 0..10 { + s.submit(req(1, f, FramePriority::Playback)); + } + let batch = s.claim_batch(0, 100).unwrap(); + assert_eq!(batch.frames.len(), 4, "batch size B caps the claim"); + // Ascending frame order inside the batch. + let frames: Vec = batch.frames.iter().map(|f| f.key.frame).collect(); + assert_eq!(frames, vec![0, 1, 2, 3]); + } + + #[test] + fn done_and_failed_drop_the_claim() { + let mut s: PreviewScheduler = PreviewScheduler::new(1, 4); + s.submit(req(1, 0, FramePriority::Playback)); + s.submit(req(1, 1, FramePriority::Playback)); + let batch = s.claim_batch(0, 4).unwrap(); + assert_eq!(batch.frames.len(), 2); + let k0 = batch.frames[0].key; + let k1 = batch.frames[1].key; + assert!(s.frame_done(&k0).is_some()); + assert!(s.frame_failed(&k1).is_some()); + assert_eq!(s.claimed_count(), 0); + assert_eq!(s.pending_count(), 0, "frame_failed is terminal (purple frame fallback)"); + // Unknown keys are no-ops. + assert!(s.frame_done(&k0).is_none()); + } + + #[test] + fn resubmit_of_claimed_key_is_rejected_until_done() { + let mut s: PreviewScheduler = PreviewScheduler::new(1, 4); + let r = req(1, 5, FramePriority::Playback); + let key = r.key; + s.submit(r); + let _ = s.claim_batch(0, 4).unwrap(); + // In flight: rejected. + assert!(!s.submit(req(1, 5, FramePriority::Seek))); + s.frame_done(&key); + // After completion the same key may be requested again (new + // version in practice). + assert!(s.submit(req(1, 5, FramePriority::Seek))); + } + + #[test] + fn cancel_sequence_drops_pending_and_claimed() { + let mut s: PreviewScheduler = PreviewScheduler::new(1, 4); + for f in 0..6 { + s.submit(req(7, f, FramePriority::Playback)); + } + let _ = s.claim_batch(0, 4).unwrap(); // claims 4 of sequence 7 + s.submit(req(8, 0, FramePriority::Playback)); // other sequence + assert_eq!(s.cancel_sequence(7), 6); + assert_eq!(s.pending_count(), 1, "sequence 8 untouched"); + assert_eq!(s.claimed_count(), 0); + } + + #[test] + fn default_batch_size_is_120_over_workers() { + let s: PreviewScheduler = PreviewScheduler::new(4, 0); + assert_eq!(s.batch_size(), 30); + let s: PreviewScheduler = PreviewScheduler::new(0, 0); + assert_eq!(s.workers(), 1); + assert_eq!(s.batch_size(), 120); + } + + #[test] + fn unknown_worker_claims_nothing() { + let mut s: PreviewScheduler = PreviewScheduler::new(2, 4); + s.submit(req(1, 0, FramePriority::Playback)); + assert!(s.claim_batch(2, 4).is_none()); + } +} diff --git a/crates/oakrender/src/ticket.rs b/crates/oakrender/src/ticket.rs index 0de78884f..6a74f94a4 100644 --- a/crates/oakrender/src/ticket.rs +++ b/crates/oakrender/src/ticket.rs @@ -33,7 +33,7 @@ use oakcore_rs::{Rational, TimeRange}; use crate::error::{Error, Result}; use crate::eval; use crate::texture::Texture; -use crate::worker::WorkerPool; +use crate::worker::JobDispatch; /// One clip of a sequence montage (M12 P0): the facade resolves the /// timeline into an ordered list of clips; the producer decodes each and @@ -128,6 +128,10 @@ pub enum TicketPayload { Video(Texture), /// Rendered interleaved audio. Audio(AudioSamples), + /// A rendered frame living in a worker's shared-memory slot (M15 + /// process backend): zero copy — the consumer reads the pixels from + /// the mapping and releases the slot through the dispatcher. + ShmFrame(crate::procpool::ShmFrameRef), } /// Completion payload: the rendered texture/samples or the failure @@ -239,18 +243,31 @@ fn lock(m: &Mutex) -> MutexGuard<'_, T> { /// The ticket arena (owned by the manager). pub struct TicketArena { next: AtomicU64, - pool: WorkerPool, + dispatch: Arc, + audio_dispatch: Arc, slots: Mutex>>, shutting_down: AtomicBool, producer: Producer, } impl TicketArena { - /// Arena dispatching through `pool`; `producer` renders frames. - pub fn new(pool: WorkerPool, producer: Producer) -> Self { + /// Arena dispatching video and audio through the same backend. + pub fn new(dispatch: Arc, producer: Producer) -> Self { + Self::new_with_audio(dispatch.clone(), dispatch, producer) + } + + /// Arena with separate video/audio backends (M15: video may run on + /// the process dispatcher while audio stays on the main-process + /// thread dispatch until S3 — design §3.7). + pub fn new_with_audio( + video: Arc, + audio: Arc, + producer: Producer, + ) -> Self { Self { next: AtomicU64::new(1), - pool, + dispatch: video, + audio_dispatch: audio, slots: Mutex::new(HashMap::new()), shutting_down: AtomicBool::new(false), producer, @@ -318,8 +335,8 @@ impl TicketArena { produce: producer, done: Box::new(move |result| slot_done.finish(result)), }; - if !self.pool.post(job) { - // Pool is gone (shutdown raced the submit): deliver now. + if !self.dispatch.post(job) { + // Backend is gone (shutdown raced the submit): deliver now. slot.finish(Err(Error::State)); } id @@ -387,7 +404,7 @@ impl TicketArena { produce: producer, done: Box::new(move |result| slot_done.finish(result)), }; - if !self.pool.post(job) { + if !self.audio_dispatch.post(job) { slot.finish(Err(Error::State)); } id @@ -477,6 +494,7 @@ mod tests { use crate::frame::VideoParamsPod; use crate::texture::Frame; + use crate::worker::WorkerPool; fn small_frame() -> Frame { let mut f = Frame::new(); @@ -497,7 +515,7 @@ mod tests { let pool = WorkerPool::new(2); let mut pool = pool; pool.start(); - let arena = TicketArena::new(pool.clone(), ok_producer()); + let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer()); let (tx, rx) = mpsc::channel(); let id = arena.submit_video( @@ -552,7 +570,7 @@ mod tests { } Ok(TicketPayload::Video(Texture::wrap_frame(small_frame()))) }); - let arena = TicketArena::new(pool.clone(), producer); + let arena = TicketArena::new(Arc::new(pool.clone()), producer); let (tx, rx) = mpsc::channel(); let id = arena.submit_video( @@ -592,7 +610,7 @@ mod tests { let pool = WorkerPool::new(1); let mut pool = pool; pool.start(); - let arena = TicketArena::new(pool.clone(), ok_producer()); + let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer()); arena.cancel(TicketId(12345)); assert!(!arena.is_finished(TicketId(12345))); pool.shutdown(); @@ -603,7 +621,7 @@ mod tests { let pool = WorkerPool::new(1); let mut pool = pool; pool.start(); - let arena = TicketArena::new(pool.clone(), ok_producer()); + let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer()); assert_eq!( arena.wait(TicketId(999)).unwrap_err().code(), Error::NotFound.code() @@ -616,7 +634,7 @@ mod tests { let pool = WorkerPool::new(1); let mut pool = pool; pool.start(); - let arena = TicketArena::new(pool.clone(), ok_producer()); + let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer()); let (tx, rx) = mpsc::channel(); let range = TimeRange::new(Rational::new(0, 1), Rational::new(10, 1)); @@ -649,7 +667,7 @@ mod tests { let pool = WorkerPool::new(1); let mut pool = pool; pool.start(); - let arena = TicketArena::new(pool.clone(), ok_producer()); + let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer()); let a = arena.submit_video( VideoTicketParams { viewer: 1, diff --git a/crates/oakrender/src/worker.rs b/crates/oakrender/src/worker.rs index 581f09831..c1176d0c5 100644 --- a/crates/oakrender/src/worker.rs +++ b/crates/oakrender/src/worker.rs @@ -16,13 +16,15 @@ //! The worker layer (C++ RenderWorkerPool + RenderThread + //! workerprocess/workerjson): thread pool AND process-isolated pool -//! behind one enum. +//! behind one dispatch seam. //! //! This pass ships the in-process [`WorkerPool`] fully. The -//! [`ProcessPool`] (crash isolation via oakengine_ipc worker processes) -//! is a documented stub: the oakengine_ipc C ABI worker binary is not -//! wired into the Rust world yet, so `start`/`post` fail with -//! `Error::Failed` and the crash-isolation tests are `#[ignore]`d. +//! process-isolated backend landed in M15 S1 as +//! [`crate::procpool::ProcessDispatcher`] (spawn/handshake/crash-restart +//! of oak-worker binaries over NDJSON + shared memory); both backends +//! implement the [`JobDispatch`] seam the ticket arena posts through. +//! [`ProcessPool`] below is the frozen pre-M15 facade stub kept for C +//! ABI parity. use std::collections::{HashMap, VecDeque}; use std::panic::{catch_unwind, AssertUnwindSafe}; @@ -52,6 +54,21 @@ fn lock(m: &Mutex) -> MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } +/// The job-dispatch seam (M15 S1): the ticket arena posts [`Job`]s +/// through this interface without knowing the backend. Implemented by +/// the in-process [`WorkerPool`] (threads) and the process-isolated +/// [`crate::procpool::ProcessDispatcher`] (oak-worker children); S2 +/// removes the thread pool and this seam becomes process-only. +pub trait JobDispatch: Send + Sync { + /// Enqueue a job; false when the backend is gone (the arena then + /// delivers the completion itself with `Error::State`). + fn post(&self, job: Job) -> bool; + + /// Stop accepting work, deliver the queued completions (cancelled) + /// and release the backend. Idempotent. + fn shutdown(&self); +} + /// Thread-pool backend (C++ RenderThread model). Cheap to clone (all /// state is behind an `Arc`); the manager and the ticket arena share one /// pool. @@ -132,6 +149,12 @@ impl WorkerPool { /// without running); running jobs are joined so no completion fires /// after shutdown returns. pub fn shutdown(&mut self) { + self.shutdown_ref(); + } + + /// [`Self::shutdown`] on a shared reference (the [`JobDispatch`] + /// seam; all state is interior-mutable). Idempotent. + pub fn shutdown_ref(&self) { // Set the flag and wake the workers while holding the queue lock. // Workers decide whether to block in `cv.wait` while holding that // lock, so a flag set outside it could land between a worker's @@ -158,6 +181,16 @@ impl WorkerPool { } } +impl JobDispatch for WorkerPool { + fn post(&self, job: Job) -> bool { + WorkerPool::post(self, job) + } + + fn shutdown(&self) { + self.shutdown_ref(); + } +} + fn worker_loop(inner: Arc) { loop { let job = { diff --git a/crates/oakrender/tests/copier_test.rs b/crates/oakrender/tests/copier_test.rs index 2bc64b36a..2fd8d3a4f 100644 --- a/crates/oakrender/tests/copier_test.rs +++ b/crates/oakrender/tests/copier_test.rs @@ -47,7 +47,10 @@ fn frame_producer() -> oakrender::ticket::Producer { fn cacher() -> (oakrender::autocacher::PreviewAutoCacher, WorkerPool) { let mut pool = WorkerPool::new(2); pool.start(); - let arena = Arc::new(TicketArena::new(pool.clone(), frame_producer())); + let arena = Arc::new(TicketArena::new( + Arc::new(pool.clone()), + frame_producer(), + )); (oakrender::autocacher::PreviewAutoCacher::new(arena), pool) } diff --git a/crates/oakrender/tests/ticket_worker_test.rs b/crates/oakrender/tests/ticket_worker_test.rs index b61a769cb..e013c12b1 100644 --- a/crates/oakrender/tests/ticket_worker_test.rs +++ b/crates/oakrender/tests/ticket_worker_test.rs @@ -83,7 +83,7 @@ impl Drop for GateRelease { fn ticket_completion_once_success() { let mut pool = WorkerPool::new(2); pool.start(); - let arena = TicketArena::new(pool.clone(), ok_producer()); + let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer()); let (tx, rx) = mpsc::channel(); let id = arena.submit_video( @@ -120,7 +120,7 @@ fn ticket_completion_once_on_cancel() { } Ok(oakrender::ticket::TicketPayload::Video(Texture::wrap_frame(small_frame()))) }); - let arena = TicketArena::new(pool.clone(), blocking); + let arena = TicketArena::new(Arc::new(pool.clone()), blocking); let (tx, rx) = mpsc::channel(); let id = arena.submit_video( @@ -159,7 +159,7 @@ fn shutdown_drains_completions() { Ok(oakrender::ticket::TicketPayload::Video(Texture::wrap_frame(small_frame()))) }) }; - let arena = TicketArena::new(pool.clone(), blocking); + let arena = TicketArena::new(Arc::new(pool.clone()), blocking); let (tx, rx) = mpsc::channel(); let mut ids = Vec::new(); @@ -197,7 +197,7 @@ fn shutdown_drains_completions() { fn pool_saturation() { let mut pool = WorkerPool::new(4); pool.start(); - let arena = TicketArena::new(pool.clone(), ok_producer()); + let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer()); let (tx, rx) = mpsc::channel(); let mut ids = Vec::new(); for i in 0..64u64 { @@ -228,7 +228,7 @@ fn pool_saturation() { fn ticket_id_monotonic() { let mut pool = WorkerPool::new(1); pool.start(); - let arena = TicketArena::new(pool.clone(), ok_producer()); + let arena = TicketArena::new(Arc::new(pool.clone()), ok_producer()); let a = arena.submit_video(params(Rational::new(0, 1)), Box::new(|_| {})); let b = arena.submit_video(params(Rational::new(1, 1)), Box::new(|_| {})); let c = arena.submit_video(params(Rational::new(2, 1)), Box::new(|_| {})); diff --git a/crates/oaktask/src/render.rs b/crates/oaktask/src/render.rs index 1d9a0b53d..525489828 100644 --- a/crates/oaktask/src/render.rs +++ b/crates/oaktask/src/render.rs @@ -660,7 +660,7 @@ impl RenderTask { oakrender::eval::render_produced_frame(time, params) .map(TicketPayload::Video) }); - let arena = Arc::new(TicketArena::new(pool.clone(), producer)); + let arena = Arc::new(TicketArena::new(Arc::new(pool.clone()), producer)); (arena, Some(pool)) } }; diff --git a/docs/zh/plans/riir/M15-render-process-isolation.md b/docs/zh/plans/riir/M15-render-process-isolation.md new file mode 100644 index 000000000..0fc2331ce --- /dev/null +++ b/docs/zh/plans/riir/M15-render-process-isolation.md @@ -0,0 +1,98 @@ +# M15:渲染进程隔离(oak-worker 真实化)设计 + +> 状态:已批准(用户 2026-08-18 提出,作为独立追加任务,不阻塞 M12 其余阶段)。 +> 前置调研:见会话调研报告(oak-worker/ipc.rs 传输层已完整、TicketArena 投递口收敛、上屏链路 6 处拷贝点)。 + +## 1. 目标(用户原文要求) + +1. oak-worker 做成真实渲染进程;**删除主进程内部渲染线程池**(oakrender `WorkerPool`),统一走"渲染进程 + 主进程"隔离模型;OFX 插件崩溃不得连累主进程。 +2. **全链路零拷贝**:主进程与 worker 之间 stdio 传控制指令、共享内存传帧;所有渲染操作在共享内存池内完成;除 GPU 上传外不做任何拷贝;渲染完直接从共享内存池零拷贝上屏。 +3. **零锁**:帧槽交接用无锁 SPSC 环(已有);调度器单线程;worker 单渲染线程。 +4. **批量认领、无工作窃取**:worker 认领一批帧(约 120 帧量级)后,这些帧不再分配给别的 worker。 +5. **调度均匀性**:相邻的帧要几乎同时渲染完——相邻帧分配给不同 worker。 + +## 2. 架构总览 + +``` +主进程 (GUI / CLI / Export) + ├── PreviewScheduler(新,oakrender/src/scheduler.rs) + │ 帧需求队列(播放窗口/seek/导出/缩略图)→ 分批 → 交织派发给 worker + ├── ProcessDispatcher(新,oakrender/src/procpool.rs) + │ N 个 WorkerHandle:spawn oak-worker、handshake、NDJSON 控制、崩溃检测重启 + ├── TicketArena(现有)—— 投递口从 WorkerPool.post 换成 ProcessDispatcher + └── ShmPool(主进程侧,ipc.rs SharedMemoryRegion 复用) + 段 0..N:每 worker 一段;段内 FrameSlotPool(free/ready SPSC 环,已有) + │ stdio NDJSON(控制面) + ▼ + oak-worker × N(渲染进程) + WorkerSession(已有骨架)+ 真实渲染栈(图反序列化/解码/合成/插件) + 渲染结果直接写入 shm 槽 → frame_ready(ticket, slot) +``` + +## 3. 关键设计决策 + +### 3.1 共享内存布局 + +- **每 worker 一个 shm 段**(主进程 create、worker attach,复用 `SharedMemoryRegion` 双模式与 `FrameSlotPool`,key 规范沿用 `olive-rw--`)。 +- **槽由主进程统一编址**:render 指令携带目标 slot id,worker 无权自行选槽 → 主进程可以把"预览缓存"直接建在槽上:预渲染帧的槽即缓存,上屏读槽即零拷贝;槽释放 = 缓存淘汰。当前帧(播放头)上屏路径:**shm 槽切片 → `queue.write_texture`**(GPU 上传是用户许可的唯一拷贝)。 +- **槽格式**:viewer 预览票请求 **BGRA8**(新 force_format;worker 在渲染管线末端 F32→BGRA8 转换后写入槽——格式转换不是拷贝);导出/全分辨率/scopes 票请求 **F32 RGBA**。槽大小 = 该段服务过的最大帧(64 对齐),段按需 ftruncate 扩容或重建。 +- **槽数**:每 worker 8 槽起步(决定单 worker 在飞帧数;内存 = N × 8 × 8.3MB(BGRA8 1080p))。 +- ⚠️ **Spike 必验**:macOS POSIX `shm_open` 单段大小上限(目标 ≥ 512MB)。不达标则回退"临时文件 + `mmap(MAP_SHARED)`"(接口封装在 `SharedMemoryRegion` 内,加 backend 枚举,协议不变)。Linux 用 POSIX shm 即可。 + +### 3.2 调度器(本任务核心,无 C++ 参照) + +- **帧需求模型**:键 = (sequence, 帧号, 参数版本[图版本/代理档/分辨率档/色彩])。来源: + 1. 播放前向窗口(默认前向 120 帧 + 后向少量,随播放头滑动); + 2. seek/当前帧(最高优先,插单帧); + 3. 导出(连续全量,经 oaktask); + 4. 缩略图/静止全分辨率帧。 +- **交织批量认领**:W 个 worker。待渲帧流按 **round-robin 分片**:worker i 认领帧号 ≡ i (mod W) 的子序列。每次握手以"批"为单位:worker 认领自己等差序列中的下 B 帧(B ≈ 120/W,可配置)。性质: + - 每帧恰好属于一个 worker(**无窃取**,满足要求 4); + - 相邻帧号落在不同 worker 上并行渲染 → 相邻帧完成时间近似相同(满足要求 5); + - worker 批内按帧号升序渲染,完成即发布(槽就绪顺序对主进程透明,主进程按帧号索引消费)。 +- **故障恢复(非窃取)**:worker 崩溃(管道 EOF/退出码非零)→ 其**未开始**的批整体回收重派;**已开始**的批中未 frame_ready 的帧标记失败并重派给健康 worker(崩溃帧重派属故障恢复,不违反无窃取)。慢 worker 不干预(防卡死优先于 fairness)。 +- **流量控制**:主进程只在目标 worker 有 free 槽时派批(槽即信用);worker 批内渲到无 free 槽时等待(SPSC 环 poll + 超时让出)。 +- **优先级**:seek/当前帧 > 播放窗口(距播放头近者优先)> 导出后台。取消 = cancel 消息 + 帧键失效(版本号 bump)。 + +### 3.3 协议(NDJSON v2,向后兼容 v1 消息名) + +已有:handshake / load_graph / render_frame / frame_ready / cancel。 +新增: +- `hello_caps`(worker→main):支持格式、最大帧尺寸(协商槽大小)。 +- `render_batch { tickets: [{ticket_id, time, slot_id, params…}] }`:一批帧 + 指定槽。 +- `batch_accepted { batch_id, tickets[] }`:认领确认(认领语义显式化)。 +- `frame_failed { ticket_id, error }`:渲染失败(主进程兜底紫帧)。 +- `shutdown` / 崩溃检测:主进程 waitpid + EOF。 + +### 3.4 拆除线程池的影响面(调研结论) + +唯一投递口 `TicketArena.pool.post`(ticket.rs:321,390)。消费者 4 个(app renderops、oak-cli、oaktask 导出、oakengine facade)API 不变。`WorkerBackend::{Threads,Processes}` 枚举已预留。oaktask 导出自建私有池改为自建私有 ProcessDispatcher(max_inflight 语义由调度器接管)。**WorkerPool 及其线程在 S2 彻底删除**(用户明确要求;单测需要的同步执行语义由 dispatcher 的 `run_inline` 测试模式提供,不保留生产线程池)。 + +### 3.5 上屏零拷贝改造(S2,src/ 侧) + +现状拷贝点(调研报告):ticket result `frame.data.clone()` → linesize 重排 → F32→BGRA8 → 缓存 → atlas write_texture。 +改后:ticket 完成返回 `ShmFrameRef{worker, slot, meta}`;viewer 预览帧(BGRA8 槽)直接切片喂 `write_texture`;scopes 分析改为读 BGRA8(精度足够)或另请 F32 票;长期缓存(静止全分辨率单帧槽、缩略图)从 shm 拷出一次(必要拷贝,槽需回收)。断言手段:`renderops` 加拷贝字节计数器,测试断言播放路径帧字节拷贝 = 0(GPU upload 除外)。 + +### 3.6 OFX 崩溃隔离 + +插件执行器(oakplugin `install_render_executor`)装在 **worker 进程**;主进程不再链接执行栈(app 只经 ticket API)。测试插件加"崩溃模式"(环境变量触发 raise(SIGSEGV))→ 验收:主进程存活、受影响帧重派、worker 自动重启、渲染结果仍正确。 + +### 3.7 音频 + +音频票同协议走 shm(AudioSamples 入槽)。S1/S2 可先保持主进程音频路径(崩溃风险主要来自视频插件),S3 迁移。 + +## 4. 分期 + +| 期 | 范围 | 验收 | +|---|---|---| +| S1(crates only) | shm spike(macOS 段上限);协议 v2;ProcessDispatcher + WorkerHandle + 崩溃重启;worker 侧真实渲染(图快照反序列化 + montage/解码/合成 + 插件执行器);PreviewScheduler(交织批量认领);与线程池**并存**(配置切换)。单测 + 集成测试(崩溃隔离/无窃取/均匀性/零拷贝计数)。 | `cargo test -p oakrender -p oak-worker` 全绿;集成测试演示 4 worker 渲 480 帧无重分配、相邻帧完成时间差有界 | +| S2 | RenderManager 默认 Processes;**删除 WorkerPool**;oaktask/oak-cli 接入;src/ 上屏零拷贝(real.rs/renderops.rs/frames.rs);app 播放前向窗口接入调度器 | `cargo test` 全绿;`cargo run` 播放流畅;拷贝计数=0;kill -SEGV worker 后播放继续 | +| S3 | 音频迁移;压测调优(B、槽数、worker 数自适应);README/docs 收尾 | 性能报告;文档 | + +## 5. 风险 + +1. **macOS POSIX shm 上限** → 3.1 回退方案。 +2. worker 内 wgpu Device:montage/合成现状为 CPU 路径,S1/S2 保持 CPU;GPU 合成后续议。 +3. 图快照一致性:GraphSnapshotStore 引用计数已有;图变更(编辑)→ 版本 bump + 重新 load_graph。 +4. 调度器位于主进程 UI tick 驱动,需避免 tick 内阻塞(全部非阻塞 poll)。 +5. oakengine facade(冻结 C ABI)引用 WorkerPool 的部分同步改为 dispatcher(非默认构建成员,但仍需编译通过)。