render: the M3 OFX host — one oak-worker --ofx-host process for every plugin job

docs/zh/plans/render-pipeline-threads.md M3 (design 3.2): OpenFX crash
isolation moves from "every worker hosts plugins" to a single dedicated
host process, served over NDJSON + shared memory.

- oak-worker --ofx-host mode (src/ofx_host.rs): loads every plugin once,
  resolves jobs by the cross-process-stable OFX identifier, and renders
  through the same in-process executor the workers used to install.
- oak-render/ofxhost.rs: the single-host client. The render manager
  creates and installs it for the Pipeline backend (lazy spawn on the
  first plugin job); eval::process_plugin_job prefers it and falls back
  to the in-process executor otherwise, so the process backend keeps its
  current behavior until M4.
- Data plane: input/output FrameSlotPool pairs (the handshake's input_*
  fields are used for the first time). Named clips and the source frame
  are written to input slots after the explicit CPU readback; the plugin
  output returns through an output slot. Pool size/capacity grow by a
  host restart when a job needs more (safe: submissions are serialized
  and one job is in flight).
- Crash loop: reader EOF fails the in-flight submit, which respawns the
  host and re-posts the same job (frames are read back once); after three
  consecutive crashes the client is permanently dead and the evaluator
  falls back to a purple frame. The dead child is reaped immediately, and
  a submit mutex enforces the one-job-in-flight contract.
- Progress/cancel: the host flushes plugin_progress immediately (live
  progress), and reads stdin on its own thread so plugin_cancel takes
  effect mid-render at the plugin's next progressUpdate; the sticky flag
  resets at progressStart and request_plugin_cancel_all broadcasts to
  both the worker pool and the host.
- JobSpec::Plugin / PluginJobPayload carry the plugin type_id (stable
  across processes); `--ofx-crash-once` / `--ofx-crash-always` are the
  deterministic crash hooks, matching the worker's env hooks.
- Tests: wire round-trips; host unit tests (crash budget, cancel-flag
  reset through the factory, source mapping); oak-worker integration
  tests against the real host + bundled test plugin (render + progress,
  crash respawn and re-post, three-crash give-up, mid-render cancel on
  the new slow variant, concurrent submits); eval's purple fallback.
