vst3_host/process_isolation.rs
1//! Process isolation for VST3 plugin hosting
2//!
3//! This module provides functionality to run VST3 plugins in separate processes
4//! for improved stability and crash protection.
5
6use serde::{Deserialize, Serialize};
7use std::io::{BufRead, BufReader, Write};
8use std::process::{Child, ChildStdin, Command, Stdio};
9use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
10use std::thread::JoinHandle;
11use std::time::Duration;
12
13/// Default time to wait for a helper response before treating the plugin as hung.
14pub(crate) const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
15
16/// Commands that can be sent to the isolated plugin process.
17///
18/// This enum is the single source of truth for the isolation IPC protocol — the
19/// helper binary imports it from here rather than redefining it, so the two halves
20/// can never drift apart.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub enum HostCommand {
23 /// Load a plugin from the specified path, configured for the given audio settings.
24 LoadPlugin {
25 /// Path to the `.vst3` bundle.
26 path: String,
27 /// Sample rate to configure the plugin for.
28 sample_rate: f64,
29 /// Block size to configure the plugin for.
30 block_size: u32,
31 /// Transport tempo (BPM) to advertise in the plugin's host `ProcessContext`.
32 tempo: f64,
33 /// Time signature numerator to advertise in the host `ProcessContext`.
34 time_sig_numerator: i32,
35 /// Time signature denominator to advertise in the host `ProcessContext`.
36 time_sig_denominator: i32,
37 },
38 /// Unload the current plugin
39 UnloadPlugin,
40 /// Create plugin GUI
41 CreateGui,
42 /// Close plugin GUI
43 CloseGui,
44 /// Start the plugin's audio processing.
45 StartProcessing,
46 /// Stop the plugin's audio processing.
47 StopProcessing,
48 /// Set a parameter (normalized 0.0..=1.0).
49 SetParameter {
50 /// Parameter id.
51 id: u32,
52 /// Normalized value.
53 value: f64,
54 },
55 /// Schedule a parameter change at a sample offset within the next process block.
56 SetParameterAt {
57 /// Parameter id.
58 id: u32,
59 /// Normalized value.
60 value: f64,
61 /// Sample offset within the next processed block.
62 offset: i32,
63 },
64 /// Read a parameter's current normalized value.
65 GetParameter {
66 /// Parameter id.
67 id: u32,
68 },
69 /// Read all parameters.
70 GetAllParameters,
71 /// Ask the plugin to format a normalized value as a display string.
72 FormatParameter {
73 /// Parameter id.
74 id: u32,
75 /// Normalized value to format.
76 normalized: f64,
77 },
78 /// Send a MIDI event to the plugin.
79 SendMidi {
80 /// The event to deliver.
81 event: crate::midi::MidiEvent,
82 },
83 /// Process one block of audio. `inputs` is per-channel; `frames` is the block length.
84 Process {
85 /// Per-channel input samples (`[channel][frame]`).
86 inputs: Vec<Vec<f32>>,
87 /// Number of frames in this block.
88 frames: u32,
89 },
90 /// Serialize the plugin's current state to an opaque byte blob.
91 SaveState,
92 /// Restore the plugin's state from a blob previously returned by `SaveState`.
93 LoadState {
94 /// The opaque state bytes.
95 data: Vec<u8>,
96 },
97 /// Shutdown the helper process
98 Shutdown,
99}
100
101/// Responses from the isolated plugin process
102#[derive(Debug, Serialize, Deserialize)]
103pub enum HostResponse {
104 /// Operation succeeded with message
105 Success {
106 /// Human-readable success detail.
107 message: String,
108 },
109 /// Operation failed with error
110 Error {
111 /// Error detail.
112 message: String,
113 },
114 /// Plugin crashed
115 Crashed {
116 /// Crash detail.
117 message: String,
118 },
119 /// Per-channel audio output data (`[channel][frame]`), plus any MIDI the plugin
120 /// emitted during the block (arpeggiators, MPE, etc.).
121 AudioOutput {
122 /// Output samples per channel.
123 outputs: Vec<Vec<f32>>,
124 /// MIDI events the plugin emitted this block, in order.
125 output_midi: Vec<crate::midi::MidiEvent>,
126 },
127 /// A single parameter value (normalized).
128 ParameterValue {
129 /// Normalized value.
130 value: f64,
131 },
132 /// A formatted parameter display string.
133 ParameterString {
134 /// The plugin-rendered display string.
135 value: String,
136 },
137 /// A list of parameters.
138 Parameters {
139 /// All parameters reported by the plugin.
140 params: Vec<crate::parameters::Parameter>,
141 },
142 /// Opaque plugin state bytes (reply to `SaveState`).
143 State {
144 /// The serialized state.
145 data: Vec<u8>,
146 },
147 /// The isolated editor window was created (reply to `CreateGui`); carries the
148 /// plugin-reported editor size so the host can report it without a second round-trip.
149 GuiCreated {
150 /// Editor width in pixels.
151 width: i32,
152 /// Editor height in pixels.
153 height: i32,
154 },
155 /// Plugin information
156 PluginInfo {
157 /// Vendor / manufacturer.
158 vendor: String,
159 /// Plugin name.
160 name: String,
161 /// Version string (may be empty if the plugin doesn't report one).
162 version: String,
163 /// Plugin sub-categories (e.g. "Fx", "Instrument|Synth"); may be empty.
164 category: String,
165 /// Unique plugin class id (hex).
166 uid: String,
167 /// Whether the plugin has an editor.
168 has_gui: bool,
169 /// Audio input bus count.
170 audio_inputs: i32,
171 /// Audio output bus count.
172 audio_outputs: i32,
173 /// Total output audio channels across all output buses.
174 output_channels: i32,
175 /// Whether the plugin has a MIDI/event input bus.
176 has_midi_input: bool,
177 /// Whether the plugin has a MIDI/event output bus.
178 has_midi_output: bool,
179 },
180}
181
182/// Manages a plugin running in an isolated process.
183///
184/// Responses are read on a background thread and delivered over a channel, so
185/// [`Self::send_command`] can wait with a deadline: a hung plugin yields a timeout
186/// error (and the child is killed) instead of blocking the host forever, and a
187/// crashed helper surfaces as a disconnect error rather than a silent wedge.
188pub struct PluginHostProcess {
189 process: Option<Child>,
190 stdin: Option<ChildStdin>,
191 /// Lines received from the helper's stdout (one JSON response each).
192 responses: Receiver<String>,
193 /// Background reader thread handle (joined on shutdown).
194 reader: Option<JoinHandle<()>>,
195 /// How long to wait for a single response before declaring a timeout.
196 timeout: Duration,
197 /// Set once the child has been killed/exited so we stop trying to talk to it.
198 dead: bool,
199}
200
201impl PluginHostProcess {
202 /// Create a new isolated plugin host process
203 pub fn new(
204 helper_override: Option<std::path::PathBuf>,
205 timeout: Duration,
206 ) -> Result<Self, String> {
207 // An explicit helper path (builder option or the VST3_HOST_HELPER_PATH env var) wins
208 // over the heuristic search below — and a missing one is reported clearly here.
209 let override_path = helper_override
210 .or_else(|| std::env::var_os("VST3_HOST_HELPER_PATH").map(std::path::PathBuf::from));
211 if let Some(p) = override_path {
212 if !p.exists() {
213 return Err(format!(
214 "Configured helper path does not exist: {}",
215 p.display()
216 ));
217 }
218 return Self::spawn(p, timeout);
219 }
220
221 // Get the path to our helper executable
222 let exe_path =
223 std::env::current_exe().map_err(|e| format!("Failed to get current exe: {}", e))?;
224
225 let exe_dir = exe_path.parent().ok_or("Failed to get exe directory")?;
226
227 // Try different possible helper names and locations
228 let helper_names = ["vst3-host-helper", "vst3-inspector-helper"];
229 let mut helper_path = None;
230
231 // First try in the same directory as the executable
232 for name in &helper_names {
233 let path = exe_dir.join(name);
234 if path.exists() {
235 helper_path = Some(path);
236 break;
237 }
238 }
239
240 // If not found and we're in an examples directory, try parent
241 if helper_path.is_none() && exe_dir.file_name() == Some(std::ffi::OsStr::new("examples")) {
242 if let Some(parent_dir) = exe_dir.parent() {
243 for name in &helper_names {
244 let path = parent_dir.join(name);
245 if path.exists() {
246 helper_path = Some(path);
247 break;
248 }
249 }
250 }
251 }
252
253 // Also check common cargo target directories
254 if helper_path.is_none() {
255 // Try to find the workspace root and look in target/debug or target/release
256 let mut current_dir = exe_dir;
257 while let Some(parent) = current_dir.parent() {
258 let debug_path = parent.join("target").join("debug").join("vst3-host-helper");
259 let release_path = parent
260 .join("target")
261 .join("release")
262 .join("vst3-host-helper");
263
264 if debug_path.exists() {
265 helper_path = Some(debug_path);
266 break;
267 } else if release_path.exists() {
268 helper_path = Some(release_path);
269 break;
270 }
271
272 // Check if we've reached a Cargo.toml (workspace root)
273 if parent.join("Cargo.toml").exists() {
274 break;
275 }
276 current_dir = parent;
277 }
278 }
279
280 let helper_path = helper_path
281 .ok_or_else(|| format!("Helper executable not found. Searched in {:?} and parent directories. Make sure to build with --bins flag.", exe_dir))?;
282
283 Self::spawn(helper_path, timeout)
284 }
285
286 /// Spawn the helper at `helper_path` and wire up the response reader thread.
287 fn spawn(helper_path: std::path::PathBuf, timeout: Duration) -> Result<Self, String> {
288 let mut child = Command::new(&helper_path)
289 .stdin(Stdio::piped())
290 .stdout(Stdio::piped())
291 .stderr(Stdio::inherit())
292 .spawn()
293 .map_err(|e| format!("Failed to spawn helper process: {}", e))?;
294
295 let stdin = child.stdin.take().ok_or("Failed to get stdin")?;
296 let stdout = child.stdout.take().ok_or("Failed to get stdout")?;
297
298 // Read responses on a background thread so the caller can apply a deadline.
299 // The thread ends (dropping the sender) when stdout hits EOF — i.e. when the
300 // helper process exits or crashes — which the receiver sees as Disconnected.
301 let (tx, rx) = mpsc::channel::<String>();
302 let reader = std::thread::spawn(move || {
303 let mut reader = BufReader::new(stdout);
304 let mut line = String::new();
305 loop {
306 line.clear();
307 match reader.read_line(&mut line) {
308 Ok(0) => break, // EOF: helper exited
309 Ok(_) => {
310 if tx.send(std::mem::take(&mut line)).is_err() {
311 break; // receiver dropped
312 }
313 }
314 Err(_) => break,
315 }
316 }
317 });
318
319 Ok(Self {
320 process: Some(child),
321 stdin: Some(stdin),
322 responses: rx,
323 reader: Some(reader),
324 timeout,
325 dead: false,
326 })
327 }
328
329 /// Set how long to wait for a helper response before declaring a timeout.
330 pub fn set_timeout(&mut self, timeout: Duration) {
331 self.timeout = timeout;
332 }
333
334 /// Send a command to the helper process and wait (with a deadline) for a response.
335 ///
336 /// Returns an error without blocking indefinitely if the plugin hangs (the child
337 /// is killed) or the helper has crashed/exited.
338 pub fn send_command(&mut self, command: HostCommand) -> Result<HostResponse, String> {
339 if self.dead {
340 return Err("Helper process is no longer running".to_string());
341 }
342
343 let command_json = serde_json::to_string(&command)
344 .map_err(|e| format!("Failed to serialize command: {}", e))?;
345
346 {
347 let stdin = self.stdin.as_mut().ok_or("No stdin available")?;
348 writeln!(stdin, "{}", command_json).map_err(|e| {
349 self.dead = true;
350 format!("Failed to write command (helper gone?): {}", e)
351 })?;
352 stdin.flush().map_err(|e| {
353 self.dead = true;
354 format!("Failed to flush stdin (helper gone?): {}", e)
355 })?;
356 }
357
358 match self.responses.recv_timeout(self.timeout) {
359 Ok(line) => {
360 serde_json::from_str(&line).map_err(|e| format!("Failed to parse response: {}", e))
361 }
362 Err(RecvTimeoutError::Timeout) => {
363 // The plugin is hung. Kill the child so it can't wedge us further.
364 self.dead = true;
365 if let Some(ref mut process) = self.process {
366 let _ = process.kill();
367 }
368 Err(format!(
369 "Timed out after {:?} waiting for helper response (plugin may have hung)",
370 self.timeout
371 ))
372 }
373 Err(RecvTimeoutError::Disconnected) => {
374 // Reader thread ended => stdout closed => helper exited/crashed.
375 self.dead = true;
376 match self.check_process_status() {
377 Err(status) => Err(format!("Helper process crashed: {}", status)),
378 Ok(()) => Err("Helper process exited unexpectedly".to_string()),
379 }
380 }
381 }
382 }
383
384 /// Whether the helper process is still considered alive.
385 pub fn is_alive(&self) -> bool {
386 !self.dead
387 }
388
389 /// OS process id of the running helper, if any. Useful for monitoring — and for tests
390 /// that need to simulate a crash by killing the helper.
391 pub fn helper_pid(&self) -> Option<u32> {
392 self.process.as_ref().map(|c| c.id())
393 }
394
395 /// Check if the helper process is still running
396 pub fn check_process_status(&mut self) -> Result<(), String> {
397 if let Some(ref mut process) = self.process {
398 match process.try_wait() {
399 Ok(Some(status)) => {
400 if !status.success() {
401 return Err(format!("Helper process exited with status: {}", status));
402 }
403 }
404 Ok(None) => {
405 // Still running
406 return Ok(());
407 }
408 Err(e) => {
409 return Err(format!("Failed to check process status: {}", e));
410 }
411 }
412 }
413 Ok(())
414 }
415
416 /// Shutdown the helper process
417 pub fn shutdown(&mut self) {
418 // Best-effort Shutdown command (no response expected — the helper just exits).
419 // We do NOT use send_command here: it waits for a reply, and Shutdown has none.
420 if !self.dead {
421 if let (Some(stdin), Ok(json)) = (
422 self.stdin.as_mut(),
423 serde_json::to_string(&HostCommand::Shutdown),
424 ) {
425 let _ = writeln!(stdin, "{}", json);
426 let _ = stdin.flush();
427 }
428 }
429
430 // Dropping stdin gives the helper's read loop EOF, guaranteeing it exits even
431 // if it ignored the Shutdown command; that in turn ends the reader thread.
432 self.stdin = None;
433
434 if let Some(mut process) = self.process.take() {
435 // Bounded wait, then SIGKILL: this runs from Drop, so a wedged helper must not
436 // be able to hang the host on exit. Poll for a clean exit up to a deadline, then
437 // force-kill (mirrors the kill-on-timeout pattern in send_command).
438 let deadline = std::time::Instant::now() + Duration::from_secs(2);
439 loop {
440 match process.try_wait() {
441 Ok(Some(_)) => break,
442 Ok(None) if std::time::Instant::now() >= deadline => {
443 let _ = process.kill();
444 let _ = process.wait();
445 break;
446 }
447 Ok(None) => std::thread::sleep(Duration::from_millis(10)),
448 Err(_) => {
449 let _ = process.kill();
450 break;
451 }
452 }
453 }
454 }
455 if let Some(reader) = self.reader.take() {
456 let _ = reader.join();
457 }
458 self.dead = true;
459 }
460}
461
462impl Drop for PluginHostProcess {
463 fn drop(&mut self) {
464 self.shutdown();
465 }
466}
467
468/// Result type for process isolation operations
469pub type IsolationResult<T> = std::result::Result<T, IsolationError>;
470
471/// Errors that can occur during process isolation
472#[derive(Debug, thiserror::Error)]
473pub enum IsolationError {
474 /// IO error
475 #[error("IO error: {0}")]
476 Io(#[from] std::io::Error),
477
478 /// Serialization error
479 #[error("Serialization error: {0}")]
480 Serialization(#[from] serde_json::Error),
481
482 /// Plugin error
483 #[error("Plugin error: {0}")]
484 Plugin(String),
485
486 /// Plugin crashed
487 #[error("Plugin crashed: {0}")]
488 Crashed(String),
489
490 /// Helper process not running
491 #[error("Helper process not running")]
492 NotRunning,
493
494 /// Unexpected response
495 #[error("Unexpected response from helper")]
496 UnexpectedResponse,
497}
498
499#[cfg(test)]
500mod wire_tests {
501 use super::*;
502 use crate::midi::{MidiChannel, MidiEvent};
503
504 #[test]
505 fn audio_output_carries_midi_across_the_wire() {
506 // The Process response now carries emitted MIDI alongside audio; make sure the
507 // extended variant round-trips through the JSON transport host and helper share.
508 let resp = HostResponse::AudioOutput {
509 outputs: vec![vec![0.0, 0.5], vec![-0.5, 0.0]],
510 output_midi: vec![
511 MidiEvent::NoteOn {
512 channel: MidiChannel::Ch1,
513 note: 60,
514 velocity: 100,
515 },
516 MidiEvent::NoteOff {
517 channel: MidiChannel::Ch1,
518 note: 60,
519 velocity: 0,
520 },
521 ],
522 };
523 let json = serde_json::to_string(&resp).expect("serialize");
524 let back: HostResponse = serde_json::from_str(&json).expect("deserialize");
525 match back {
526 HostResponse::AudioOutput {
527 outputs,
528 output_midi,
529 } => {
530 assert_eq!(outputs, vec![vec![0.0, 0.5], vec![-0.5, 0.0]]);
531 assert_eq!(output_midi.len(), 2);
532 assert_eq!(
533 output_midi[0],
534 MidiEvent::NoteOn {
535 channel: MidiChannel::Ch1,
536 note: 60,
537 velocity: 100
538 }
539 );
540 }
541 other => panic!("round-trip changed the variant: {other:?}"),
542 }
543 }
544
545 #[test]
546 fn state_commands_round_trip_across_the_wire() {
547 // SaveState/LoadState/State carry the opaque plugin state blob across isolation.
548 let blob: Vec<u8> = vec![0, 1, 2, 250, 255, 42];
549
550 let save = serde_json::to_string(&HostCommand::SaveState).expect("serialize SaveState");
551 assert!(matches!(
552 serde_json::from_str::<HostCommand>(&save).expect("deserialize SaveState"),
553 HostCommand::SaveState
554 ));
555
556 let load = HostCommand::LoadState { data: blob.clone() };
557 let load_json = serde_json::to_string(&load).expect("serialize LoadState");
558 match serde_json::from_str::<HostCommand>(&load_json).expect("deserialize LoadState") {
559 HostCommand::LoadState { data } => assert_eq!(data, blob),
560 other => panic!("LoadState round-trip changed the variant: {other:?}"),
561 }
562
563 let state = HostResponse::State { data: blob.clone() };
564 let state_json = serde_json::to_string(&state).expect("serialize State");
565 match serde_json::from_str::<HostResponse>(&state_json).expect("deserialize State") {
566 HostResponse::State { data } => assert_eq!(data, blob),
567 other => panic!("State round-trip changed the variant: {other:?}"),
568 }
569 }
570
571 #[test]
572 fn set_parameter_at_round_trips_across_the_wire() {
573 // The sample-accurate automation command must survive the JSON transport intact
574 // (roadmap 3.5 — the offset is now carried across the isolation boundary).
575 let cmd = HostCommand::SetParameterAt {
576 id: 42,
577 value: 0.75,
578 offset: 256,
579 };
580 let json = serde_json::to_string(&cmd).expect("serialize SetParameterAt");
581 match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetParameterAt") {
582 HostCommand::SetParameterAt { id, value, offset } => {
583 assert_eq!(id, 42);
584 assert_eq!(value, 0.75);
585 assert_eq!(offset, 256);
586 }
587 other => panic!("round-trip changed the variant: {other:?}"),
588 }
589 }
590
591 #[test]
592 fn explicit_helper_override_missing_path_reports_clearly() {
593 // An explicit helper path that doesn't exist must fail with a clear, path-naming
594 // error *before* spawning — not fall through to the heuristic search. This is the
595 // observable contract for the builder's `helper_path()` override (roadmap 3.3).
596 let bogus = std::path::PathBuf::from("/nonexistent/vst3-host-helper-xyz");
597 let err = match PluginHostProcess::new(Some(bogus.clone()), DEFAULT_RESPONSE_TIMEOUT) {
598 Ok(_) => panic!("a missing override path must error, not spawn"),
599 Err(e) => e,
600 };
601 assert!(
602 err.contains("does not exist"),
603 "error should explain the missing path, got: {err}"
604 );
605 assert!(
606 err.contains("vst3-host-helper-xyz"),
607 "error should name the offending path, got: {err}"
608 );
609 }
610}
611
612/// Crash protection utilities for in-process plugins
613pub mod crash_protection {
614 use std::panic::catch_unwind;
615 use std::panic::UnwindSafe;
616 use std::time::Duration;
617
618 /// Status of a plugin after a protected call
619 #[derive(Debug, Clone, PartialEq)]
620 pub enum PluginStatus {
621 /// Plugin executed successfully
622 Ok,
623 /// Plugin crashed with panic
624 Crashed(String),
625 /// Plugin took too long to execute
626 Timeout(Duration),
627 }
628
629 /// Execute a function with panic protection
630 pub fn protected_call<F, R>(f: F) -> Result<R, String>
631 where
632 F: FnOnce() -> R + UnwindSafe,
633 {
634 catch_unwind(f).map_err(|e| {
635 if let Some(s) = e.downcast_ref::<&str>() {
636 format!("Plugin panicked: {}", s)
637 } else if let Some(s) = e.downcast_ref::<String>() {
638 format!("Plugin panicked: {}", s)
639 } else {
640 "Plugin panicked with unknown error".to_string()
641 }
642 })
643 }
644}