Skip to main content

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}