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    /// Start a note (MPE). The helper's plugin allocates the per-voice note id and returns
98    /// it in [`HostResponse::NoteStarted`] (in isolation the helper owns the real plugin).
99    NoteOn {
100        /// MIDI channel, 0-based index (`MidiChannel::as_index`).
101        channel: u8,
102        /// Note number (0-127).
103        note: u8,
104        /// Velocity (0-127).
105        velocity: u8,
106        /// Sample offset within the next processed block.
107        sample_offset: i32,
108    },
109    /// Release a note previously started with [`HostCommand::NoteOn`].
110    NoteOff {
111        /// Raw note id returned by `NoteOn`.
112        note_id: i32,
113        /// Sample offset within the next processed block.
114        sample_offset: i32,
115    },
116    /// Send a per-note expression value (normalized 0..1) for a voice. The expression
117    /// dimension crosses the boundary as the serializable `NoteExpressionType` enum.
118    SendNoteExpression {
119        /// Raw note id returned by `NoteOn`.
120        note_id: i32,
121        /// Which note-expression dimension to set.
122        kind: crate::midi::NoteExpressionType,
123        /// Normalized expression value (0..1).
124        value: f64,
125        /// Sample offset within the next processed block.
126        sample_offset: i32,
127    },
128    /// Enumerate the per-note expressions the plugin advertises (`INoteExpressionController`).
129    NoteExpressions {
130        /// Event bus index.
131        bus: i32,
132        /// Channel index.
133        channel: i16,
134    },
135    /// Shutdown the helper process
136    Shutdown,
137}
138
139/// Responses from the isolated plugin process
140#[derive(Debug, Serialize, Deserialize)]
141pub enum HostResponse {
142    /// Operation succeeded with message
143    Success {
144        /// Human-readable success detail.
145        message: String,
146    },
147    /// Operation failed with error
148    Error {
149        /// Error detail.
150        message: String,
151    },
152    /// Plugin crashed
153    Crashed {
154        /// Crash detail.
155        message: String,
156    },
157    /// Per-channel audio output data (`[channel][frame]`), plus any MIDI the plugin
158    /// emitted during the block (arpeggiators, MPE, etc.).
159    AudioOutput {
160        /// Output samples per channel.
161        outputs: Vec<Vec<f32>>,
162        /// MIDI events the plugin emitted this block, in order.
163        output_midi: Vec<crate::midi::MidiEvent>,
164    },
165    /// A single parameter value (normalized).
166    ParameterValue {
167        /// Normalized value.
168        value: f64,
169    },
170    /// A formatted parameter display string.
171    ParameterString {
172        /// The plugin-rendered display string.
173        value: String,
174    },
175    /// A list of parameters.
176    Parameters {
177        /// All parameters reported by the plugin.
178        params: Vec<crate::parameters::Parameter>,
179    },
180    /// Opaque plugin state bytes (reply to `SaveState`).
181    State {
182        /// The serialized state.
183        data: Vec<u8>,
184    },
185    /// The isolated editor window was created (reply to `CreateGui`); carries the
186    /// plugin-reported editor size so the host can report it without a second round-trip.
187    GuiCreated {
188        /// Editor width in pixels.
189        width: i32,
190        /// Editor height in pixels.
191        height: i32,
192    },
193    /// Plugin information
194    PluginInfo {
195        /// Vendor / manufacturer.
196        vendor: String,
197        /// Plugin name.
198        name: String,
199        /// Version string (may be empty if the plugin doesn't report one).
200        version: String,
201        /// Plugin sub-categories (e.g. "Fx", "Instrument|Synth"); may be empty.
202        category: String,
203        /// Unique plugin class id (hex).
204        uid: String,
205        /// Whether the plugin has an editor.
206        has_gui: bool,
207        /// Audio input bus count.
208        audio_inputs: i32,
209        /// Audio output bus count.
210        audio_outputs: i32,
211        /// Total output audio channels across all output buses.
212        output_channels: i32,
213        /// Whether the plugin has a MIDI/event input bus.
214        has_midi_input: bool,
215        /// Whether the plugin has a MIDI/event output bus.
216        has_midi_output: bool,
217    },
218    /// A note was started (reply to `NoteOn`); carries the helper-allocated raw note id.
219    NoteStarted {
220        /// Raw note id the host wraps back into a `NoteId`.
221        note_id: i32,
222    },
223    /// The per-note expressions the plugin advertises (reply to `NoteExpressions`).
224    NoteExpressions {
225        /// The advertised note-expression dimensions.
226        expressions: Vec<crate::midi::NoteExpressionInfo>,
227    },
228}
229
230/// Manages a plugin running in an isolated process.
231///
232/// Responses are read on a background thread and delivered over a channel, so
233/// [`Self::send_command`] can wait with a deadline: a hung plugin yields a timeout
234/// error (and the child is killed) instead of blocking the host forever, and a
235/// crashed helper surfaces as a disconnect error rather than a silent wedge.
236pub struct PluginHostProcess {
237    process: Option<Child>,
238    stdin: Option<ChildStdin>,
239    /// Lines received from the helper's stdout (one JSON response each).
240    responses: Receiver<String>,
241    /// Background reader thread handle (joined on shutdown).
242    reader: Option<JoinHandle<()>>,
243    /// How long to wait for a single response before declaring a timeout.
244    timeout: Duration,
245    /// Set once the child has been killed/exited so we stop trying to talk to it.
246    dead: bool,
247}
248
249impl PluginHostProcess {
250    /// Create a new isolated plugin host process
251    pub fn new(
252        helper_override: Option<std::path::PathBuf>,
253        timeout: Duration,
254    ) -> Result<Self, String> {
255        // An explicit helper path (builder option or the VST3_HOST_HELPER_PATH env var) wins
256        // over the heuristic search below — and a missing one is reported clearly here.
257        let override_path = helper_override
258            .or_else(|| std::env::var_os("VST3_HOST_HELPER_PATH").map(std::path::PathBuf::from));
259        if let Some(p) = override_path {
260            if !p.exists() {
261                return Err(format!(
262                    "Configured helper path does not exist: {}",
263                    p.display()
264                ));
265            }
266            return Self::spawn(p, timeout);
267        }
268
269        // Get the path to our helper executable
270        let exe_path =
271            std::env::current_exe().map_err(|e| format!("Failed to get current exe: {}", e))?;
272
273        let exe_dir = exe_path.parent().ok_or("Failed to get exe directory")?;
274
275        // Try different possible helper names and locations
276        let helper_names = ["vst3-host-helper", "vst3-inspector-helper"];
277        let mut helper_path = None;
278
279        // First try in the same directory as the executable
280        for name in &helper_names {
281            let path = exe_dir.join(name);
282            if path.exists() {
283                helper_path = Some(path);
284                break;
285            }
286        }
287
288        // If not found and we're in an examples directory, try parent
289        if helper_path.is_none() && exe_dir.file_name() == Some(std::ffi::OsStr::new("examples")) {
290            if let Some(parent_dir) = exe_dir.parent() {
291                for name in &helper_names {
292                    let path = parent_dir.join(name);
293                    if path.exists() {
294                        helper_path = Some(path);
295                        break;
296                    }
297                }
298            }
299        }
300
301        // Also check common cargo target directories
302        if helper_path.is_none() {
303            // Try to find the workspace root and look in target/debug or target/release
304            let mut current_dir = exe_dir;
305            while let Some(parent) = current_dir.parent() {
306                let debug_path = parent.join("target").join("debug").join("vst3-host-helper");
307                let release_path = parent
308                    .join("target")
309                    .join("release")
310                    .join("vst3-host-helper");
311
312                if debug_path.exists() {
313                    helper_path = Some(debug_path);
314                    break;
315                } else if release_path.exists() {
316                    helper_path = Some(release_path);
317                    break;
318                }
319
320                // Check if we've reached a Cargo.toml (workspace root)
321                if parent.join("Cargo.toml").exists() {
322                    break;
323                }
324                current_dir = parent;
325            }
326        }
327
328        let helper_path = helper_path
329            .ok_or_else(|| format!("Helper executable not found. Searched in {:?} and parent directories. Make sure to build with --bins flag.", exe_dir))?;
330
331        Self::spawn(helper_path, timeout)
332    }
333
334    /// Spawn the helper at `helper_path` and wire up the response reader thread.
335    fn spawn(helper_path: std::path::PathBuf, timeout: Duration) -> Result<Self, String> {
336        let mut child = Command::new(&helper_path)
337            .stdin(Stdio::piped())
338            .stdout(Stdio::piped())
339            .stderr(Stdio::inherit())
340            .spawn()
341            .map_err(|e| format!("Failed to spawn helper process: {}", e))?;
342
343        let stdin = child.stdin.take().ok_or("Failed to get stdin")?;
344        let stdout = child.stdout.take().ok_or("Failed to get stdout")?;
345
346        // Read responses on a background thread so the caller can apply a deadline.
347        // The thread ends (dropping the sender) when stdout hits EOF — i.e. when the
348        // helper process exits or crashes — which the receiver sees as Disconnected.
349        let (tx, rx) = mpsc::channel::<String>();
350        let reader = std::thread::spawn(move || {
351            let mut reader = BufReader::new(stdout);
352            let mut line = String::new();
353            loop {
354                line.clear();
355                match reader.read_line(&mut line) {
356                    Ok(0) => break, // EOF: helper exited
357                    Ok(_) => {
358                        if tx.send(std::mem::take(&mut line)).is_err() {
359                            break; // receiver dropped
360                        }
361                    }
362                    Err(_) => break,
363                }
364            }
365        });
366
367        Ok(Self {
368            process: Some(child),
369            stdin: Some(stdin),
370            responses: rx,
371            reader: Some(reader),
372            timeout,
373            dead: false,
374        })
375    }
376
377    /// Set how long to wait for a helper response before declaring a timeout.
378    pub fn set_timeout(&mut self, timeout: Duration) {
379        self.timeout = timeout;
380    }
381
382    /// Send a command to the helper process and wait (with a deadline) for a response.
383    ///
384    /// Returns an error without blocking indefinitely if the plugin hangs (the child
385    /// is killed) or the helper has crashed/exited.
386    pub fn send_command(&mut self, command: HostCommand) -> Result<HostResponse, String> {
387        if self.dead {
388            return Err("Helper process is no longer running".to_string());
389        }
390
391        let command_json = serde_json::to_string(&command)
392            .map_err(|e| format!("Failed to serialize command: {}", e))?;
393
394        {
395            let stdin = self.stdin.as_mut().ok_or("No stdin available")?;
396            writeln!(stdin, "{}", command_json).map_err(|e| {
397                self.dead = true;
398                format!("Failed to write command (helper gone?): {}", e)
399            })?;
400            stdin.flush().map_err(|e| {
401                self.dead = true;
402                format!("Failed to flush stdin (helper gone?): {}", e)
403            })?;
404        }
405
406        match self.responses.recv_timeout(self.timeout) {
407            Ok(line) => {
408                serde_json::from_str(&line).map_err(|e| format!("Failed to parse response: {}", e))
409            }
410            Err(RecvTimeoutError::Timeout) => {
411                // The plugin is hung. Kill the child so it can't wedge us further.
412                self.dead = true;
413                if let Some(ref mut process) = self.process {
414                    let _ = process.kill();
415                }
416                Err(format!(
417                    "Timed out after {:?} waiting for helper response (plugin may have hung)",
418                    self.timeout
419                ))
420            }
421            Err(RecvTimeoutError::Disconnected) => {
422                // Reader thread ended => stdout closed => helper exited/crashed.
423                self.dead = true;
424                match self.check_process_status() {
425                    Err(status) => Err(format!("Helper process crashed: {}", status)),
426                    Ok(()) => Err("Helper process exited unexpectedly".to_string()),
427                }
428            }
429        }
430    }
431
432    /// Whether the helper process is still considered alive.
433    pub fn is_alive(&self) -> bool {
434        !self.dead
435    }
436
437    /// OS process id of the running helper, if any. Useful for monitoring — and for tests
438    /// that need to simulate a crash by killing the helper.
439    pub fn helper_pid(&self) -> Option<u32> {
440        self.process.as_ref().map(|c| c.id())
441    }
442
443    /// Check if the helper process is still running
444    pub fn check_process_status(&mut self) -> Result<(), String> {
445        if let Some(ref mut process) = self.process {
446            match process.try_wait() {
447                Ok(Some(status)) => {
448                    if !status.success() {
449                        return Err(format!("Helper process exited with status: {}", status));
450                    }
451                }
452                Ok(None) => {
453                    // Still running
454                    return Ok(());
455                }
456                Err(e) => {
457                    return Err(format!("Failed to check process status: {}", e));
458                }
459            }
460        }
461        Ok(())
462    }
463
464    /// Shutdown the helper process
465    pub fn shutdown(&mut self) {
466        // Best-effort Shutdown command (no response expected — the helper just exits).
467        // We do NOT use send_command here: it waits for a reply, and Shutdown has none.
468        if !self.dead {
469            if let (Some(stdin), Ok(json)) = (
470                self.stdin.as_mut(),
471                serde_json::to_string(&HostCommand::Shutdown),
472            ) {
473                let _ = writeln!(stdin, "{}", json);
474                let _ = stdin.flush();
475            }
476        }
477
478        // Dropping stdin gives the helper's read loop EOF, guaranteeing it exits even
479        // if it ignored the Shutdown command; that in turn ends the reader thread.
480        self.stdin = None;
481
482        if let Some(mut process) = self.process.take() {
483            // Bounded wait, then SIGKILL: this runs from Drop, so a wedged helper must not
484            // be able to hang the host on exit. Poll for a clean exit up to a deadline, then
485            // force-kill (mirrors the kill-on-timeout pattern in send_command).
486            let deadline = std::time::Instant::now() + Duration::from_secs(2);
487            loop {
488                match process.try_wait() {
489                    Ok(Some(_)) => break,
490                    Ok(None) if std::time::Instant::now() >= deadline => {
491                        let _ = process.kill();
492                        let _ = process.wait();
493                        break;
494                    }
495                    Ok(None) => std::thread::sleep(Duration::from_millis(10)),
496                    Err(_) => {
497                        let _ = process.kill();
498                        break;
499                    }
500                }
501            }
502        }
503        if let Some(reader) = self.reader.take() {
504            let _ = reader.join();
505        }
506        self.dead = true;
507    }
508}
509
510impl Drop for PluginHostProcess {
511    fn drop(&mut self) {
512        self.shutdown();
513    }
514}
515
516/// Result type for process isolation operations
517pub type IsolationResult<T> = std::result::Result<T, IsolationError>;
518
519/// Errors that can occur during process isolation
520#[derive(Debug, thiserror::Error)]
521pub enum IsolationError {
522    /// IO error
523    #[error("IO error: {0}")]
524    Io(#[from] std::io::Error),
525
526    /// Serialization error
527    #[error("Serialization error: {0}")]
528    Serialization(#[from] serde_json::Error),
529
530    /// Plugin error
531    #[error("Plugin error: {0}")]
532    Plugin(String),
533
534    /// Plugin crashed
535    #[error("Plugin crashed: {0}")]
536    Crashed(String),
537
538    /// Helper process not running
539    #[error("Helper process not running")]
540    NotRunning,
541
542    /// Unexpected response
543    #[error("Unexpected response from helper")]
544    UnexpectedResponse,
545}
546
547#[cfg(test)]
548mod wire_tests {
549    use super::*;
550    use crate::midi::{MidiChannel, MidiEvent};
551
552    #[test]
553    fn audio_output_carries_midi_across_the_wire() {
554        // The Process response carries emitted MIDI alongside audio; check the variant
555        // round-trips through the JSON transport host and helper share.
556        let resp = HostResponse::AudioOutput {
557            outputs: vec![vec![0.0, 0.5], vec![-0.5, 0.0]],
558            output_midi: vec![
559                MidiEvent::NoteOn {
560                    channel: MidiChannel::Ch1,
561                    note: 60,
562                    velocity: 100,
563                },
564                MidiEvent::NoteOff {
565                    channel: MidiChannel::Ch1,
566                    note: 60,
567                    velocity: 0,
568                },
569            ],
570        };
571        let json = serde_json::to_string(&resp).expect("serialize");
572        let back: HostResponse = serde_json::from_str(&json).expect("deserialize");
573        match back {
574            HostResponse::AudioOutput {
575                outputs,
576                output_midi,
577            } => {
578                assert_eq!(outputs, vec![vec![0.0, 0.5], vec![-0.5, 0.0]]);
579                assert_eq!(output_midi.len(), 2);
580                assert_eq!(
581                    output_midi[0],
582                    MidiEvent::NoteOn {
583                        channel: MidiChannel::Ch1,
584                        note: 60,
585                        velocity: 100
586                    }
587                );
588            }
589            other => panic!("round-trip changed the variant: {other:?}"),
590        }
591    }
592
593    #[test]
594    fn state_commands_round_trip_across_the_wire() {
595        // SaveState/LoadState/State carry the opaque plugin state blob across isolation.
596        let blob: Vec<u8> = vec![0, 1, 2, 250, 255, 42];
597
598        let save = serde_json::to_string(&HostCommand::SaveState).expect("serialize SaveState");
599        assert!(matches!(
600            serde_json::from_str::<HostCommand>(&save).expect("deserialize SaveState"),
601            HostCommand::SaveState
602        ));
603
604        let load = HostCommand::LoadState { data: blob.clone() };
605        let load_json = serde_json::to_string(&load).expect("serialize LoadState");
606        match serde_json::from_str::<HostCommand>(&load_json).expect("deserialize LoadState") {
607            HostCommand::LoadState { data } => assert_eq!(data, blob),
608            other => panic!("LoadState round-trip changed the variant: {other:?}"),
609        }
610
611        let state = HostResponse::State { data: blob.clone() };
612        let state_json = serde_json::to_string(&state).expect("serialize State");
613        match serde_json::from_str::<HostResponse>(&state_json).expect("deserialize State") {
614            HostResponse::State { data } => assert_eq!(data, blob),
615            other => panic!("State round-trip changed the variant: {other:?}"),
616        }
617    }
618
619    #[test]
620    fn set_parameter_at_round_trips_across_the_wire() {
621        // The sample-accurate automation command must survive the JSON transport intact
622        // (the offset is carried across the isolation boundary).
623        let cmd = HostCommand::SetParameterAt {
624            id: 42,
625            value: 0.75,
626            offset: 256,
627        };
628        let json = serde_json::to_string(&cmd).expect("serialize SetParameterAt");
629        match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetParameterAt") {
630            HostCommand::SetParameterAt { id, value, offset } => {
631                assert_eq!(id, 42);
632                assert_eq!(value, 0.75);
633                assert_eq!(offset, 256);
634            }
635            other => panic!("round-trip changed the variant: {other:?}"),
636        }
637    }
638
639    #[test]
640    fn note_expression_commands_round_trip_across_the_wire() {
641        // The MPE commands/responses must survive the JSON transport host and helper share.
642        use crate::midi::{NoteExpressionInfo, NoteExpressionType};
643
644        let on = HostCommand::NoteOn {
645            channel: 0,
646            note: 60,
647            velocity: 100,
648            sample_offset: 0,
649        };
650        let on_json = serde_json::to_string(&on).expect("serialize NoteOn");
651        match serde_json::from_str::<HostCommand>(&on_json).expect("deserialize NoteOn") {
652            HostCommand::NoteOn {
653                channel,
654                note,
655                velocity,
656                sample_offset,
657            } => {
658                assert_eq!((channel, note, velocity, sample_offset), (0, 60, 100, 0));
659            }
660            other => panic!("NoteOn round-trip changed the variant: {other:?}"),
661        }
662
663        let expr = HostCommand::SendNoteExpression {
664            note_id: 7,
665            kind: NoteExpressionType::Tuning,
666            value: 1.0,
667            sample_offset: 0,
668        };
669        let expr_json = serde_json::to_string(&expr).expect("serialize SendNoteExpression");
670        match serde_json::from_str::<HostCommand>(&expr_json).expect("deserialize") {
671            HostCommand::SendNoteExpression {
672                note_id,
673                kind,
674                value,
675                ..
676            } => {
677                assert_eq!(note_id, 7);
678                assert_eq!(kind, NoteExpressionType::Tuning);
679                assert_eq!(value, 1.0);
680            }
681            other => panic!("SendNoteExpression round-trip changed the variant: {other:?}"),
682        }
683
684        let started = HostResponse::NoteStarted { note_id: 42 };
685        let started_json = serde_json::to_string(&started).expect("serialize NoteStarted");
686        match serde_json::from_str::<HostResponse>(&started_json).expect("deserialize") {
687            HostResponse::NoteStarted { note_id } => assert_eq!(note_id, 42),
688            other => panic!("NoteStarted round-trip changed the variant: {other:?}"),
689        }
690
691        let info = NoteExpressionInfo {
692            kind: NoteExpressionType::Tuning,
693            title: "Tuning".to_string(),
694            short_title: "Tun".to_string(),
695            units: String::new(),
696            default_value: 0.5,
697            min: 0.0,
698            max: 1.0,
699            step_count: 0,
700            is_bipolar: true,
701            is_one_shot: false,
702            is_absolute: false,
703        };
704        let resp = HostResponse::NoteExpressions {
705            expressions: vec![info.clone()],
706        };
707        let resp_json = serde_json::to_string(&resp).expect("serialize NoteExpressions");
708        match serde_json::from_str::<HostResponse>(&resp_json).expect("deserialize") {
709            HostResponse::NoteExpressions { expressions } => {
710                assert_eq!(expressions, vec![info]);
711            }
712            other => panic!("NoteExpressions round-trip changed the variant: {other:?}"),
713        }
714    }
715
716    #[test]
717    fn explicit_helper_override_missing_path_reports_clearly() {
718        // An explicit helper path that doesn't exist must fail with a clear, path-naming
719        // error *before* spawning — not fall through to the heuristic search. This is the
720        // observable contract for the builder's `helper_path()` override (roadmap 3.3).
721        let bogus = std::path::PathBuf::from("/nonexistent/vst3-host-helper-xyz");
722        let err = match PluginHostProcess::new(Some(bogus.clone()), DEFAULT_RESPONSE_TIMEOUT) {
723            Ok(_) => panic!("a missing override path must error, not spawn"),
724            Err(e) => e,
725        };
726        assert!(
727            err.contains("does not exist"),
728            "error should explain the missing path, got: {err}"
729        );
730        assert!(
731            err.contains("vst3-host-helper-xyz"),
732            "error should name the offending path, got: {err}"
733        );
734    }
735
736    /// A helper that never responds (a hung plugin) must not hang the host: `send_command`
737    /// returns an error within the timeout and kills the child.
738    #[cfg(unix)]
739    #[test]
740    fn hung_helper_times_out_and_is_killed_not_blocking() {
741        use std::io::Write;
742        use std::os::unix::fs::PermissionsExt;
743        use std::time::{Duration, Instant};
744
745        // Fake helper: read nothing, write nothing, just sleep — i.e. hang forever.
746        let dir = std::env::temp_dir().join(format!("vst3_hang_{}", std::process::id()));
747        std::fs::create_dir_all(&dir).unwrap();
748        let fake = dir.join("hung-helper");
749        let mut f = std::fs::File::create(&fake).unwrap();
750        // `exec` so the shell is replaced by sleep (no orphaned child holding the stdout
751        // pipe); killing the helper then closes the pipe and ends the reader thread promptly.
752        writeln!(f, "#!/bin/sh\nexec sleep 30").unwrap();
753        drop(f);
754        std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
755
756        let mut proc =
757            PluginHostProcess::spawn(fake.clone(), Duration::from_millis(200)).expect("spawn");
758        let started = Instant::now();
759        let res = proc.send_command(HostCommand::Shutdown);
760        let elapsed = started.elapsed();
761
762        assert!(
763            res.is_err(),
764            "a hung helper must yield an error, got {res:?}"
765        );
766        assert!(
767            elapsed < Duration::from_secs(3),
768            "send_command must return promptly on timeout, took {elapsed:?}"
769        );
770        // The child was killed; a follow-up command also errors rather than hanging.
771        assert!(proc.send_command(HostCommand::Shutdown).is_err());
772
773        let _ = std::fs::remove_dir_all(&dir);
774    }
775}
776
777/// Crash protection utilities for in-process plugins
778pub mod crash_protection {
779    use std::panic::catch_unwind;
780    use std::panic::UnwindSafe;
781    use std::time::Duration;
782
783    /// Status of a plugin after a protected call
784    #[derive(Debug, Clone, PartialEq)]
785    pub enum PluginStatus {
786        /// Plugin executed successfully
787        Ok,
788        /// Plugin crashed with panic
789        Crashed(String),
790        /// Plugin took too long to execute
791        Timeout(Duration),
792    }
793
794    /// Execute a function with panic protection
795    pub fn protected_call<F, R>(f: F) -> Result<R, String>
796    where
797        F: FnOnce() -> R + UnwindSafe,
798    {
799        catch_unwind(f).map_err(|e| {
800            if let Some(s) = e.downcast_ref::<&str>() {
801                format!("Plugin panicked: {}", s)
802            } else if let Some(s) = e.downcast_ref::<String>() {
803                format!("Plugin panicked: {}", s)
804            } else {
805                "Plugin panicked with unknown error".to_string()
806            }
807        })
808    }
809}