// 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 segment —
//! POSIX `shm_open` + `mmap` on Unix (`munmap` + `shm_unlink` on
//! close), `CreateFileMappingW`/`OpenFileMappingW` + `MapViewOfFile`
//! on Windows (`UnmapViewOfFile` + `CloseHandle` 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)]
#[cfg(windows)]
use std::ffi::c_void;
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";
/// `"render_audio_batch"` (protocol v2, M15 S3): a batch of audio range
/// pulls rendered into the same shm slot transport as video frames.
pub const TYPE_RENDER_AUDIO_BATCH: &str = "render_audio_batch";
/// `"plugin_progress"` (protocol v2): worker->main — one OFX plugin
/// progress event (progressStart/Update/End forwarded over the control
/// plane). The main process drains it into the plugin-progress dialog.
pub const TYPE_PLUGIN_PROGRESS: &str = "plugin_progress";
/// `"plugin_cancel"` (protocol v2): main->worker — the user cancelled the
/// plugin render. The worker sets its sticky cancel flag; every live
/// progress reporter then answers false (the plugin aborts at its next
/// progressUpdate).
pub const TYPE_PLUGIN_CANCEL: &str = "plugin_cancel";
/// 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;
/// Wire-format slot format for interleaved f32 audio samples (M15 S3).
/// An audio slot reuses [`FrameSlotMeta`]: `format` is this marker,
/// `channel_count` is the channel count, `linesize` is
/// `channels * 4` (bytes per sample frame), `data_size` is the total
/// sample bytes and `width` carries the output sample rate (Hz). The
/// value lives outside the `PixelFormat` enum range like
/// [`SLOT_FORMAT_BGRA8`].
pub const SLOT_FORMAT_AUDIO_F32: i32 = 101;
/// `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,
}
/// A node value on the wire (protocol v2, montage effect parameters).
/// `oak_node::value::NodeValue` itself is not serde-able (texture/sample
/// payloads, handles); this enum covers the plain-data variants an effect
/// parameter can carry. Connection/handle variants (texture, samples,
/// node refs, video/audio params, push buttons) have no wire
/// representation and are dropped by [`WireNodeValue::from_node_value`].
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(tag = "t", content = "v", rename_all = "snake_case")]
pub enum WireNodeValue {
/// No value.
None,
/// Integer.
Int(i64),
/// Float.
Float(f64),
/// RGBA color.
Color([f64; 4]),
/// Text.
Text(String),
/// Boolean.
Boolean(bool),
/// Rational (num, den).
Rational(i64, i64),
/// Vec2.
Vec2([f64; 2]),
/// Vec3.
Vec3([f64; 3]),
/// Vec4.
Vec4([f64; 4]),
/// 4x4 matrix, row-major.
Matrix([f64; 16]),
/// Combo index.
Combo(i64),
/// String combo.
StrCombo(String),
/// Opaque bytes.
Binary(Vec),
}
impl Default for WireNodeValue {
fn default() -> Self {
WireNodeValue::None
}
}
impl WireNodeValue {
/// The wire form of a node value, or `None` when the variant has no
/// wire representation (the parameter is then not carried).
pub fn from_node_value(v: &oak_node::value::NodeValue) -> Option {
use oak_node::value::NodeValue as NV;
Some(match v {
NV::None => WireNodeValue::None,
NV::Int(i) => WireNodeValue::Int(*i),
NV::Float(f) => WireNodeValue::Float(*f),
NV::Color(c) => WireNodeValue::Color(*c),
NV::Text(s) => WireNodeValue::Text(s.clone()),
NV::Boolean(b) => WireNodeValue::Boolean(*b),
NV::Rational(r) => WireNodeValue::Rational(r.numerator(), r.denominator()),
NV::Vec2(v2) => WireNodeValue::Vec2(*v2),
NV::Vec3(v3) => WireNodeValue::Vec3(*v3),
NV::Vec4(v4) => WireNodeValue::Vec4(*v4),
NV::Matrix(m) => WireNodeValue::Matrix(*m),
NV::Combo(i) => WireNodeValue::Combo(*i),
NV::StrCombo(s) => WireNodeValue::StrCombo(s.clone()),
NV::Binary(b) => WireNodeValue::Binary(b.clone()),
_ => return None,
})
}
/// Back to a node value (worker side).
pub fn to_node_value(&self) -> oak_node::value::NodeValue {
use oak_node::value::NodeValue as NV;
match self {
WireNodeValue::None => NV::None,
WireNodeValue::Int(i) => NV::Int(*i),
WireNodeValue::Float(f) => NV::Float(*f),
WireNodeValue::Color(c) => NV::Color(*c),
WireNodeValue::Text(s) => NV::Text(s.clone()),
WireNodeValue::Boolean(b) => NV::Boolean(*b),
WireNodeValue::Rational(n, d) => NV::Rational(oak_core::Rational::new(*n, *d)),
WireNodeValue::Vec2(v2) => NV::Vec2(*v2),
WireNodeValue::Vec3(v3) => NV::Vec3(*v3),
WireNodeValue::Vec4(v4) => NV::Vec4(*v4),
WireNodeValue::Matrix(m) => NV::Matrix(*m),
WireNodeValue::Combo(i) => NV::Combo(*i),
WireNodeValue::StrCombo(s) => NV::StrCombo(s.clone()),
WireNodeValue::Binary(b) => NV::Binary(b.clone()),
}
}
}
/// One effect parameter on the wire (input id + value).
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct WireEffectParam {
/// Node input id.
pub input: String,
/// Parameter value.
pub value: WireNodeValue,
}
/// One effect of a montage clip's effect stack on the wire (protocol v2
/// additive field of [`WireMontageClip`]; source-first order).
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct WireMontageEffect {
/// Built-in node type id or OFX plugin identifier.
pub type_id: String,
/// Enabled flag (disabled effects are bypassed).
pub enabled: bool,
/// Effect input (clip) name; "" = none.
pub effect_input_id: String,
/// Parameter values.
pub params: Vec,
}
/// 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,
/// The clip's effect stack (protocol v2 additive: older peers omit the
/// field and it defaults to an empty stack).
pub effects: Vec,
}
/// Map a ticket-side montage effect to its wire form (main process;
/// parameters without a wire representation are dropped).
pub fn wire_effect_from(effect: &crate::ticket::MontageEffect) -> WireMontageEffect {
WireMontageEffect {
type_id: effect.type_id.clone(),
enabled: effect.enabled,
effect_input_id: effect.effect_input_id.clone().unwrap_or_default(),
params: effect
.params
.iter()
.filter_map(|(input, value)| {
WireNodeValue::from_node_value(value).map(|value| WireEffectParam {
input: input.clone(),
value,
})
})
.collect(),
}
}
/// Map a wire effect back to the ticket-side form (worker).
pub fn montage_effect_from(wire: &WireMontageEffect) -> crate::ticket::MontageEffect {
crate::ticket::MontageEffect {
type_id: wire.type_id.clone(),
enabled: wire.enabled,
effect_input_id: if wire.effect_input_id.is_empty() {
None
} else {
Some(wire.effect_input_id.clone())
},
params: wire
.params
.iter()
.map(|p| (p.input.clone(), p.value.to_node_value()))
.collect(),
}
}
/// 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,
/// Sequence viewer node identity (0 = montage mode; nonzero = render
/// the viewer's graph frame from the worker's loaded snapshot).
pub viewer_node: u64,
/// The owning project's uuid (M16 S1): the worker renders the viewer's
/// graph frame only when this matches the loaded snapshot's project
/// ("" = no graph mode).
pub project_key: String,
}
/// `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,
}
/// One audio range pull inside a [`RenderAudioBatchMsg`] (M15 S3) — the
/// audio counterpart of [`BatchTicketSpec`]: the main process assigns the
/// destination `slot`, the worker mixes the montage over `[time,
/// time + duration)` at `sample_rate`/`channel_layout` into interleaved
/// f32 and writes it into the slot (wire format
/// [`SLOT_FORMAT_AUDIO_F32`]).
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct AudioTicketSpec {
/// Ticket id (correlates with frame_ready / frame_failed).
pub ticket: i64,
/// Destination slot index in the worker->main output pool.
pub slot: i32,
/// Range start numerator.
pub time_num: i64,
/// Range start denominator.
pub time_den: i64,
/// Range length numerator.
pub duration_num: i64,
/// Range length denominator.
pub duration_den: i64,
/// Output sample rate (Hz).
pub sample_rate: i32,
/// Output channel layout mask.
pub channel_layout: u64,
/// Channel count (derived from the layout; written into the slot meta).
pub channels: i32,
/// Sequence montage (ordered topmost-last; empty = silence).
pub montage: Vec,
}
/// `render_audio_batch` (main->worker, M15 S3) — a batch of audio range
/// pulls rendered in order, sharing the claim/credit/frame_ready flow of
/// [`RenderBatchMsg`].
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct RenderAudioBatchMsg {
/// Batch id (correlates with batch_accepted).
pub batch_id: i64,
/// The audio tickets, rendered in order.
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,
}
/// `plugin_progress` (worker->main) — one OFX plugin progress event
/// forwarded over the control plane.
///
/// The oakplugin progress suite runs in the worker process (plugin
/// rendering is process-isolated); the worker installs a progress reporter
/// factory whose reporters push these messages to stdout. Wire shape is
/// intentionally the same as the main-process `PluginProgressEvent`:
/// progressStart arrives with fraction 0 and label/message set,
/// progressUpdate with the fraction, progressEnd with fraction 1.0 (the
/// app closes the dialog on >= 1.0, mirroring the main-process reporter —
/// the `UiProgressReporter` trait has no end hook, so completion is
/// inferred from the fraction there too).
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct PluginProgressMsg {
/// The plugin's progressStart label.
pub label: String,
/// The plugin's progressStart message.
pub message: String,
/// Progress fraction in 0.0..=1.0.
pub fraction: f64,
}
impl PluginProgressMsg {
/// Build the wire `plugin_progress` value.
pub fn to_json(&self) -> Value {
json!({
"type": TYPE_PLUGIN_PROGRESS,
"label": self.label,
"message": self.message,
"fraction": self.fraction,
})
}
}
/// The wire `plugin_cancel` message (main->worker; no payload).
pub fn plugin_cancel_json() -> Value {
json!({ "type": TYPE_PLUGIN_CANCEL })
}
/// 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 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.
///
/// Two backends: POSIX `shm_open`/`mmap`/`munmap`/`shm_unlink` on Unix,
/// and the Win32 file-mapping API on Windows
/// (`CreateFileMappingW`/`OpenFileMappingW` + `MapViewOfFile`).
///
/// **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 mapped data pointer; null when invalid.
data: *mut u8,
/// File descriptor from `shm_open` (-1 when invalid). Unix only.
#[cfg(unix)]
fd: i32,
/// File-mapping handle from `CreateFileMappingW`/`OpenFileMappingW`
/// (null when invalid). Windows only.
#[cfg(windows)]
mapping: *mut c_void,
/// Open mode.
mode: ShmMode,
/// Human-readable reason of the last failed open.
error: String,
/// The platform-prefixed name actually passed to `shm_open`. Unix only.
#[cfg(unix)]
shm_name: String,
}
/// Owned segment keys that are still mapped when the process exits. Test
/// binaries finish via `std::process::exit` (libtest), which skips Rust
/// static destructors — the process-wide render-manager singleton never
/// runs `Drop`, its `shm_unlink` never fires, and every test run leaks one
/// ~66 MiB segment per worker until `/dev/shm` fills up (the next create
/// then `memset`s a mapping backed by a full tmpfs and faults with SIGBUS).
/// `libc::atexit` handlers DO run under `process::exit`, so each `Create`
/// registers its key here and [`SharedMemoryRegion::atexit_cleanup_owned_shm`]
/// unlinks them all at exit. Unlinking while a peer still maps the segment
/// is safe — POSIX only removes the name; the mapping lives until the last
/// `munmap` (the workers attach without owning, so they never register).
#[cfg(unix)]
static OWNED_SHM_KEYS: std::sync::Mutex