Files
oak-editor/crates/oakrender/src/manager.rs
T
Mike-Solar 431b9ed2b1 feat(oakrender): render-process isolation S1 - dispatcher, scheduler, real worker
Per the M15 design (docs/zh/plans/riir/M15-render-process-isolation.md):

- ipc.rs moved into oakrender with protocol v2: hello_caps,
  render_batch, batch_accepted, frame_failed; main-process-assigned
  slots; BGRA8 slot format. POSIX shm verified to 1GiB on macOS.
- ProcessDispatcher: spawns oak-worker processes, handshake, stdio
  NDJSON control, shm segment lifecycle with generation-tagged keys,
  crash detection with bounded restart and frame redispatch, zero-copy
  ShmFrameRef delivery and copy counters.
- PreviewScheduler: interleaved batch claiming (frame % W per worker,
  no work stealing), seek > playback-distance > background priority,
  credit-based flow control, crash recovery.
- oak-worker renders for real: graph snapshot deserialization, montage
  decode+composite straight into the assigned shm slot, F32->BGRA8
  final conversion in-worker, OFX plugin executor installed in-worker,
  crash hooks for isolation testing.

Thread pool coexists for now (S2 removes it). Integration tests cover
two-worker zero-copy rendering, crash isolation with redelivery, and
real H.264 footage decode into slots.
2026-08-18 18:58:38 +08:00

316 lines
9.9 KiB
Rust