This commit is contained in:
2026-09-12 23:10:43 +08:00
parent 4337559ed0
commit fec6e9dba7
14 changed files with 2054 additions and 27 deletions
+82 -19
View File
@@ -142,6 +142,10 @@ pub enum JobSpec {
Plugin {
/// Plugin instance identity (oakplugin instance registry key).
instance: u64,
/// OFX plugin identifier (cross-process stable). The single OFX
/// host process (M3) resolves its own instance from this; the
/// in-process executor ignores it and uses `instance`.
type_id: String,
/// Request time in seconds (C++ `PluginJob` time).
time: f64,
/// Clip name the main source texture arrives on (C++
@@ -401,13 +405,16 @@ impl RenderEvalHooks {
Ok(())
}
/// C++ process_plugin_job: dispatch through the installed plugin
/// executor (the oakplugin render driver; dependency inversion). A
/// missing executor or a failed render yields a purple failure frame
/// instead of aborting the graph, matching pluginjob.cpp's fallback.
/// C++ process_plugin_job: dispatch through the single OFX host
/// process when one is installed (M3), else through the in-process
/// plugin executor (the oakplugin render driver; dependency
/// inversion). A missing host/executor or a failed render yields a
/// purple failure frame instead of aborting the graph, matching
/// pluginjob.cpp's fallback.
fn process_plugin_job(&mut self, src: Texture, spec: &JobSpec) -> Result<Texture> {
let JobSpec::Plugin {
instance,
type_id,
time,
effect_input_id,
inputs,
@@ -417,10 +424,21 @@ impl RenderEvalHooks {
return Err(Error::Invalid);
};
let size = src.size();
if let Some(host) = crate::ofxhost::client() {
return match host.submit(spec, &src) {
Ok(frame) => Ok(Texture::wrap_frame(frame)),
Err(err) => {
eprintln!(
"OFX host job {type_id} (instance {instance}) at t={time}s failed: {err:#}"
);
Ok(purple_frame(Rational::from_double(*time), size))
}
};
}
let Some(executor) = plugin_executor() else {
return Ok(purple_frame(Rational::from_double(*time), size));
};
let _ = (instance, effect_input_id, inputs, values);
let _ = (instance, type_id, effect_input_id, inputs, values);
match executor(&PluginJobRequest { spec, src }) {
Ok(texture) => Ok(texture),
Err(err) => {
@@ -602,6 +620,7 @@ impl RenderEvalHooks {
let spec = JobSpec::Plugin {
instance: payload.instance.0,
type_id: payload.type_id.clone(),
time: payload.time.to_f64(),
effect_input_id: if payload.effect_input_id.is_empty() {
None
@@ -2867,23 +2886,31 @@ fn apply_montage_effect(
// plugin identifier; the rendering process resolves it to a live
// instance through the oakplugin-installed factory (lazily created
// and cached per identifier), then dispatches through the plugin
// executor exactly like the graph path's plugin jobs.
let Some(factory) = plugin_instance_factory() else {
warn_unsupported_once(
&effect.type_id,
"no plugin instance factory installed (oakplugin init missing in this process)",
);
return src;
};
let Some(instance) = factory(&effect.type_id) else {
warn_unsupported_once(
&effect.type_id,
"no evaluator: unknown built-in effect or OFX plugin unavailable in this process",
);
return src;
// executor exactly like the graph path's plugin jobs. When the single
// OFX host client is installed the local instance is not needed: the
// host resolves the identifier itself.
let instance = if crate::ofxhost::client().is_some() {
0
} else {
let Some(factory) = plugin_instance_factory() else {
warn_unsupported_once(
&effect.type_id,
"no plugin instance factory installed (oakplugin init missing in this process)",
);
return src;
};
let Some(instance) = factory(&effect.type_id) else {
warn_unsupported_once(
&effect.type_id,
"no evaluator: unknown built-in effect or OFX plugin unavailable in this process",
);
return src;
};
instance
};
let spec = JobSpec::Plugin {
instance,
type_id: effect.type_id.clone(),
time: time.to_f64(),
effect_input_id: effect.effect_input_id.clone(),
inputs: Vec::new(),
@@ -3113,6 +3140,7 @@ mod tests {
fn plugin_spec() -> JobSpec {
JobSpec::Plugin {
instance: 7,
type_id: "org.oak.test-plugin".into(),
time: 0.5,
effect_input_id: Some("Source".into()),
inputs: Vec::new(),
@@ -3134,6 +3162,7 @@ mod tests {
#[test]
fn plugin_job_without_executor_yields_purple_frame() {
let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
crate::ofxhost::install_client(None);
set_plugin_executor(None);
let mut hooks = RenderEvalHooks::new();
let src = Texture::wrap_frame(
@@ -3147,6 +3176,7 @@ mod tests {
#[test]
fn plugin_job_executor_error_falls_back_to_purple() {
let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
crate::ofxhost::install_client(None);
set_plugin_executor(Some(Arc::new(|_req: &PluginJobRequest<'_>| {
Err(Error::Failed("boom".into()))
})));
@@ -3159,9 +3189,40 @@ mod tests {
set_plugin_executor(None);
}
/// M3 acceptance: a host that cannot come up (or crashes past its
/// budget) yields the purple failure frame, exactly like a failing
/// in-process executor.
#[test]
#[cfg(unix)]
fn plugin_job_host_failure_yields_purple_frame() {
let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
// An executor that would succeed, to prove the host result wins.
set_plugin_executor(Some(Arc::new(|_req: &PluginJobRequest<'_>| {
Ok(Texture::wrap_frame(
generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap(),
))
})));
let host = crate::ofxhost::OfxHost::new(crate::ofxhost::OfxHostConfig {
host_bin: Some(std::path::PathBuf::from("/bin/false")),
max_failures: 1,
..Default::default()
})
.unwrap();
crate::ofxhost::install_client(Some(host));
let mut hooks = RenderEvalHooks::new();
let src = Texture::wrap_frame(
generate_frame(Rational::new(0, 1), (2, 2), PixelFormat::F32).unwrap(),
);
let out = hooks.process_plugin_job(src, &plugin_spec()).unwrap();
assert_eq!(first_pixel(&out), [1.0, 0.0, 1.0, 1.0]);
crate::ofxhost::install_client(None);
set_plugin_executor(None);
}
#[test]
fn plugin_job_dispatches_through_installed_executor() {
let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
crate::ofxhost::install_client(None);
set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| {
// Echo: paint the source size with the instance id.
let JobSpec::Plugin { instance, .. } = req.spec else {
@@ -3190,6 +3251,7 @@ mod tests {
use oak_node::nodes::plugin::{PluginInstanceHandle, PluginJobPayload};
let _guard = PLUGIN_TEST_LOCK.lock().unwrap();
crate::ofxhost::install_client(None);
set_plugin_executor(Some(Arc::new(|req: &PluginJobRequest<'_>| {
let JobSpec::Plugin {
instance,
@@ -3227,6 +3289,7 @@ mod tests {
values.insert("gain".into(), NodeValue::Float(0.25));
let payload = PluginJobPayload {
instance: PluginInstanceHandle(7),
type_id: "org.oak.test-plugin".into(),
time: Rational::new(1, 2),
effect_input_id: "Source".into(),
values,