// 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 <http://www.gnu.org/licenses/>.
//! The render manager: process-wide singleton owning the worker pool,
//! the ticket arena, the auto-cacher, and backend selection
//! (C++ `RenderManager`).
//!
//! The singleton lives behind a `Mutex<Option<Arc<…>>>` so `init` /
//! `shutdown` round-trips (C++ `create_instance` / `destroy_instance`),
//! and consumers share the `Arc`. `global()` returns the `Arc` (the
//! skeleton's `&'static` reference was replaced because a resettable
//! singleton cannot hand out stable references safely).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
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::{JobDispatch, WorkerPool};
static MANAGER: Mutex<Option<Arc<RenderManager>>> = Mutex::new(None);
fn lock<T>(m: &Mutex<T>) -> 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 {
/// Video job dispatch (thread pool or process dispatcher, M15).
pub dispatch: Arc<dyn JobDispatch>,
/// Audio job dispatch — kept on main-process threads until S3
/// (design §3.7: crash risk is dominated by video plugins).
pub audio_dispatch: Arc<dyn JobDispatch>,
/// Ticket arena.
pub tickets: Arc<TicketArena>,
/// Active GPU backend.
pub backend: BackendKind,
/// The backend the user requested (C++ `requested_backend`).
pub requested_backend: BackendKind,
/// Auto-cacher (None until first access; created lazily by
/// [`RenderManager::get_cacher`]).
pub autocacher: Mutex<Option<PreviewAutoCacher>>,
/// Aggressive decoder GC toggle.
aggressive_gc: AtomicBool,
}
impl RenderManager {
/// 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 producer: crate::ticket::Producer = Arc::new(|time, params| {
eval::render_produced_frame(time, params)
.map(crate::ticket::TicketPayload::Video)
});
let (dispatch, audio_dispatch): (Arc<dyn JobDispatch>, Arc<dyn JobDispatch>) =
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 {
dispatch,
audio_dispatch,
tickets,
backend,
requested_backend: backend,
autocacher: Mutex::new(None),
aggressive_gc: AtomicBool::new(false),
}));
Ok(())
}
/// Global access; `None` before init.
pub fn global() -> Option<Arc<RenderManager>> {
lock(&MANAGER).clone()
}
/// The auto-cacher, creating it on first access (C++ `get_cacher`).
pub fn get_cacher(&self) -> MutexGuard<'_, Option<PreviewAutoCacher>> {
let mut guard = lock(&self.autocacher);
if guard.is_none() {
*guard = Some(PreviewAutoCacher::new(self.tickets.clone()));
}
guard
}
/// 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();
// 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);
}
}
/// Aggressive-GC toggle (C++ `SetAggressiveGarbageCollection`).
pub fn set_aggressive_gc(&self, on: bool) {
self.aggressive_gc.store(on, Ordering::Release);
}
/// The aggressive-GC toggle.
pub fn aggressive_gc(&self) -> bool {
self.aggressive_gc.load(Ordering::Acquire)
}
/// Submit a video ticket through the manager's arena (used by the
/// auto-cacher and the FFI request path).
pub fn submit_video(
&self,
params: crate::ticket::VideoTicketParams,
done: crate::ticket::Completion,
) -> TicketId {
self.tickets.submit_video(params, done)
}
}
/// The default disk cache directory (C++ `DiskManager::
/// get_default_disk_cache_path`); shared with oaknode via oakcommon
/// (single-lib unification).
pub fn disk_cache_path() -> String {
oakcommon::filefunctions::default_disk_cache_path()
}
/// Bytes consumed by the default disk cache folder (direct filesystem
/// scan; the C++ DiskManager index is replaced by the folder walk).
pub fn disk_cache_size() -> Result<i64> {
let path = disk_cache_path();
let root = std::path::Path::new(&path);
if !root.exists() {
return Ok(0);
}
let mut total: i64 = 0;
for entry in walk(root) {
total = total.saturating_add(entry.metadata().map(|m| m.len() as i64).unwrap_or(0));
}
Ok(total)
}
/// Clear the default disk cache folder (C++ `DiskManager::
/// clear_disk_cache`).
pub fn disk_cache_clear() -> Result<()> {
let path = disk_cache_path();
let root = std::path::Path::new(&path);
if root.exists() {
std::fs::remove_dir_all(root)
.map_err(|e| Error::Failed(format!("clear disk cache: {e}")))?;
}
std::fs::create_dir_all(root)
.map_err(|e| Error::Failed(format!("recreate disk cache: {e}")))?;
Ok(())
}
/// Recursively walk a directory (files only).
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk(&path));
} else {
out.push(path);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
/// Serializes the manager-singleton tests (the singleton is global).
static MANAGER_TEST_LOCK: Mutex<()> = Mutex::new(());
fn manager_lock() -> MutexGuard<'static, ()> {
MANAGER_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn init_shutdown_roundtrip() {
let _lock = manager_lock();
// Ensure a clean slate.
RenderManager::shutdown();
RenderManager::init().unwrap();
assert!(RenderManager::global().is_some());
// Idempotence: second init is a state error.
assert_eq!(
RenderManager::init().unwrap_err().code(),
Error::State.code()
);
RenderManager::shutdown();
assert!(RenderManager::global().is_none());
// Re-init works after shutdown (C++ destroy_instance semantics).
RenderManager::init().unwrap();
RenderManager::shutdown();
}
#[test]
fn aggressive_gc_toggle() {
let _lock = manager_lock();
RenderManager::shutdown();
RenderManager::init().unwrap();
let m = RenderManager::global().unwrap();
assert!(!m.aggressive_gc());
m.set_aggressive_gc(true);
assert!(m.aggressive_gc());
RenderManager::shutdown();
}
#[test]
fn cacher_is_lazily_created() {
let _lock = manager_lock();
RenderManager::shutdown();
RenderManager::init().unwrap();
let m = RenderManager::global().unwrap();
{
let g = m.get_cacher();
assert!(g.is_some());
}
RenderManager::shutdown();
}
#[test]
fn disk_cache_size_and_clear() {
let _guard = crate::commonutil::ENV_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("oakrender-diskcache-test");
std::env::set_var("OAK_CONFIG_DIR", &dir);
std::fs::create_dir_all(dir.join("mediacache").join("sub")).unwrap();
std::fs::write(dir.join("mediacache").join("sub").join("a.bin"), [1u8; 100]).unwrap();
assert_eq!(disk_cache_size().unwrap(), 100);
disk_cache_clear().unwrap();
assert_eq!(disk_cache_size().unwrap(), 0);
assert!(dir.join("mediacache").exists());
std::fs::remove_dir_all(&dir).ok();
std::env::remove_var("OAK_CONFIG_DIR");
}
#[test]
fn disk_cache_size_missing_dir_is_zero() {
let _guard = crate::commonutil::ENV_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("oakrender-diskcache-missing");
std::env::set_var("OAK_CONFIG_DIR", &dir);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(disk_cache_size().unwrap(), 0);
std::env::remove_var("OAK_CONFIG_DIR");
}
}