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, Deserializer, Serialize, Serializer};
7use std::io::{BufRead, BufReader, Read, Write};
8use std::process::{Child, ChildStdin, Command, Stdio};
9use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
10use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
11use std::sync::Arc;
12use std::thread::JoinHandle;
13use std::time::{Duration, Instant};
14
15/// Default time to wait for a helper response before treating the plugin as hung.
16pub(crate) const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
17
18/// Default deadline for the slow class of commands ([`is_slow_command`]).
19///
20/// Loading a plugin binary or serializing a large state blob legitimately takes far longer
21/// than a process block, so those commands get their own (longer) deadline: the short
22/// per-block deadline exists to catch a plugin hung inside `process()`, and applying it to a
23/// cold-cache module load would SIGKILL a helper that is merely slow.
24pub(crate) const DEFAULT_SLOW_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
25
26/// Maximum bytes accepted for a single line on the protocol stream. Longer lines are
27/// discarded rather than buffered, so a helper (or a plugin sharing its stdout) that never
28/// terminates a line cannot grow the host's memory without bound.
29const MAX_STATE_BASE64_BYTES: usize = crate::plugin::MAX_STATE_SNAPSHOT_BYTES.div_ceil(3) * 4;
30const MAX_RESPONSE_LINE_BYTES: usize = MAX_STATE_BASE64_BYTES + 1024 * 1024;
31
32/// Maximum unread lines buffered from the helper. Beyond this the reader drops new lines
33/// (counting them) instead of queueing them forever.
34const MAX_QUEUED_RESPONSES: usize = 64;
35
36/// Upper bound on an audio channel count taken from the wire.
37const MAX_WIRE_CHANNELS: usize = 256;
38
39/// Upper bound on frames-per-channel taken from the wire.
40const MAX_WIRE_FRAMES: usize = 1 << 20;
41
42/// Upper bound on a bus count taken from the wire.
43const MAX_WIRE_BUSES: i32 = 256;
44
45/// The in-process host has two independently bounded feedback sources: processor output
46/// parameters and controller/editor changes. One response drains both, so its wire cap is the
47/// sum of their 4096-entry limits.
48const MAX_WIRE_PARAMETER_CHANGES: usize = 8192;
49
50/// Whether a command belongs to the slow class — module load and state I/O — which gets
51/// [`DEFAULT_SLOW_COMMAND_TIMEOUT`] instead of the per-block response deadline.
52pub(crate) fn is_slow_command(command: &HostCommand) -> bool {
53    matches!(
54        command,
55        HostCommand::LoadPlugin { .. }
56            | HostCommand::SaveState
57            | HostCommand::LoadState { .. }
58            | HostCommand::GetProgramData { .. }
59            | HostCommand::SetProgramData { .. }
60            | HostCommand::GetUnitData { .. }
61            | HostCommand::SetUnitData { .. }
62    )
63}
64
65/// Lossless wire encoding for audio sample buffers.
66///
67/// JSON cannot represent a non-finite number: `serde_json` writes NaN and ±∞ as `null`, and
68/// `null` will not deserialize back into an `f32`. A single non-finite sample from a plugin
69/// would therefore break every block on the boundary. Samples cross as base64 of their
70/// little-endian IEEE-754 bit patterns instead, which is exact for every `f32` — and about
71/// three times smaller than the decimal number array it replaces.
72pub(crate) mod audio_codec {
73    use super::{Deserialize, Deserializer, Serializer, MAX_WIRE_CHANNELS, MAX_WIRE_FRAMES};
74
75    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
76
77    /// Encode one channel's samples as base64 of their little-endian bit patterns.
78    pub(super) fn encode_channel(samples: &[f32]) -> String {
79        let mut bytes = Vec::with_capacity(samples.len() * 4);
80        for s in samples {
81            bytes.extend_from_slice(&s.to_bits().to_le_bytes());
82        }
83        let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
84        for chunk in bytes.chunks(3) {
85            let b1 = chunk.get(1).copied().unwrap_or(0);
86            let b2 = chunk.get(2).copied().unwrap_or(0);
87            let n = (u32::from(chunk[0]) << 16) | (u32::from(b1) << 8) | u32::from(b2);
88            out.push(ALPHABET[(n >> 18) as usize & 63] as char);
89            out.push(ALPHABET[(n >> 12) as usize & 63] as char);
90            out.push(if chunk.len() > 1 {
91                ALPHABET[(n >> 6) as usize & 63] as char
92            } else {
93                '='
94            });
95            out.push(if chunk.len() > 2 {
96                ALPHABET[n as usize & 63] as char
97            } else {
98                '='
99            });
100        }
101        out
102    }
103
104    fn sextet(c: u8) -> Option<u32> {
105        let v = match c {
106            b'A'..=b'Z' => c - b'A',
107            b'a'..=b'z' => c - b'a' + 26,
108            b'0'..=b'9' => c - b'0' + 52,
109            b'+' => 62,
110            b'/' => 63,
111            _ => return None,
112        };
113        Some(u32::from(v))
114    }
115
116    /// Decode one channel, rejecting anything that isn't well-formed base64 of whole samples.
117    pub(super) fn decode_channel(encoded: &str) -> Option<Vec<f32>> {
118        let bytes = encoded.as_bytes();
119        if bytes.len() % 4 != 0 {
120            return None;
121        }
122        let mut raw = Vec::with_capacity(bytes.len() / 4 * 3);
123        for chunk in bytes.chunks(4) {
124            let pad = chunk.iter().rev().take_while(|&&c| c == b'=').count();
125            if pad > 2 {
126                return None;
127            }
128            let mut n = 0u32;
129            for (i, &c) in chunk.iter().enumerate() {
130                if c == b'=' {
131                    if i < 4 - pad {
132                        return None; // padding is only legal at the end of the group
133                    }
134                    continue;
135                }
136                n |= sextet(c)? << (18 - 6 * i);
137            }
138            raw.push((n >> 16) as u8);
139            if pad < 2 {
140                raw.push((n >> 8) as u8);
141            }
142            if pad < 1 {
143                raw.push(n as u8);
144            }
145        }
146        if raw.len() % 4 != 0 {
147            return None;
148        }
149        Some(
150            raw.chunks_exact(4)
151                .map(|b| f32::from_bits(u32::from_le_bytes([b[0], b[1], b[2], b[3]])))
152                .collect(),
153        )
154    }
155
156    pub(crate) fn serialize<S: Serializer>(
157        channels: &[Vec<f32>],
158        serializer: S,
159    ) -> Result<S::Ok, S::Error> {
160        serializer.collect_seq(channels.iter().map(|c| encode_channel(c)))
161    }
162
163    pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
164        deserializer: D,
165    ) -> Result<Vec<Vec<f32>>, D::Error> {
166        let encoded = Vec::<String>::deserialize(deserializer)?;
167        if encoded.len() > MAX_WIRE_CHANNELS {
168            log::warn!(
169                "isolation: clamping {} wire channels to {MAX_WIRE_CHANNELS}",
170                encoded.len()
171            );
172        }
173        encoded
174            .iter()
175            .take(MAX_WIRE_CHANNELS)
176            .map(|c| {
177                let mut samples = decode_channel(c).ok_or_else(|| {
178                    serde::de::Error::custom("malformed base64 audio channel payload")
179                })?;
180                if samples.len() > MAX_WIRE_FRAMES {
181                    log::warn!(
182                        "isolation: clamping {} wire frames to {MAX_WIRE_FRAMES}",
183                        samples.len()
184                    );
185                    samples.truncate(MAX_WIRE_FRAMES);
186                }
187                Ok(samples)
188            })
189            .collect()
190    }
191}
192
193/// Compact, bounded wire encoding for opaque plugin state.
194///
195/// `Vec<u8>`'s default JSON representation is a decimal integer array and can expand a valid
196/// 64 MiB state by roughly four times. Base64 keeps the expansion to 4/3. The deserializer also
197/// accepts the old array representation so a new host/helper can finish an in-flight exchange
198/// with a peer from an earlier release.
199pub(crate) mod state_codec {
200    use super::{Deserialize, Deserializer, Serializer, MAX_STATE_BASE64_BYTES};
201
202    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
203    const MAX_STATE_BYTES: usize = crate::plugin::MAX_STATE_SNAPSHOT_BYTES;
204
205    pub(crate) fn serialize<S: Serializer>(state: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
206        if state.len() > MAX_STATE_BYTES {
207            return Err(serde::ser::Error::custom("plugin state exceeds wire limit"));
208        }
209        let mut out = String::with_capacity(state.len().div_ceil(3) * 4);
210        for chunk in state.chunks(3) {
211            let b1 = chunk.get(1).copied().unwrap_or(0);
212            let b2 = chunk.get(2).copied().unwrap_or(0);
213            let n = (u32::from(chunk[0]) << 16) | (u32::from(b1) << 8) | u32::from(b2);
214            out.push(ALPHABET[(n >> 18) as usize & 63] as char);
215            out.push(ALPHABET[(n >> 12) as usize & 63] as char);
216            out.push(if chunk.len() > 1 {
217                ALPHABET[(n >> 6) as usize & 63] as char
218            } else {
219                '='
220            });
221            out.push(if chunk.len() > 2 {
222                ALPHABET[n as usize & 63] as char
223            } else {
224                '='
225            });
226        }
227        serializer.serialize_str(&out)
228    }
229
230    fn sextet(byte: u8) -> Option<u32> {
231        Some(u32::from(match byte {
232            b'A'..=b'Z' => byte - b'A',
233            b'a'..=b'z' => byte - b'a' + 26,
234            b'0'..=b'9' => byte - b'0' + 52,
235            b'+' => 62,
236            b'/' => 63,
237            _ => return None,
238        }))
239    }
240
241    fn decode(encoded: &str) -> Option<Vec<u8>> {
242        let bytes = encoded.as_bytes();
243        if bytes.len() > MAX_STATE_BASE64_BYTES || bytes.len() % 4 != 0 {
244            return None;
245        }
246        let mut raw = Vec::with_capacity(bytes.len() / 4 * 3);
247        let chunk_count = bytes.len() / 4;
248        for (chunk_index, chunk) in bytes.chunks(4).enumerate() {
249            let pad = chunk.iter().rev().take_while(|&&byte| byte == b'=').count();
250            if pad > 2 || (pad != 0 && chunk_index + 1 != chunk_count) {
251                return None;
252            }
253            let mut n = 0u32;
254            for (index, &byte) in chunk.iter().enumerate() {
255                if byte == b'=' {
256                    if index < 4 - pad {
257                        return None;
258                    }
259                } else {
260                    n |= sextet(byte)? << (18 - 6 * index);
261                }
262            }
263            raw.push((n >> 16) as u8);
264            if pad < 2 {
265                raw.push((n >> 8) as u8);
266            }
267            if pad == 0 {
268                raw.push(n as u8);
269            }
270        }
271        (raw.len() <= MAX_STATE_BYTES).then_some(raw)
272    }
273
274    #[derive(Deserialize)]
275    #[serde(untagged)]
276    enum StateWire {
277        Base64(String),
278        Legacy(Vec<u8>),
279    }
280
281    pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
282        deserializer: D,
283    ) -> Result<Vec<u8>, D::Error> {
284        match StateWire::deserialize(deserializer)? {
285            StateWire::Base64(encoded) => decode(&encoded)
286                .ok_or_else(|| serde::de::Error::custom("malformed or oversized plugin state")),
287            StateWire::Legacy(state) if state.len() <= MAX_STATE_BYTES => Ok(state),
288            StateWire::Legacy(_) => {
289                Err(serde::de::Error::custom("plugin state exceeds wire limit"))
290            }
291        }
292    }
293}
294
295/// Wire encoding for `f64`s that may legitimately be non-finite.
296///
297/// Same problem as the audio payload: `serde_json` turns NaN/±∞ into `null`, which then fails
298/// to deserialize. Finite values stay plain JSON numbers; non-finite ones cross as their
299/// standard textual spelling, which `f64::from_str` parses back exactly.
300mod lossless_f64 {
301    use super::{Deserializer, Serializer};
302    use std::fmt;
303
304    pub(super) fn serialize<S: Serializer>(value: &f64, serializer: S) -> Result<S::Ok, S::Error> {
305        if value.is_finite() {
306            serializer.serialize_f64(*value)
307        } else if value.is_nan() {
308            serializer.serialize_str("NaN")
309        } else if *value > 0.0 {
310            serializer.serialize_str("inf")
311        } else {
312            serializer.serialize_str("-inf")
313        }
314    }
315
316    struct AnyF64;
317
318    impl serde::de::Visitor<'_> for AnyF64 {
319        type Value = f64;
320
321        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322            f.write_str("a number or a non-finite float spelled as a string")
323        }
324
325        fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<f64, E> {
326            Ok(v)
327        }
328
329        fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<f64, E> {
330            Ok(v as f64)
331        }
332
333        fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<f64, E> {
334            Ok(v as f64)
335        }
336
337        fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<f64, E> {
338            v.parse::<f64>()
339                .map_err(|_| E::custom(format!("not a float: {v}")))
340        }
341
342        // `null` is what an older peer would have written for a non-finite value; treat it as
343        // NaN rather than failing the whole exchange.
344        fn visit_unit<E: serde::de::Error>(self) -> Result<f64, E> {
345            Ok(f64::NAN)
346        }
347    }
348
349    pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
350        deserializer.deserialize_any(AnyF64)
351    }
352}
353
354/// Compact, lossless, bounded encoding for processor/controller parameter feedback.
355///
356/// Values travel as IEEE-754 bits so a misbehaving plugin returning NaN or infinity cannot make
357/// serde_json turn the whole helper response into an undecodable `null`.
358mod parameter_changes_codec {
359    use super::{Deserializer, Serializer, MAX_WIRE_PARAMETER_CHANGES};
360    use serde::de::{SeqAccess, Visitor};
361    use serde::ser::SerializeSeq;
362    use std::fmt;
363
364    pub(super) fn serialize<S: Serializer>(
365        changes: &[(u32, f64)],
366        serializer: S,
367    ) -> Result<S::Ok, S::Error> {
368        if changes.len() > MAX_WIRE_PARAMETER_CHANGES {
369            return Err(serde::ser::Error::custom(
370                "parameter feedback exceeds wire limit",
371            ));
372        }
373        let mut sequence = serializer.serialize_seq(Some(changes.len()))?;
374        for &(id, value) in changes {
375            sequence.serialize_element(&(id, value.to_bits()))?;
376        }
377        sequence.end()
378    }
379
380    struct ParameterChangesVisitor;
381
382    impl<'de> Visitor<'de> for ParameterChangesVisitor {
383        type Value = Vec<(u32, f64)>;
384
385        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
386            write!(
387                formatter,
388                "at most {MAX_WIRE_PARAMETER_CHANGES} parameter-id/value-bit pairs"
389            )
390        }
391
392        fn visit_seq<A: SeqAccess<'de>>(self, mut sequence: A) -> Result<Self::Value, A::Error> {
393            let capacity = sequence
394                .size_hint()
395                .unwrap_or(0)
396                .min(MAX_WIRE_PARAMETER_CHANGES);
397            let mut changes = Vec::with_capacity(capacity);
398            while let Some((id, bits)) = sequence.next_element::<(u32, u64)>()? {
399                if changes.len() >= MAX_WIRE_PARAMETER_CHANGES {
400                    return Err(serde::de::Error::custom(
401                        "parameter feedback exceeds wire limit",
402                    ));
403                }
404                changes.push((id, f64::from_bits(bits)));
405            }
406            Ok(changes)
407        }
408    }
409
410    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
411        deserializer: D,
412    ) -> Result<Vec<(u32, f64)>, D::Error> {
413        deserializer.deserialize_seq(ParameterChangesVisitor)
414    }
415}
416
417/// Clamp a wire-provided channel count into `0..=MAX_WIRE_CHANNELS`.
418fn clamped_channel_count<'de, D: Deserializer<'de>>(deserializer: D) -> Result<i32, D::Error> {
419    let raw = i32::deserialize(deserializer)?;
420    Ok(raw.clamp(0, MAX_WIRE_CHANNELS as i32))
421}
422
423/// Clamp a wire-provided bus count into `0..=MAX_WIRE_BUSES`.
424fn clamped_bus_count<'de, D: Deserializer<'de>>(deserializer: D) -> Result<i32, D::Error> {
425    let raw = i32::deserialize(deserializer)?;
426    Ok(raw.clamp(0, MAX_WIRE_BUSES))
427}
428
429/// Commands that can be sent to the isolated plugin process.
430///
431/// This enum is the single source of truth for the isolation IPC protocol — the
432/// helper binary imports it from here rather than redefining it, so the two halves
433/// can never drift apart.
434#[derive(Debug, Clone, Serialize, Deserialize)]
435pub enum HostCommand {
436    /// Load a plugin from the specified path, configured for the given audio settings.
437    LoadPlugin {
438        /// Path to the `.vst3` bundle.
439        path: String,
440        /// Sample rate to configure the plugin for.
441        #[serde(with = "lossless_f64")]
442        sample_rate: f64,
443        /// Block size to configure the plugin for.
444        block_size: u32,
445        /// Transport tempo (BPM) to advertise in the plugin's host `ProcessContext`.
446        #[serde(with = "lossless_f64")]
447        tempo: f64,
448        /// Time signature numerator to advertise in the host `ProcessContext`.
449        time_sig_numerator: i32,
450        /// Time signature denominator to advertise in the host `ProcessContext`.
451        time_sig_denominator: i32,
452        /// Specific current or moduleinfo-retired audio class id to instantiate.
453        #[serde(default)]
454        class_id: Option<String>,
455    },
456    /// Unload the current plugin
457    UnloadPlugin,
458    /// Create plugin GUI
459    CreateGui,
460    /// Close plugin GUI
461    CloseGui,
462    /// Start the plugin's audio processing.
463    StartProcessing,
464    /// Stop the plugin's audio processing.
465    StopProcessing,
466    /// Re-run the plugin's `setupProcessing` at a new sample rate / block size.
467    Reconfigure {
468        /// New sample rate in Hz.
469        #[serde(with = "lossless_f64")]
470        sample_rate: f64,
471        /// New block size in frames.
472        block_size: u32,
473    },
474    /// Switch the plugin between real-time and offline (`kOffline`) processing.
475    SetProcessMode {
476        /// Desired process mode.
477        mode: crate::plugin::ProcessMode,
478    },
479    /// Set a parameter (normalized 0.0..=1.0).
480    SetParameter {
481        /// Parameter id.
482        id: u32,
483        /// Normalized value.
484        #[serde(with = "lossless_f64")]
485        value: f64,
486    },
487    /// Schedule a parameter change at a sample offset within the next process block.
488    SetParameterAt {
489        /// Parameter id.
490        id: u32,
491        /// Normalized value.
492        #[serde(with = "lossless_f64")]
493        value: f64,
494        /// Sample offset within the next processed block.
495        offset: i32,
496    },
497    /// Set the transport tempo (BPM) advertised in the plugin's host `ProcessContext`, taking
498    /// effect on the next processed block.
499    SetTempo {
500        /// Transport tempo in beats per minute (validated `> 0` on the host side).
501        #[serde(with = "lossless_f64")]
502        bpm: f64,
503    },
504    /// Set the transport time signature advertised in the plugin's host `ProcessContext`,
505    /// taking effect on the next processed block.
506    SetTimeSignature {
507        /// Time signature numerator (validated `> 0` on the host side).
508        numerator: i32,
509        /// Time signature denominator (validated `1|2|4|8|16` on the host side).
510        denominator: i32,
511    },
512    /// Toggle the transport playing state (`kPlaying`) in the plugin's host `ProcessContext`,
513    /// taking effect on the next processed block.
514    SetPlaying {
515        /// Whether the transport is playing.
516        playing: bool,
517    },
518    /// Read a parameter's current normalized value.
519    GetParameter {
520        /// Parameter id.
521        id: u32,
522    },
523    /// Read all parameters.
524    GetAllParameters,
525    /// Ask the plugin to format a normalized value as a display string.
526    FormatParameter {
527        /// Parameter id.
528        id: u32,
529        /// Normalized value to format.
530        #[serde(with = "lossless_f64")]
531        normalized: f64,
532    },
533    /// Send a MIDI event to the plugin.
534    SendMidi {
535        /// The event to deliver.
536        event: crate::midi::MidiEvent,
537    },
538    /// Schedule a MIDI event at a sample offset within the next process block.
539    SendMidiAt {
540        /// The event to deliver.
541        event: crate::midi::MidiEvent,
542        /// Sample offset within the next processed block.
543        sample_offset: i32,
544    },
545    /// Send a fully owned VST3 event, including pointer-backed SysEx/text payloads.
546    SendPluginEvent {
547        /// The event to deliver.
548        event: crate::midi::PluginEvent,
549    },
550    /// Release all notes currently tracked by the plugin.
551    MidiPanic,
552    /// Process one block of audio. `inputs` is per-channel; `frames` is the block length.
553    Process {
554        /// Per-channel input samples (`[channel][frame]`), carried as base64 bit patterns.
555        #[serde(with = "audio_codec")]
556        inputs: Vec<Vec<f32>>,
557        /// Number of frames in this block.
558        frames: u32,
559    },
560    /// Process one block while preserving every VST3 audio bus.
561    ProcessBuses {
562        /// Input buses in bus-index order, including inactive buses.
563        inputs: Vec<crate::audio::AudioBusBuffer>,
564        /// Output bus shapes and activation flags.
565        outputs: Vec<crate::audio::AudioBusConfig>,
566        /// Number of frames in this block.
567        frames: u32,
568    },
569    /// Query per-bus channel counts and activation state.
570    AudioBusLayout,
571    /// Serialize the plugin's current state to an opaque byte blob.
572    SaveState,
573    /// Restore the plugin's state from a blob previously returned by `SaveState`.
574    LoadState {
575        /// The opaque state bytes.
576        #[serde(with = "state_codec")]
577        data: Vec<u8>,
578        /// Where the bytes came from, so the helper's `setState` stream carries the same
579        /// `IStreamAttributes` an in-process restore would.
580        ///
581        /// Absent on the wire means [`crate::plugin::StateContext::Project`], which is what
582        /// every release before this field sent — so a new helper reads an old host's
583        /// `LoadState`, and an old helper simply ignores a field it does not know.
584        #[serde(default)]
585        context: crate::plugin::StateContext,
586    },
587    /// Start a note (MPE). The helper's plugin allocates the per-voice note id and returns
588    /// it in [`HostResponse::NoteStarted`] (in isolation the helper owns the real plugin).
589    NoteOn {
590        /// MIDI channel, 0-based index (`MidiChannel::as_index`).
591        channel: u8,
592        /// Note number (0-127).
593        note: u8,
594        /// Velocity (0-127).
595        velocity: u8,
596        /// Sample offset within the next processed block.
597        sample_offset: i32,
598    },
599    /// Release a note previously started with [`HostCommand::NoteOn`].
600    NoteOff {
601        /// Raw note id returned by `NoteOn`.
602        note_id: i32,
603        /// Sample offset within the next processed block.
604        sample_offset: i32,
605    },
606    /// Send a per-note expression value (normalized 0..1) for a voice. The expression
607    /// dimension crosses the boundary as the serializable `NoteExpressionType` enum.
608    SendNoteExpression {
609        /// Raw note id returned by `NoteOn`.
610        note_id: i32,
611        /// Which note-expression dimension to set.
612        kind: crate::midi::NoteExpressionType,
613        /// Normalized expression value (0..1).
614        #[serde(with = "lossless_f64")]
615        value: f64,
616        /// Sample offset within the next processed block.
617        sample_offset: i32,
618    },
619    /// Enumerate the per-note expressions the plugin advertises (`INoteExpressionController`).
620    NoteExpressions {
621        /// Event bus index.
622        bus: i32,
623        /// Channel index.
624        channel: i16,
625    },
626    /// Select a program in a unit's program list (`IUnitInfo`).
627    SelectProgram {
628        /// Unit id (the root unit is `0`).
629        unit_id: i32,
630        /// 0-based index into the unit's program list.
631        program_index: i32,
632    },
633    /// Activate or deactivate a single bus (`IComponent::activateBus`).
634    SetBusActive {
635        /// Whether the bus carries audio or events.
636        media_type: crate::audio::MediaType,
637        /// Whether the bus is an input or an output.
638        direction: crate::audio::BusDirection,
639        /// 0-based bus index within its `(media_type, direction)` group.
640        bus_index: i32,
641        /// `true` to activate, `false` to deactivate.
642        active: bool,
643    },
644    /// Query each audio bus's current speaker arrangement (`IAudioProcessor::getBusArrangement`).
645    BusArrangements,
646    /// Request specific speaker arrangements for the audio buses (re-runs `setupProcessing`).
647    SetBusArrangements {
648        /// Desired arrangement per input bus, in bus-index order.
649        inputs: Vec<crate::audio::SpeakerArrangement>,
650        /// Desired arrangement per output bus, in bus-index order.
651        outputs: Vec<crate::audio::SpeakerArrangement>,
652    },
653    /// Enumerate the plugin's units and their program lists (`IUnitInfo`).
654    GetUnits,
655    /// Query the currently selected unit.
656    GetSelectedUnit,
657    /// Select a unit.
658    SelectUnit {
659        /// Unit id.
660        unit_id: i32,
661    },
662    /// Query pitch names for a program.
663    ProgramPitchNames {
664        /// Program-list id.
665        program_list_id: i32,
666        /// Program index.
667        program_index: i32,
668    },
669    /// Read opaque data for a program.
670    GetProgramData {
671        /// Program-list id.
672        program_list_id: i32,
673        /// Program index.
674        program_index: i32,
675    },
676    /// Restore opaque data for a program.
677    SetProgramData {
678        /// Program-list id.
679        program_list_id: i32,
680        /// Program index.
681        program_index: i32,
682        /// Opaque plugin data.
683        #[serde(with = "state_codec")]
684        data: Vec<u8>,
685    },
686    /// Read opaque data for a unit.
687    GetUnitData {
688        /// Unit id.
689        unit_id: i32,
690    },
691    /// Restore opaque data for a unit.
692    SetUnitData {
693        /// Unit id.
694        unit_id: i32,
695        /// Opaque plugin data.
696        #[serde(with = "state_codec")]
697        data: Vec<u8>,
698    },
699    /// Begin a host-edit session.
700    BeginHostEdit {
701        /// Parameter id.
702        parameter_id: u32,
703    },
704    /// End a host-edit session.
705    EndHostEdit {
706        /// Parameter id.
707        parameter_id: u32,
708    },
709    /// Forward live MIDI controller input to `IMidiLearn`.
710    SendMidiLearn {
711        /// Event bus.
712        bus: i32,
713        /// MIDI channel.
714        channel: i16,
715        /// MIDI controller number.
716        controller: u16,
717    },
718    /// Report the current automation state.
719    SetAutomationState {
720        /// Automation state.
721        state: crate::plugin::AutomationState,
722    },
723    /// Map a parameter id from a plugin class this controller replaces (`IRemapParamID`).
724    RemapParameterId {
725        /// Canonical separator-free 32-hex-character VST3 class id.
726        old_plugin_uid: String,
727        /// Parameter id used by the replaced plugin class.
728        old_param_id: u32,
729    },
730    /// Query the plugin's reported processing latency in samples
731    /// (`IAudioProcessor::getLatencySamples`).
732    LatencySamples,
733    /// Query the plugin's reported tail length in samples (`IAudioProcessor::getTailSamples`).
734    TailSamples,
735    /// Resolve a MIDI controller to the parameter it's mapped to (`IMidiMapping`).
736    MidiCcToParameter {
737        /// Event input bus index.
738        bus: i32,
739        /// 0-based MIDI channel.
740        channel: i16,
741        /// MIDI controller number (0-127, or a VST3 special such as aftertouch/pitch-bend).
742        cc: u16,
743    },
744    /// Drain the ordered parameter-edit gesture log (begin/change/end) the helper's plugin has
745    /// accumulated from its editor since the last poll.
746    TakeParameterEdits,
747    /// Drain processor- and controller-originated parameter value feedback.
748    TakeParameterChanges,
749    /// Drain ordered `IComponentHandler2` requests from the helper.
750    TakeHostNotifications,
751    /// Dispatch and drain owned VST3 data-exchange blocks from the helper.
752    TakeDataExchangeBlocks,
753    /// Execute an item from a pending plugin-provided context menu.
754    ExecuteContextMenuItem {
755        /// Host-assigned popup id.
756        menu_id: u64,
757        /// Host-assigned item id within the popup.
758        item_id: u32,
759    },
760    /// Dismiss a pending plugin-provided context menu.
761    DismissContextMenu {
762        /// Host-assigned popup id.
763        menu_id: u64,
764    },
765    /// Drain accumulated `restartComponent` flags without applying lifecycle changes.
766    TakeRestartFlags,
767    /// Drain restart flags and apply required lifecycle changes in the helper.
768    ServiceHostRequests,
769    /// Shutdown the helper process
770    Shutdown,
771}
772
773/// Responses from the isolated plugin process
774#[derive(Debug, Serialize, Deserialize)]
775pub enum HostResponse {
776    /// Operation succeeded with message
777    Success {
778        /// Human-readable success detail.
779        message: String,
780    },
781    /// Operation failed with error
782    Error {
783        /// Error detail.
784        message: String,
785    },
786    /// Plugin crashed
787    Crashed {
788        /// Crash detail.
789        message: String,
790    },
791    /// Per-channel audio output data (`[channel][frame]`), plus any MIDI the plugin
792    /// emitted during the block (arpeggiators, MPE, etc.).
793    AudioOutput {
794        /// Output samples per channel, carried as base64 bit patterns.
795        #[serde(with = "audio_codec")]
796        outputs: Vec<Vec<f32>>,
797        /// Owned VST3 events the plugin emitted this block, in order.
798        output_events: Vec<crate::midi::PluginEvent>,
799    },
800    /// Bus-preserving audio output from a `ProcessBuses` request.
801    BusAudioOutput {
802        /// Output buses in VST3 bus-index order.
803        outputs: Vec<crate::audio::AudioBusBuffer>,
804        /// Owned VST3 events emitted during the block.
805        output_events: Vec<crate::midi::PluginEvent>,
806    },
807    /// Current audio-bus layout and activation state.
808    AudioBusLayout {
809        /// Complete input/output layout.
810        layout: crate::audio::AudioBusLayout,
811    },
812    /// A single parameter value (normalized).
813    ParameterValue {
814        /// Normalized value.
815        #[serde(with = "lossless_f64")]
816        value: f64,
817    },
818    /// A formatted parameter display string.
819    ParameterString {
820        /// The plugin-rendered display string.
821        value: String,
822    },
823    /// A list of parameters.
824    Parameters {
825        /// All parameters reported by the plugin.
826        params: Vec<crate::parameters::Parameter>,
827    },
828    /// Opaque plugin state bytes (reply to `SaveState`).
829    State {
830        /// The serialized state.
831        #[serde(with = "state_codec")]
832        data: Vec<u8>,
833    },
834    /// The isolated editor window was created (reply to `CreateGui`); carries the
835    /// plugin-reported editor size so the host can report it without a second round-trip.
836    GuiCreated {
837        /// Editor width in pixels.
838        width: i32,
839        /// Editor height in pixels.
840        height: i32,
841    },
842    /// Plugin information
843    PluginInfo {
844        /// Vendor / manufacturer.
845        vendor: String,
846        /// Plugin name.
847        name: String,
848        /// Version string (may be empty if the plugin doesn't report one).
849        version: String,
850        /// Plugin sub-categories (e.g. "Fx", "Instrument|Synth"); may be empty.
851        category: String,
852        /// Unique plugin class id (hex).
853        uid: String,
854        /// Whether the plugin has an editor.
855        has_gui: bool,
856        /// Audio input bus count (clamped to a sane maximum on receipt).
857        #[serde(deserialize_with = "clamped_bus_count")]
858        audio_inputs: i32,
859        /// Audio output bus count (clamped to a sane maximum on receipt).
860        #[serde(deserialize_with = "clamped_bus_count")]
861        audio_outputs: i32,
862        /// Total output audio channels across all output buses (clamped on receipt: the host
863        /// sizes buffers from this number, so it is not trusted verbatim).
864        #[serde(deserialize_with = "clamped_channel_count")]
865        output_channels: i32,
866        /// Whether the plugin has a MIDI/event input bus.
867        has_midi_input: bool,
868        /// Whether the plugin has a MIDI/event output bus.
869        has_midi_output: bool,
870        /// Current/retired class-id mappings discovered by the helper.
871        #[serde(default)]
872        compatibility: Vec<crate::discovery::ClassCompatibility>,
873    },
874    /// A note was started (reply to `NoteOn`); carries the helper-allocated raw note id.
875    NoteStarted {
876        /// Raw note id the host wraps back into a `NoteId`.
877        note_id: i32,
878    },
879    /// The per-note expressions the plugin advertises (reply to `NoteExpressions`).
880    NoteExpressions {
881        /// The advertised note-expression dimensions.
882        expressions: Vec<crate::midi::NoteExpressionInfo>,
883    },
884    /// The ordered parameter-edit gestures drained from the helper (reply to
885    /// `TakeParameterEdits`).
886    ParameterEdits {
887        /// The gesture events, in the order the plugin's editor reported them.
888        edits: Vec<crate::plugin::ParameterEdit>,
889    },
890    /// Processor- and controller-originated parameter feedback drained from the helper.
891    ParameterChanges {
892        /// Parameter id and normalized value pairs, in drain order.
893        #[serde(with = "parameter_changes_codec")]
894        changes: Vec<(u32, f64)>,
895    },
896    /// Ordered `IComponentHandler2` requests drained from the helper.
897    HostNotifications {
898        /// The queued host requests.
899        notifications: Vec<crate::plugin::HostNotification>,
900    },
901    /// Owned VST3 data-exchange blocks drained from the helper.
902    DataExchangeBlocks {
903        /// Block snapshots, in delivery order.
904        blocks: Vec<crate::plugin::DataExchangeBlock>,
905    },
906    /// Accumulated `restartComponent` flags.
907    RestartFlags {
908        /// Raw VST3 restart flag bits.
909        bits: i32,
910    },
911    /// Each audio bus's current speaker arrangement (reply to `BusArrangements`).
912    BusArrangements {
913        /// The input/output arrangements.
914        arrangements: crate::audio::BusArrangements,
915    },
916    /// The plugin's units and program lists (reply to `GetUnits`).
917    Units {
918        /// The advertised units.
919        units: Vec<crate::plugin::PluginUnit>,
920    },
921    /// Currently selected unit.
922    SelectedUnit {
923        /// Unit id, or `None` when units are unsupported/unselected.
924        unit_id: Option<i32>,
925    },
926    /// Program pitch names.
927    ProgramPitchNames {
928        /// Advertised pitch names.
929        names: Vec<crate::plugin::ProgramPitchName>,
930    },
931    /// Opaque program/unit data.
932    OpaqueData {
933        /// Whether the corresponding interface/data kind is supported.
934        supported: bool,
935        /// Opaque bytes; empty is a valid supported payload.
936        #[serde(with = "state_codec")]
937        data: Vec<u8>,
938    },
939    /// The plugin's reported processing latency in samples (reply to `LatencySamples`).
940    LatencySamples {
941        /// Latency in samples.
942        samples: u32,
943    },
944    /// The plugin's reported tail length in samples (reply to `TailSamples`).
945    TailSamples {
946        /// Tail length in samples.
947        samples: u32,
948    },
949    /// The parameter a MIDI controller is mapped to, if any (reply to `MidiCcToParameter`).
950    MidiParameterMapping {
951        /// The mapped parameter id, or `None` if unmapped / not implemented.
952        id: Option<u32>,
953    },
954    /// A parameter-id compatibility mapping returned by `IRemapParamID`.
955    RemappedParameter {
956        /// The replacement parameter id, or `None` if unsupported/unmapped.
957        id: Option<u32>,
958    },
959}
960
961/// The helper process's write half of the request/response protocol.
962///
963/// A loaded VST3 plugin shares the helper's file descriptors, and third-party plugins do
964/// print to stdout. Since the protocol is a line stream over the helper's stdout, one stray
965/// `printf` would be read by the host as a response and desynchronise every later exchange.
966///
967/// [`ProtocolChannel::claim`] therefore takes stdout away from the plugin: on Unix it
968/// duplicates the inherited stdout onto a private, close-on-exec descriptor and repoints file
969/// descriptor 1 at stderr, so plugin output is merged into the helper's stderr instead. Call
970/// it once, before any plugin code can run.
971///
972/// On non-Unix platforms the protocol still runs over the process stdout; a plugin writing
973/// there corrupts the stream (the host drops lines it cannot parse, which limits the damage
974/// to noise, but a well-formed line would still be taken for a response).
975pub struct ProtocolChannel {
976    inner: ProtocolChannelInner,
977}
978
979#[cfg(unix)]
980type ProtocolChannelInner = std::fs::File;
981#[cfg(not(unix))]
982type ProtocolChannelInner = std::io::Stdout;
983
984impl ProtocolChannel {
985    /// Claim the protocol channel for this process. See the type documentation.
986    #[cfg(unix)]
987    pub fn claim() -> Self {
988        use std::os::fd::FromRawFd;
989
990        // SAFETY: `F_DUPFD_CLOEXEC`/`dup` return a fresh descriptor owned by this process, and
991        // `dup2` rebinds STDOUT_FILENO, which this process also owns. Both are the documented
992        // POSIX contracts; no descriptor Rust already owns as a `File` is aliased or closed.
993        let fd = unsafe {
994            let private = match libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD_CLOEXEC, 3) {
995                fd if fd >= 0 => Some(fd),
996                _ => match libc::dup(libc::STDOUT_FILENO) {
997                    fd if fd >= 0 => Some(fd),
998                    _ => None,
999                },
1000            };
1001            match private {
1002                Some(fd) => {
1003                    // Plugin (and helper) writes to stdout now land on stderr.
1004                    libc::dup2(libc::STDERR_FILENO, libc::STDOUT_FILENO);
1005                    fd
1006                }
1007                None => {
1008                    // Nothing to fall back to: keep speaking on fd 1 as-is, so the protocol
1009                    // still works even though plugin writes can pollute it.
1010                    eprintln!("helper: could not privatise the protocol channel; plugin writes to stdout may corrupt it");
1011                    libc::STDOUT_FILENO
1012                }
1013            }
1014        };
1015        // SAFETY: `fd` is a descriptor this process owns — a fresh duplicate, or stdout
1016        // itself when duplication failed — and this channel becomes its sole owner.
1017        Self {
1018            inner: unsafe { std::fs::File::from_raw_fd(fd) },
1019        }
1020    }
1021
1022    /// Claim the protocol channel for this process. See the type documentation.
1023    #[cfg(not(unix))]
1024    pub fn claim() -> Self {
1025        Self {
1026            inner: std::io::stdout(),
1027        }
1028    }
1029}
1030
1031impl Write for ProtocolChannel {
1032    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1033        self.inner.write(buf)
1034    }
1035
1036    fn flush(&mut self) -> std::io::Result<()> {
1037        self.inner.flush()
1038    }
1039}
1040
1041/// Manages a plugin running in an isolated process.
1042///
1043/// Responses are read on a background thread and delivered over a channel, so
1044/// [`Self::send_command`] can wait with a deadline: a hung plugin yields a timeout
1045/// error (and the child is killed) instead of blocking the host forever, and a
1046/// crashed helper surfaces as a disconnect error rather than a silent wedge.
1047pub struct PluginHostProcess {
1048    process: Option<Child>,
1049    stdin: Option<ChildStdin>,
1050    /// Lines received from the helper's stdout (one JSON response each).
1051    responses: Receiver<String>,
1052    /// Background reader thread handle (joined, with a bound, on shutdown).
1053    reader: Option<JoinHandle<()>>,
1054    /// Set by the reader thread when it is about to exit, so shutdown can join it only when
1055    /// the join is known to be instant.
1056    reader_finished: Arc<AtomicBool>,
1057    /// Lines queued but not yet taken by [`Self::send_command`]; bounds the reader's buffer.
1058    queued: Arc<AtomicUsize>,
1059    /// Lines the reader refused because the queue was full or the line was oversized.
1060    discarded_by_reader: Arc<AtomicU64>,
1061    /// Lines received that did not parse as a response (helper noise), for diagnostics.
1062    unparsed_lines: u64,
1063    /// How long to wait for a single response before declaring a timeout.
1064    timeout: Duration,
1065    /// Deadline for the slow command class ([`is_slow_command`]).
1066    slow_timeout: Duration,
1067    /// Set once the child has been killed/exited so we stop trying to talk to it.
1068    dead: bool,
1069    /// The helper binary this child was spawned from, kept so [`Self::send_command`] can put a
1070    /// fresh one in its place when a load kills it. Resolved once by [`Self::new`], so a
1071    /// respawn cannot pick a different binary than the original search did.
1072    helper_path: std::path::PathBuf,
1073}
1074
1075/// How many times a `LoadPlugin` that killed the helper is replayed against a freshly spawned
1076/// one.
1077///
1078/// Loading a plugin runs the plugin's own module and instance initialization, and some real
1079/// plugins lose a race in there: Dexed (JUCE) segfaults or aborts inside
1080/// `juce::MessageQueue::runLoopSourceCallback` on roughly 6% of cold loads, dispatching an
1081/// async update into an object whose construction has not finished. Nothing the host does
1082/// provokes it and nothing it does prevents it — but a *fresh* helper is an independent roll,
1083/// and the crashed one had no state worth preserving (its load never completed), so replaying
1084/// the load is exactly what a human would do.
1085///
1086/// One retry, not a loop: it takes a 6% failure to 0.4%, while a plugin that genuinely cannot
1087/// load still reports that after two attempts instead of grinding.
1088const LOAD_CRASH_RETRIES: u32 = 1;
1089
1090/// Pause before replaying a crashed load, so the retry is not a tight respawn loop and the
1091/// dying child's teardown (crash reporter, atexit handlers) has a moment to finish.
1092const LOAD_CRASH_RETRY_BACKOFF: Duration = Duration::from_millis(250);
1093
1094/// Why one host↔helper exchange failed.
1095///
1096/// The variants exist to tell "the helper died while handling *this* command" — the only case
1097/// worth replaying against a fresh child — from a timeout, a helper that was already gone, or
1098/// a local transport failure. Each carries the message the public API reports, unchanged:
1099/// [`crate::internal::isolated_plugin_impl`] classifies those strings into
1100/// [`crate::Error::PluginCrashed`] / [`crate::Error::PluginTimeout`].
1101enum ExchangeError {
1102    /// The helper was already known dead; nothing was sent.
1103    AlreadyDead(String),
1104    /// The helper crashed or exited while this command was in flight.
1105    DiedDuringCommand(String),
1106    /// No answer within the deadline; the child has been killed.
1107    TimedOut(String),
1108    /// The command could not be encoded (never reached the helper).
1109    Encoding(String),
1110}
1111
1112impl From<ExchangeError> for String {
1113    fn from(error: ExchangeError) -> String {
1114        match error {
1115            ExchangeError::AlreadyDead(message)
1116            | ExchangeError::DiedDuringCommand(message)
1117            | ExchangeError::TimedOut(message)
1118            | ExchangeError::Encoding(message) => message,
1119        }
1120    }
1121}
1122
1123/// One read from the helper's stdout.
1124enum ReadLine {
1125    /// A complete line (newline included), within the size cap.
1126    Line(Vec<u8>),
1127    /// A line longer than the cap; its bytes were discarded rather than buffered.
1128    Oversized,
1129    /// The stream ended (helper exited) or errored.
1130    Eof,
1131}
1132
1133/// Read one newline-terminated line, discarding (rather than buffering) anything longer than
1134/// `max` bytes so a helper that never terminates a line cannot exhaust host memory.
1135fn read_bounded_line(reader: &mut impl BufRead, max: usize) -> ReadLine {
1136    let mut line = Vec::new();
1137    let mut oversized = false;
1138    loop {
1139        let budget = (max + 1 - line.len()) as u64;
1140        let mut chunk = Vec::new();
1141        let read = match reader.by_ref().take(budget).read_until(b'\n', &mut chunk) {
1142            Ok(n) => n,
1143            Err(_) => return ReadLine::Eof,
1144        };
1145        let complete = chunk.last() == Some(&b'\n');
1146        if !oversized {
1147            line.extend_from_slice(&chunk);
1148            if line.len() > max {
1149                oversized = true;
1150                line = Vec::new();
1151            }
1152        }
1153        if read == 0 {
1154            // EOF: a trailing partial line is still worth delivering, an oversized one is not.
1155            return if oversized {
1156                ReadLine::Oversized
1157            } else if line.is_empty() {
1158                ReadLine::Eof
1159            } else {
1160                ReadLine::Line(line)
1161            };
1162        }
1163        if complete {
1164            return if oversized {
1165                ReadLine::Oversized
1166            } else {
1167                ReadLine::Line(line)
1168            };
1169        }
1170    }
1171}
1172
1173impl PluginHostProcess {
1174    /// Create a new isolated plugin host process
1175    pub fn new(
1176        helper_override: Option<std::path::PathBuf>,
1177        timeout: Duration,
1178    ) -> Result<Self, String> {
1179        // An explicit helper path (builder option or the VST3_HOST_HELPER_PATH env var) wins
1180        // over the heuristic search below — and a missing one is reported clearly here.
1181        let override_path = helper_override
1182            .or_else(|| std::env::var_os("VST3_HOST_HELPER_PATH").map(std::path::PathBuf::from));
1183        if let Some(p) = override_path {
1184            if !p.exists() {
1185                return Err(format!(
1186                    "Configured helper path does not exist: {}",
1187                    p.display()
1188                ));
1189            }
1190            return Self::spawn(p, timeout);
1191        }
1192
1193        // Get the path to our helper executable
1194        let exe_path =
1195            std::env::current_exe().map_err(|e| format!("Failed to get current exe: {}", e))?;
1196
1197        let exe_dir = exe_path.parent().ok_or("Failed to get exe directory")?;
1198
1199        // Try different possible helper names and locations
1200        let helper_names = ["vst3-host-helper", "vst3-inspector-helper"];
1201        let mut helper_path = None;
1202
1203        // First try in the same directory as the executable
1204        for name in &helper_names {
1205            let path = exe_dir.join(name);
1206            if path.exists() {
1207                helper_path = Some(path);
1208                break;
1209            }
1210        }
1211
1212        // If not found and we're in an examples directory, try parent
1213        if helper_path.is_none() && exe_dir.file_name() == Some(std::ffi::OsStr::new("examples")) {
1214            if let Some(parent_dir) = exe_dir.parent() {
1215                for name in &helper_names {
1216                    let path = parent_dir.join(name);
1217                    if path.exists() {
1218                        helper_path = Some(path);
1219                        break;
1220                    }
1221                }
1222            }
1223        }
1224
1225        // Also check common cargo target directories.
1226        //
1227        // Only when *we* are running from inside a cargo target tree — that is the case this
1228        // fallback exists for (test binaries live in `target/<profile>/deps`, so the checks above
1229        // don't find the sibling helper). For a deployed application it would be a liability: the
1230        // walk reaches into directories an unprivileged process can write, and the binary it finds
1231        // is spawned and then trusted for every answer the host gets about the plugin. Deployed
1232        // builds use the explicit `helper_path`/env override or a helper beside the executable.
1233        if helper_path.is_none() && crate::discovery::running_from_cargo_target(exe_dir) {
1234            // Try to find the workspace root and look in target/debug or target/release
1235            let mut current_dir = exe_dir;
1236            while let Some(parent) = current_dir.parent() {
1237                let debug_path = parent.join("target").join("debug").join("vst3-host-helper");
1238                let release_path = parent
1239                    .join("target")
1240                    .join("release")
1241                    .join("vst3-host-helper");
1242
1243                if debug_path.exists() {
1244                    helper_path = Some(debug_path);
1245                    break;
1246                } else if release_path.exists() {
1247                    helper_path = Some(release_path);
1248                    break;
1249                }
1250
1251                // Check if we've reached a Cargo.toml (workspace root)
1252                if parent.join("Cargo.toml").exists() {
1253                    break;
1254                }
1255                current_dir = parent;
1256            }
1257        }
1258
1259        let helper_path = helper_path
1260            .ok_or_else(|| format!("Helper executable not found. Searched in {:?} and parent directories. Make sure to build with --bins flag.", exe_dir))?;
1261
1262        Self::spawn(helper_path, timeout)
1263    }
1264
1265    /// Spawn the helper at `helper_path` and wire up the response reader thread.
1266    fn spawn(helper_path: std::path::PathBuf, timeout: Duration) -> Result<Self, String> {
1267        let mut child = Command::new(&helper_path)
1268            .stdin(Stdio::piped())
1269            .stdout(Stdio::piped())
1270            .stderr(Stdio::inherit())
1271            .spawn()
1272            .map_err(|e| format!("Failed to spawn helper process: {}", e))?;
1273
1274        let stdin = child.stdin.take().ok_or("Failed to get stdin")?;
1275        let stdout = child.stdout.take().ok_or("Failed to get stdout")?;
1276
1277        // Read responses on a background thread so the caller can apply a deadline.
1278        // The thread ends (dropping the sender) when stdout hits EOF — i.e. when the
1279        // helper process exits or crashes — which the receiver sees as Disconnected.
1280        //
1281        // Nothing the helper sends is trusted for size: lines are read with a byte cap and
1282        // the queue of not-yet-consumed lines is bounded, so neither an unterminated line nor
1283        // a helper that spews between commands can grow the host's memory without bound.
1284        let (tx, rx) = mpsc::channel::<String>();
1285        let queued = Arc::new(AtomicUsize::new(0));
1286        let discarded = Arc::new(AtomicU64::new(0));
1287        let finished = Arc::new(AtomicBool::new(false));
1288        let reader = std::thread::spawn({
1289            let queued = Arc::clone(&queued);
1290            let discarded = Arc::clone(&discarded);
1291            let finished = Arc::clone(&finished);
1292            move || {
1293                let mut reader = BufReader::new(stdout);
1294                loop {
1295                    match read_bounded_line(&mut reader, MAX_RESPONSE_LINE_BYTES) {
1296                        ReadLine::Eof => break,
1297                        ReadLine::Oversized => {
1298                            discarded.fetch_add(1, Ordering::Relaxed);
1299                        }
1300                        ReadLine::Line(bytes) => {
1301                            if queued.load(Ordering::Relaxed) >= MAX_QUEUED_RESPONSES {
1302                                discarded.fetch_add(1, Ordering::Relaxed);
1303                                continue;
1304                            }
1305                            queued.fetch_add(1, Ordering::Relaxed);
1306                            // Lossy: a plugin can put arbitrary bytes on the stream, and
1307                            // mangled noise must not end the reader thread.
1308                            let line = String::from_utf8_lossy(&bytes).into_owned();
1309                            if tx.send(line).is_err() {
1310                                break; // receiver dropped
1311                            }
1312                        }
1313                    }
1314                }
1315                finished.store(true, Ordering::Release);
1316            }
1317        });
1318
1319        Ok(Self {
1320            process: Some(child),
1321            stdin: Some(stdin),
1322            responses: rx,
1323            reader: Some(reader),
1324            reader_finished: finished,
1325            queued,
1326            discarded_by_reader: discarded,
1327            unparsed_lines: 0,
1328            timeout,
1329            slow_timeout: DEFAULT_SLOW_COMMAND_TIMEOUT.max(timeout),
1330            dead: false,
1331            helper_path,
1332        })
1333    }
1334
1335    /// Put a freshly spawned helper in place of the current (dead) child, keeping the
1336    /// deadlines and the diagnostic counters this handle has accumulated.
1337    fn respawn(&mut self) -> Result<(), String> {
1338        self.shutdown();
1339        let slow_timeout = self.slow_timeout;
1340        let unparsed_lines = self.unparsed_lines;
1341        let discarded = self.discarded_by_reader.load(Ordering::Relaxed);
1342
1343        *self = Self::spawn(self.helper_path.clone(), self.timeout)?;
1344
1345        self.slow_timeout = slow_timeout;
1346        self.unparsed_lines = unparsed_lines;
1347        self.discarded_by_reader
1348            .fetch_add(discarded, Ordering::Relaxed);
1349        Ok(())
1350    }
1351
1352    /// Set how long to wait for a helper response before declaring a timeout.
1353    pub fn set_timeout(&mut self, timeout: Duration) {
1354        self.timeout = timeout;
1355    }
1356
1357    /// Set the deadline used for the slow command class — loading a plugin and saving or
1358    /// restoring its state. Never shorter than the per-command timeout.
1359    pub fn set_slow_command_timeout(&mut self, timeout: Duration) {
1360        self.slow_timeout = timeout;
1361    }
1362
1363    /// The deadline to apply to `command`.
1364    fn timeout_for(&self, command: &HostCommand) -> Duration {
1365        if is_slow_command(command) {
1366            self.slow_timeout.max(self.timeout)
1367        } else {
1368            self.timeout
1369        }
1370    }
1371
1372    /// Drop lines that arrived before this command was sent: they are stale replies or
1373    /// unsolicited output, never the answer to what we are about to ask.
1374    fn drop_stale_lines(&mut self) {
1375        let mut stale = 0u64;
1376        while self.responses.try_recv().is_ok() {
1377            self.queued.fetch_sub(1, Ordering::Relaxed);
1378            stale += 1;
1379        }
1380        if stale > 0 {
1381            self.unparsed_lines += stale;
1382            log::warn!("isolation: dropped {stale} unsolicited line(s) from the helper");
1383        }
1384    }
1385
1386    /// The child's exit status if it has already exited, `None` while it is still running.
1387    fn exit_status(&mut self) -> Option<std::process::ExitStatus> {
1388        self.process
1389            .as_mut()
1390            .and_then(|p| p.try_wait().ok().flatten())
1391    }
1392
1393    /// How many lines from the helper were discarded (oversized, over-queued, unparseable or
1394    /// unsolicited). A non-zero count means the helper is putting non-protocol data on the
1395    /// stream — typically a plugin writing to stdout on a platform where the protocol channel
1396    /// cannot be made private.
1397    pub fn discarded_line_count(&self) -> u64 {
1398        self.unparsed_lines + self.discarded_by_reader.load(Ordering::Relaxed)
1399    }
1400
1401    /// Send a command to the helper process and wait (with a deadline) for a response.
1402    ///
1403    /// Returns an error without blocking indefinitely if the plugin hangs (the child
1404    /// is killed) or the helper has crashed/exited.
1405    ///
1406    /// A line that does not parse as a response is either a helper that died mid-write —
1407    /// reported as a crash — or noise on the stream, which is dropped so the exchange stays
1408    /// in sync instead of answering every later command with the previous one's reply. The
1409    /// deadline covers the whole exchange, not each individual line.
1410    ///
1411    /// One command heals itself: a [`HostCommand::LoadPlugin`] that *kills* the helper is
1412    /// replayed once against a freshly spawned one. A helper
1413    /// that died mid-load holds nothing worth preserving — its load never completed — so the
1414    /// replay is observationally identical to the caller having spawned the helper a moment
1415    /// later, and it absorbs the cold-load crashes some real plugins lose a race to. A
1416    /// timeout is not retried (it already cost a full deadline, and a plugin that hangs while
1417    /// loading will hang again), and no other command is: those run against a helper holding
1418    /// live plugin state that a fresh process would not have.
1419    pub fn send_command(&mut self, command: HostCommand) -> Result<HostResponse, String> {
1420        if !matches!(command, HostCommand::LoadPlugin { .. }) {
1421            return self.exchange(command).map_err(String::from);
1422        }
1423
1424        let mut retries_left = LOAD_CRASH_RETRIES;
1425        loop {
1426            match self.exchange(command.clone()) {
1427                Ok(response) => return Ok(response),
1428                Err(ExchangeError::DiedDuringCommand(detail)) if retries_left > 0 => {
1429                    retries_left -= 1;
1430                    log::warn!(
1431                        "isolation: the helper died while loading the plugin ({detail}); \
1432                         retrying once with a fresh helper"
1433                    );
1434                    std::thread::sleep(LOAD_CRASH_RETRY_BACKOFF);
1435                    if let Err(spawn_error) = self.respawn() {
1436                        // No fresh helper to retry against — report the crash, not the spawn.
1437                        log::warn!("isolation: could not respawn the helper: {spawn_error}");
1438                        return Err(detail);
1439                    }
1440                }
1441                Err(other) => return Err(String::from(other)),
1442            }
1443        }
1444    }
1445
1446    /// One request/response exchange with the current child, with no recovery.
1447    fn exchange(&mut self, command: HostCommand) -> Result<HostResponse, ExchangeError> {
1448        if self.dead {
1449            return Err(ExchangeError::AlreadyDead(
1450                "Helper process is no longer running".to_string(),
1451            ));
1452        }
1453
1454        let command_json = serde_json::to_string(&command)
1455            .map_err(|e| ExchangeError::Encoding(format!("Failed to serialize command: {}", e)))?;
1456
1457        // Anything already queued predates this command.
1458        self.drop_stale_lines();
1459
1460        {
1461            let Some(stdin) = self.stdin.as_mut() else {
1462                return Err(ExchangeError::AlreadyDead("No stdin available".to_string()));
1463            };
1464            if let Err(e) = writeln!(stdin, "{}", command_json).and_then(|()| stdin.flush()) {
1465                self.dead = true;
1466                return Err(ExchangeError::DiedDuringCommand(format!(
1467                    "Failed to write command (helper gone?): {}",
1468                    e
1469                )));
1470            }
1471        }
1472
1473        let timeout = self.timeout_for(&command);
1474        let deadline = Instant::now() + timeout;
1475        loop {
1476            let remaining = deadline.saturating_duration_since(Instant::now());
1477            match self.responses.recv_timeout(remaining) {
1478                Ok(line) => {
1479                    self.queued.fetch_sub(1, Ordering::Relaxed);
1480                    match serde_json::from_str::<HostResponse>(&line) {
1481                        Ok(response) => return Ok(response),
1482                        Err(parse_error) => {
1483                            // A helper that died mid-write leaves a truncated line behind:
1484                            // that is a crash, not noise, and must be reported as one.
1485                            if let Some(status) = self.exit_status() {
1486                                self.dead = true;
1487                                return Err(ExchangeError::DiedDuringCommand(format!(
1488                                    "Helper process crashed: exited with {status} while writing a response ({parse_error})"
1489                                )));
1490                            }
1491                            self.unparsed_lines += 1;
1492                            log::warn!(
1493                                "isolation: dropping unparseable line from the helper ({parse_error})"
1494                            );
1495                        }
1496                    }
1497                }
1498                Err(RecvTimeoutError::Timeout) => {
1499                    // The plugin is hung. Kill the child so it can't wedge us further.
1500                    self.dead = true;
1501                    if let Some(ref mut process) = self.process {
1502                        let _ = process.kill();
1503                    }
1504                    return Err(ExchangeError::TimedOut(format!(
1505                        "Timed out after {:?} waiting for helper response (plugin may have hung)",
1506                        timeout
1507                    )));
1508                }
1509                Err(RecvTimeoutError::Disconnected) => {
1510                    // Reader thread ended => stdout closed => helper exited/crashed.
1511                    self.dead = true;
1512                    let detail = match self.check_process_status() {
1513                        Err(status) => format!("Helper process crashed: {}", status),
1514                        Ok(()) => "Helper process exited unexpectedly".to_string(),
1515                    };
1516                    return Err(ExchangeError::DiedDuringCommand(detail));
1517                }
1518            }
1519        }
1520    }
1521
1522    /// Whether the helper process is still considered alive.
1523    pub fn is_alive(&self) -> bool {
1524        !self.dead
1525    }
1526
1527    /// OS process id of the running helper, if any. Useful for monitoring — and for tests
1528    /// that need to simulate a crash by killing the helper.
1529    pub fn helper_pid(&self) -> Option<u32> {
1530        self.process.as_ref().map(|c| c.id())
1531    }
1532
1533    /// Check if the helper process is still running
1534    pub fn check_process_status(&mut self) -> Result<(), String> {
1535        if let Some(ref mut process) = self.process {
1536            match process.try_wait() {
1537                Ok(Some(status)) => {
1538                    if !status.success() {
1539                        return Err(format!("Helper process exited with status: {}", status));
1540                    }
1541                }
1542                Ok(None) => {
1543                    // Still running
1544                    return Ok(());
1545                }
1546                Err(e) => {
1547                    return Err(format!("Failed to check process status: {}", e));
1548                }
1549            }
1550        }
1551        Ok(())
1552    }
1553
1554    /// Shutdown the helper process
1555    pub fn shutdown(&mut self) {
1556        // Best-effort Shutdown command (no response expected — the helper just exits).
1557        // We do NOT use send_command here: it waits for a reply, and Shutdown has none.
1558        if !self.dead {
1559            if let (Some(stdin), Ok(json)) = (
1560                self.stdin.as_mut(),
1561                serde_json::to_string(&HostCommand::Shutdown),
1562            ) {
1563                let _ = writeln!(stdin, "{}", json);
1564                let _ = stdin.flush();
1565            }
1566        }
1567
1568        // Dropping stdin gives the helper's read loop EOF, guaranteeing it exits even
1569        // if it ignored the Shutdown command; that in turn ends the reader thread.
1570        self.stdin = None;
1571
1572        if let Some(mut process) = self.process.take() {
1573            // Bounded wait, then SIGKILL: this runs from Drop, so a wedged helper must not
1574            // be able to hang the host on exit. Poll for a clean exit up to a deadline, then
1575            // force-kill (mirrors the kill-on-timeout pattern in send_command).
1576            let deadline = std::time::Instant::now() + Duration::from_secs(2);
1577            loop {
1578                match process.try_wait() {
1579                    Ok(Some(_)) => break,
1580                    Ok(None) if std::time::Instant::now() >= deadline => {
1581                        let _ = process.kill();
1582                        let _ = process.wait();
1583                        break;
1584                    }
1585                    Ok(None) => std::thread::sleep(Duration::from_millis(10)),
1586                    Err(_) => {
1587                        let _ = process.kill();
1588                        break;
1589                    }
1590                }
1591            }
1592        }
1593        if let Some(reader) = self.reader.take() {
1594            // Join only once the thread has signalled that it is finishing. It blocks in a
1595            // read on the helper's stdout, and that pipe stays open as long as *any* process
1596            // holds it — a plugin-spawned grandchild that inherited it, for instance. Since
1597            // this runs from Drop, waiting on that is not an option: after a short grace
1598            // period the thread is detached instead. It ends by itself at EOF, and a leaked
1599            // thread beats a host that can never drop a plugin.
1600            let deadline = std::time::Instant::now() + Duration::from_millis(250);
1601            while !self.reader_finished.load(Ordering::Acquire) {
1602                if std::time::Instant::now() >= deadline {
1603                    log::debug!("isolation: helper stdout still open, detaching reader thread");
1604                    break;
1605                }
1606                std::thread::sleep(Duration::from_millis(5));
1607            }
1608            if self.reader_finished.load(Ordering::Acquire) {
1609                let _ = reader.join();
1610            }
1611        }
1612        self.dead = true;
1613    }
1614}
1615
1616impl Drop for PluginHostProcess {
1617    fn drop(&mut self) {
1618        self.shutdown();
1619    }
1620}
1621
1622/// Result type for process isolation operations
1623pub type IsolationResult<T> = std::result::Result<T, IsolationError>;
1624
1625/// Errors that can occur during process isolation
1626#[derive(Debug, thiserror::Error)]
1627pub enum IsolationError {
1628    /// IO error
1629    #[error("IO error: {0}")]
1630    Io(#[from] std::io::Error),
1631
1632    /// Serialization error
1633    #[error("Serialization error: {0}")]
1634    Serialization(#[from] serde_json::Error),
1635
1636    /// Plugin error
1637    #[error("Plugin error: {0}")]
1638    Plugin(String),
1639
1640    /// Plugin crashed
1641    #[error("Plugin crashed: {0}")]
1642    Crashed(String),
1643
1644    /// Helper process not running
1645    #[error("Helper process not running")]
1646    NotRunning,
1647
1648    /// Unexpected response
1649    #[error("Unexpected response from helper")]
1650    UnexpectedResponse,
1651}
1652
1653#[cfg(test)]
1654mod wire_tests {
1655    use super::*;
1656    use crate::midi::{MidiChannel, MidiEvent};
1657
1658    #[test]
1659    fn audio_output_carries_midi_across_the_wire() {
1660        // The Process response carries emitted MIDI alongside audio; check the variant
1661        // round-trips through the JSON transport host and helper share.
1662        let resp = HostResponse::AudioOutput {
1663            outputs: vec![vec![0.0, 0.5], vec![-0.5, 0.0]],
1664            output_events: vec![
1665                MidiEvent::NoteOn {
1666                    channel: MidiChannel::Ch1,
1667                    note: 60,
1668                    velocity: 100,
1669                }
1670                .into(),
1671                MidiEvent::NoteOff {
1672                    channel: MidiChannel::Ch1,
1673                    note: 60,
1674                    velocity: 0,
1675                }
1676                .into(),
1677            ],
1678        };
1679        let json = serde_json::to_string(&resp).expect("serialize");
1680        let back: HostResponse = serde_json::from_str(&json).expect("deserialize");
1681        match back {
1682            HostResponse::AudioOutput {
1683                outputs,
1684                output_events,
1685            } => {
1686                assert_eq!(outputs, vec![vec![0.0, 0.5], vec![-0.5, 0.0]]);
1687                assert_eq!(output_events.len(), 2);
1688                assert_eq!(
1689                    output_events[0].to_midi(),
1690                    Some(MidiEvent::NoteOn {
1691                        channel: MidiChannel::Ch1,
1692                        note: 60,
1693                        velocity: 100
1694                    })
1695                );
1696            }
1697            other => panic!("round-trip changed the variant: {other:?}"),
1698        }
1699    }
1700
1701    #[test]
1702    fn state_commands_round_trip_across_the_wire() {
1703        // SaveState/LoadState/State carry the opaque plugin state blob across isolation.
1704        let blob: Vec<u8> = vec![0, 1, 2, 250, 255, 42];
1705
1706        let save = serde_json::to_string(&HostCommand::SaveState).expect("serialize SaveState");
1707        assert!(matches!(
1708            serde_json::from_str::<HostCommand>(&save).expect("deserialize SaveState"),
1709            HostCommand::SaveState
1710        ));
1711
1712        let load = HostCommand::LoadState {
1713            data: blob.clone(),
1714            context: crate::plugin::StateContext::Project,
1715        };
1716        let load_json = serde_json::to_string(&load).expect("serialize LoadState");
1717        assert!(
1718            load_json.contains("\"data\":\""),
1719            "state should use compact base64, not a JSON integer array"
1720        );
1721        match serde_json::from_str::<HostCommand>(&load_json).expect("deserialize LoadState") {
1722            HostCommand::LoadState { data, context } => {
1723                assert_eq!(data, blob);
1724                assert_eq!(context, crate::plugin::StateContext::Project);
1725            }
1726            other => panic!("LoadState round-trip changed the variant: {other:?}"),
1727        }
1728
1729        let legacy = r#"{"LoadState":{"data":[0,1,2,250,255,42]}}"#;
1730        match serde_json::from_str::<HostCommand>(legacy).expect("deserialize legacy LoadState") {
1731            HostCommand::LoadState { data, context } => {
1732                assert_eq!(data, blob);
1733                assert_eq!(context, crate::plugin::StateContext::Project);
1734            }
1735            other => panic!("legacy LoadState changed the variant: {other:?}"),
1736        }
1737
1738        let state = HostResponse::State { data: blob.clone() };
1739        let state_json = serde_json::to_string(&state).expect("serialize State");
1740        match serde_json::from_str::<HostResponse>(&state_json).expect("deserialize State") {
1741            HostResponse::State { data } => assert_eq!(data, blob),
1742            other => panic!("State round-trip changed the variant: {other:?}"),
1743        }
1744    }
1745
1746    #[test]
1747    fn set_parameter_at_round_trips_across_the_wire() {
1748        // The sample-accurate automation command must survive the JSON transport intact
1749        // (the offset is carried across the isolation boundary).
1750        let cmd = HostCommand::SetParameterAt {
1751            id: 42,
1752            value: 0.75,
1753            offset: 256,
1754        };
1755        let json = serde_json::to_string(&cmd).expect("serialize SetParameterAt");
1756        match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetParameterAt") {
1757            HostCommand::SetParameterAt { id, value, offset } => {
1758                assert_eq!(id, 42);
1759                assert_eq!(value, 0.75);
1760                assert_eq!(offset, 256);
1761            }
1762            other => panic!("round-trip changed the variant: {other:?}"),
1763        }
1764    }
1765
1766    #[test]
1767    fn scheduled_midi_offset_ipc_round_trips() {
1768        // Sample-accurate MIDI must carry its offset across the isolation boundary, so isolated
1769        // playback schedules the event in the same block position as the in-process path.
1770        use crate::midi::{MidiChannel, MidiEvent};
1771        let cmd = HostCommand::SendMidiAt {
1772            event: MidiEvent::NoteOn {
1773                channel: MidiChannel::Ch1,
1774                note: 60,
1775                velocity: 100,
1776            },
1777            sample_offset: 256,
1778        };
1779        let json = serde_json::to_string(&cmd).expect("serialize SendMidiAt");
1780        match serde_json::from_str::<HostCommand>(&json).expect("deserialize SendMidiAt") {
1781            HostCommand::SendMidiAt {
1782                event,
1783                sample_offset,
1784            } => {
1785                assert_eq!(
1786                    event,
1787                    MidiEvent::NoteOn {
1788                        channel: MidiChannel::Ch1,
1789                        note: 60,
1790                        velocity: 100
1791                    }
1792                );
1793                assert_eq!(sample_offset, 256);
1794            }
1795            other => panic!("round-trip changed the variant: {other:?}"),
1796        }
1797    }
1798
1799    #[test]
1800    fn owned_sysex_round_trips_in_commands_and_process_output() {
1801        let event = crate::midi::PluginEvent::sysex(vec![0xf0, 0x7d, 1, 2, 0xf7]).at(37);
1802        let command = HostCommand::SendPluginEvent {
1803            event: event.clone(),
1804        };
1805        let json = serde_json::to_string(&command).expect("serialize owned event");
1806        match serde_json::from_str::<HostCommand>(&json).expect("deserialize owned event") {
1807            HostCommand::SendPluginEvent { event: decoded } => assert_eq!(decoded, event),
1808            other => panic!("owned event command changed variant: {other:?}"),
1809        }
1810
1811        let response = HostResponse::AudioOutput {
1812            outputs: Vec::new(),
1813            output_events: vec![event.clone()],
1814        };
1815        let json = serde_json::to_string(&response).expect("serialize owned output");
1816        match serde_json::from_str::<HostResponse>(&json).expect("deserialize owned output") {
1817            HostResponse::AudioOutput { output_events, .. } => {
1818                assert_eq!(output_events, vec![event])
1819            }
1820            other => panic!("owned event response changed variant: {other:?}"),
1821        }
1822    }
1823
1824    #[test]
1825    fn select_program_round_trips_across_the_wire() {
1826        // Program selection must survive the JSON transport host and helper share.
1827        let cmd = HostCommand::SelectProgram {
1828            unit_id: 0,
1829            program_index: 17,
1830        };
1831        let json = serde_json::to_string(&cmd).expect("serialize SelectProgram");
1832        match serde_json::from_str::<HostCommand>(&json).expect("deserialize SelectProgram") {
1833            HostCommand::SelectProgram {
1834                unit_id,
1835                program_index,
1836            } => {
1837                assert_eq!(unit_id, 0);
1838                assert_eq!(program_index, 17);
1839            }
1840            other => panic!("round-trip changed the variant: {other:?}"),
1841        }
1842    }
1843
1844    #[test]
1845    fn transport_commands_round_trip_across_the_wire() {
1846        // The runtime transport mutations must survive the JSON transport intact so the helper
1847        // applies the same change the host requested.
1848        let tempo = HostCommand::SetTempo { bpm: 137.5 };
1849        let json = serde_json::to_string(&tempo).expect("serialize SetTempo");
1850        match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetTempo") {
1851            HostCommand::SetTempo { bpm } => assert_eq!(bpm, 137.5),
1852            other => panic!("round-trip changed the variant: {other:?}"),
1853        }
1854
1855        let ts = HostCommand::SetTimeSignature {
1856            numerator: 7,
1857            denominator: 8,
1858        };
1859        let json = serde_json::to_string(&ts).expect("serialize SetTimeSignature");
1860        match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetTimeSignature") {
1861            HostCommand::SetTimeSignature {
1862                numerator,
1863                denominator,
1864            } => assert_eq!((numerator, denominator), (7, 8)),
1865            other => panic!("round-trip changed the variant: {other:?}"),
1866        }
1867
1868        let playing = HostCommand::SetPlaying { playing: false };
1869        let json = serde_json::to_string(&playing).expect("serialize SetPlaying");
1870        match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetPlaying") {
1871            HostCommand::SetPlaying { playing } => assert!(!playing),
1872            other => panic!("round-trip changed the variant: {other:?}"),
1873        }
1874    }
1875
1876    #[test]
1877    fn set_bus_active_round_trips_across_the_wire() {
1878        use crate::audio::{BusDirection, MediaType};
1879        let cmd = HostCommand::SetBusActive {
1880            media_type: MediaType::Audio,
1881            direction: BusDirection::Input,
1882            bus_index: 1,
1883            active: true,
1884        };
1885        let json = serde_json::to_string(&cmd).expect("serialize SetBusActive");
1886        match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetBusActive") {
1887            HostCommand::SetBusActive {
1888                media_type,
1889                direction,
1890                bus_index,
1891                active,
1892            } => {
1893                assert_eq!(media_type, MediaType::Audio);
1894                assert_eq!(direction, BusDirection::Input);
1895                assert_eq!(bus_index, 1);
1896                assert!(active);
1897            }
1898            other => panic!("round-trip changed the variant: {other:?}"),
1899        }
1900    }
1901
1902    #[test]
1903    fn bus_arrangements_round_trip_across_the_wire() {
1904        use crate::audio::{BusArrangements, SpeakerArrangement};
1905
1906        let cmd = serde_json::to_string(&HostCommand::BusArrangements)
1907            .expect("serialize BusArrangements");
1908        assert!(matches!(
1909            serde_json::from_str::<HostCommand>(&cmd).expect("deserialize BusArrangements"),
1910            HostCommand::BusArrangements
1911        ));
1912
1913        let set = HostCommand::SetBusArrangements {
1914            inputs: vec![],
1915            outputs: vec![SpeakerArrangement::STEREO],
1916        };
1917        let set_json = serde_json::to_string(&set).expect("serialize SetBusArrangements");
1918        match serde_json::from_str::<HostCommand>(&set_json).expect("deserialize") {
1919            HostCommand::SetBusArrangements { inputs, outputs } => {
1920                assert!(inputs.is_empty());
1921                assert_eq!(outputs, vec![SpeakerArrangement::STEREO]);
1922            }
1923            other => panic!("SetBusArrangements round-trip changed the variant: {other:?}"),
1924        }
1925
1926        let arrangements = BusArrangements {
1927            inputs: vec![],
1928            outputs: vec![SpeakerArrangement::STEREO],
1929        };
1930        let resp = HostResponse::BusArrangements {
1931            arrangements: arrangements.clone(),
1932        };
1933        let resp_json = serde_json::to_string(&resp).expect("serialize BusArrangements response");
1934        match serde_json::from_str::<HostResponse>(&resp_json).expect("deserialize") {
1935            HostResponse::BusArrangements { arrangements: back } => {
1936                assert_eq!(back, arrangements);
1937            }
1938            other => panic!("BusArrangements response round-trip changed the variant: {other:?}"),
1939        }
1940    }
1941
1942    #[test]
1943    fn get_units_round_trips_across_the_wire() {
1944        use crate::plugin::PluginUnit;
1945
1946        let cmd = serde_json::to_string(&HostCommand::GetUnits).expect("serialize GetUnits");
1947        assert!(matches!(
1948            serde_json::from_str::<HostCommand>(&cmd).expect("deserialize GetUnits"),
1949            HostCommand::GetUnits
1950        ));
1951
1952        let units = vec![PluginUnit {
1953            id: 0,
1954            parent_id: -1,
1955            name: "Root".to_string(),
1956            program_list_id: Some(12),
1957            programs: vec!["Init".to_string(), "Lead".to_string()],
1958        }];
1959        let resp = HostResponse::Units {
1960            units: units.clone(),
1961        };
1962        let resp_json = serde_json::to_string(&resp).expect("serialize Units");
1963        match serde_json::from_str::<HostResponse>(&resp_json).expect("deserialize Units") {
1964            HostResponse::Units { units: back } => assert_eq!(back, units),
1965            other => panic!("Units round-trip changed the variant: {other:?}"),
1966        }
1967    }
1968
1969    #[test]
1970    fn latency_and_tail_round_trip_across_the_wire() {
1971        let latency_cmd = serde_json::to_string(&HostCommand::LatencySamples).expect("serialize");
1972        assert!(matches!(
1973            serde_json::from_str::<HostCommand>(&latency_cmd).expect("deserialize"),
1974            HostCommand::LatencySamples
1975        ));
1976        let tail_cmd = serde_json::to_string(&HostCommand::TailSamples).expect("serialize");
1977        assert!(matches!(
1978            serde_json::from_str::<HostCommand>(&tail_cmd).expect("deserialize"),
1979            HostCommand::TailSamples
1980        ));
1981
1982        let latency_resp = HostResponse::LatencySamples { samples: 128 };
1983        let json = serde_json::to_string(&latency_resp).expect("serialize");
1984        match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
1985            HostResponse::LatencySamples { samples } => assert_eq!(samples, 128),
1986            other => panic!("LatencySamples round-trip changed the variant: {other:?}"),
1987        }
1988
1989        let tail_resp = HostResponse::TailSamples { samples: 44100 };
1990        let json = serde_json::to_string(&tail_resp).expect("serialize");
1991        match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
1992            HostResponse::TailSamples { samples } => assert_eq!(samples, 44100),
1993            other => panic!("TailSamples round-trip changed the variant: {other:?}"),
1994        }
1995    }
1996
1997    #[test]
1998    fn midi_cc_to_parameter_round_trips_across_the_wire() {
1999        let cmd = HostCommand::MidiCcToParameter {
2000            bus: 0,
2001            channel: 1,
2002            cc: 74,
2003        };
2004        let json = serde_json::to_string(&cmd).expect("serialize MidiCcToParameter");
2005        match serde_json::from_str::<HostCommand>(&json).expect("deserialize") {
2006            HostCommand::MidiCcToParameter { bus, channel, cc } => {
2007                assert_eq!((bus, channel, cc), (0, 1, 74));
2008            }
2009            other => panic!("MidiCcToParameter round-trip changed the variant: {other:?}"),
2010        }
2011
2012        let resp = HostResponse::MidiParameterMapping { id: Some(42) };
2013        let json = serde_json::to_string(&resp).expect("serialize MidiParameterMapping");
2014        match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
2015            HostResponse::MidiParameterMapping { id } => assert_eq!(id, Some(42)),
2016            other => panic!("MidiParameterMapping round-trip changed the variant: {other:?}"),
2017        }
2018
2019        let none_resp = HostResponse::MidiParameterMapping { id: None };
2020        let json = serde_json::to_string(&none_resp).expect("serialize");
2021        match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
2022            HostResponse::MidiParameterMapping { id } => assert_eq!(id, None),
2023            other => {
2024                panic!("MidiParameterMapping (None) round-trip changed the variant: {other:?}")
2025            }
2026        }
2027    }
2028
2029    #[test]
2030    fn parameter_id_remapping_round_trips_uid_and_optional_result() {
2031        let uid = "123456789ABCDEF01122334455667788";
2032        let command = HostCommand::RemapParameterId {
2033            old_plugin_uid: uid.to_string(),
2034            old_param_id: 0xDEAD_BEEF,
2035        };
2036        let json = serde_json::to_string(&command).expect("serialize RemapParameterId");
2037        match serde_json::from_str::<HostCommand>(&json).expect("deserialize RemapParameterId") {
2038            HostCommand::RemapParameterId {
2039                old_plugin_uid,
2040                old_param_id,
2041            } => {
2042                assert_eq!(old_plugin_uid, uid);
2043                assert_eq!(old_param_id, 0xDEAD_BEEF);
2044                assert!(crate::internal::utils::parse_class_uid(&old_plugin_uid).is_some());
2045            }
2046            other => panic!("RemapParameterId round-trip changed the variant: {other:?}"),
2047        }
2048
2049        for id in [Some(42), None] {
2050            let response = HostResponse::RemappedParameter { id };
2051            let json = serde_json::to_string(&response).expect("serialize RemappedParameter");
2052            match serde_json::from_str::<HostResponse>(&json)
2053                .expect("deserialize RemappedParameter")
2054            {
2055                HostResponse::RemappedParameter { id: decoded } => assert_eq!(decoded, id),
2056                other => panic!("RemappedParameter round-trip changed the variant: {other:?}"),
2057            }
2058        }
2059
2060        let invalid = HostCommand::RemapParameterId {
2061            old_plugin_uid: "1234-not-a-uid".to_string(),
2062            old_param_id: 1,
2063        };
2064        let json = serde_json::to_string(&invalid).expect("serialize invalid UID");
2065        match serde_json::from_str::<HostCommand>(&json).expect("deserialize invalid UID") {
2066            HostCommand::RemapParameterId { old_plugin_uid, .. } => {
2067                assert!(crate::internal::utils::parse_class_uid(&old_plugin_uid).is_none());
2068            }
2069            other => panic!("invalid RemapParameterId changed the variant: {other:?}"),
2070        }
2071    }
2072
2073    #[test]
2074    fn parameter_edits_round_trip_across_the_wire() {
2075        // The ordered gesture log must survive the JSON transport host and helper share, both
2076        // the empty command and the populated reply.
2077        use crate::plugin::{ParameterEdit, ParameterEditKind};
2078
2079        let cmd = serde_json::to_string(&HostCommand::TakeParameterEdits)
2080            .expect("serialize TakeParameterEdits");
2081        assert!(matches!(
2082            serde_json::from_str::<HostCommand>(&cmd).expect("deserialize TakeParameterEdits"),
2083            HostCommand::TakeParameterEdits
2084        ));
2085
2086        let edits = vec![
2087            ParameterEdit {
2088                id: 9,
2089                kind: ParameterEditKind::BeginGesture,
2090                value: None,
2091            },
2092            ParameterEdit {
2093                id: 9,
2094                kind: ParameterEditKind::ValueChange,
2095                value: Some(0.3),
2096            },
2097            ParameterEdit {
2098                id: 9,
2099                kind: ParameterEditKind::EndGesture,
2100                value: None,
2101            },
2102        ];
2103        let resp = HostResponse::ParameterEdits {
2104            edits: edits.clone(),
2105        };
2106        let resp_json = serde_json::to_string(&resp).expect("serialize ParameterEdits");
2107        match serde_json::from_str::<HostResponse>(&resp_json).expect("deserialize ParameterEdits")
2108        {
2109            HostResponse::ParameterEdits { edits: back } => assert_eq!(back, edits),
2110            other => panic!("ParameterEdits round-trip changed the variant: {other:?}"),
2111        }
2112    }
2113
2114    #[test]
2115    fn parameter_feedback_round_trips_losslessly_and_is_bounded() {
2116        let command = serde_json::to_string(&HostCommand::TakeParameterChanges)
2117            .expect("serialize TakeParameterChanges");
2118        assert!(matches!(
2119            serde_json::from_str::<HostCommand>(&command)
2120                .expect("deserialize TakeParameterChanges"),
2121            HostCommand::TakeParameterChanges
2122        ));
2123
2124        let changes = vec![
2125            (1, 0.25),
2126            (2, -0.0),
2127            (3, f64::NAN),
2128            (4, f64::INFINITY),
2129            (5, f64::NEG_INFINITY),
2130        ];
2131        let response = HostResponse::ParameterChanges {
2132            changes: changes.clone(),
2133        };
2134        let json = serde_json::to_string(&response).expect("serialize parameter feedback");
2135        let HostResponse::ParameterChanges { changes: decoded } =
2136            serde_json::from_str::<HostResponse>(&json).expect("deserialize parameter feedback")
2137        else {
2138            panic!("parameter feedback changed response variant");
2139        };
2140        assert_eq!(
2141            decoded
2142                .iter()
2143                .map(|&(id, value)| (id, value.to_bits()))
2144                .collect::<Vec<_>>(),
2145            changes
2146                .iter()
2147                .map(|&(id, value)| (id, value.to_bits()))
2148                .collect::<Vec<_>>()
2149        );
2150
2151        let over_limit = HostResponse::ParameterChanges {
2152            changes: vec![(1, 0.5); MAX_WIRE_PARAMETER_CHANGES + 1],
2153        };
2154        assert!(
2155            serde_json::to_string(&over_limit).is_err(),
2156            "the helper must not emit an oversized feedback response"
2157        );
2158
2159        let entries = (0..=MAX_WIRE_PARAMETER_CHANGES)
2160            .map(|_| "[1,0]")
2161            .collect::<Vec<_>>()
2162            .join(",");
2163        let oversized_json = format!("{{\"ParameterChanges\":{{\"changes\":[{entries}]}}}}");
2164        assert!(
2165            serde_json::from_str::<HostResponse>(&oversized_json).is_err(),
2166            "the host must reject oversized feedback before collecting it"
2167        );
2168    }
2169
2170    #[test]
2171    fn host_notifications_and_restart_requests_round_trip_across_the_wire() {
2172        use crate::plugin::HostNotification;
2173
2174        for command in [
2175            HostCommand::TakeHostNotifications,
2176            HostCommand::ExecuteContextMenuItem {
2177                menu_id: 19,
2178                item_id: 3,
2179            },
2180            HostCommand::DismissContextMenu { menu_id: 20 },
2181            HostCommand::TakeRestartFlags,
2182            HostCommand::ServiceHostRequests,
2183        ] {
2184            let json = serde_json::to_string(&command).expect("serialize host request");
2185            let decoded = serde_json::from_str::<HostCommand>(&json).expect("deserialize");
2186            assert_eq!(
2187                std::mem::discriminant(&decoded),
2188                std::mem::discriminant(&command)
2189            );
2190        }
2191
2192        let notifications = vec![
2193            HostNotification::DirtyChanged(true),
2194            HostNotification::OpenEditorRequested {
2195                name: Some("editor".to_string()),
2196            },
2197            HostNotification::GroupEditStarted,
2198            HostNotification::GroupEditFinished,
2199            HostNotification::ContextMenuRequested {
2200                menu_id: 19,
2201                parameter_id: Some(44),
2202                x: 12,
2203                y: 24,
2204                items: vec![crate::plugin::ContextMenuItem {
2205                    item_id: 0,
2206                    name: "Reset".to_string(),
2207                    tag: 7,
2208                    flags: 0,
2209                }],
2210            },
2211        ];
2212        let response = HostResponse::HostNotifications {
2213            notifications: notifications.clone(),
2214        };
2215        let json = serde_json::to_string(&response).expect("serialize notifications");
2216        match serde_json::from_str::<HostResponse>(&json).expect("deserialize notifications") {
2217            HostResponse::HostNotifications {
2218                notifications: decoded,
2219            } => assert_eq!(decoded, notifications),
2220            other => panic!("HostNotifications changed variant: {other:?}"),
2221        }
2222
2223        let response = HostResponse::RestartFlags { bits: 0x345 };
2224        let json = serde_json::to_string(&response).expect("serialize restart flags");
2225        match serde_json::from_str::<HostResponse>(&json).expect("deserialize restart flags") {
2226            HostResponse::RestartFlags { bits } => assert_eq!(bits, 0x345),
2227            other => panic!("RestartFlags changed variant: {other:?}"),
2228        }
2229    }
2230
2231    #[test]
2232    fn note_expression_commands_round_trip_across_the_wire() {
2233        // The MPE commands/responses must survive the JSON transport host and helper share.
2234        use crate::midi::{NoteExpressionInfo, NoteExpressionType};
2235
2236        let on = HostCommand::NoteOn {
2237            channel: 0,
2238            note: 60,
2239            velocity: 100,
2240            sample_offset: 0,
2241        };
2242        let on_json = serde_json::to_string(&on).expect("serialize NoteOn");
2243        match serde_json::from_str::<HostCommand>(&on_json).expect("deserialize NoteOn") {
2244            HostCommand::NoteOn {
2245                channel,
2246                note,
2247                velocity,
2248                sample_offset,
2249            } => {
2250                assert_eq!((channel, note, velocity, sample_offset), (0, 60, 100, 0));
2251            }
2252            other => panic!("NoteOn round-trip changed the variant: {other:?}"),
2253        }
2254
2255        let expr = HostCommand::SendNoteExpression {
2256            note_id: 7,
2257            kind: NoteExpressionType::Tuning,
2258            value: 1.0,
2259            sample_offset: 0,
2260        };
2261        let expr_json = serde_json::to_string(&expr).expect("serialize SendNoteExpression");
2262        match serde_json::from_str::<HostCommand>(&expr_json).expect("deserialize") {
2263            HostCommand::SendNoteExpression {
2264                note_id,
2265                kind,
2266                value,
2267                ..
2268            } => {
2269                assert_eq!(note_id, 7);
2270                assert_eq!(kind, NoteExpressionType::Tuning);
2271                assert_eq!(value, 1.0);
2272            }
2273            other => panic!("SendNoteExpression round-trip changed the variant: {other:?}"),
2274        }
2275
2276        let started = HostResponse::NoteStarted { note_id: 42 };
2277        let started_json = serde_json::to_string(&started).expect("serialize NoteStarted");
2278        match serde_json::from_str::<HostResponse>(&started_json).expect("deserialize") {
2279            HostResponse::NoteStarted { note_id } => assert_eq!(note_id, 42),
2280            other => panic!("NoteStarted round-trip changed the variant: {other:?}"),
2281        }
2282
2283        let info = NoteExpressionInfo {
2284            kind: NoteExpressionType::Tuning,
2285            title: "Tuning".to_string(),
2286            short_title: "Tun".to_string(),
2287            units: String::new(),
2288            default_value: 0.5,
2289            min: 0.0,
2290            max: 1.0,
2291            step_count: 0,
2292            is_bipolar: true,
2293            is_one_shot: false,
2294            is_absolute: false,
2295        };
2296        let resp = HostResponse::NoteExpressions {
2297            expressions: vec![info.clone()],
2298        };
2299        let resp_json = serde_json::to_string(&resp).expect("serialize NoteExpressions");
2300        match serde_json::from_str::<HostResponse>(&resp_json).expect("deserialize") {
2301            HostResponse::NoteExpressions { expressions } => {
2302                assert_eq!(expressions, vec![info]);
2303            }
2304            other => panic!("NoteExpressions round-trip changed the variant: {other:?}"),
2305        }
2306    }
2307
2308    #[test]
2309    fn explicit_helper_override_missing_path_reports_clearly() {
2310        // An explicit helper path that doesn't exist must fail with a clear, path-naming
2311        // error *before* spawning — not fall through to the heuristic search. This is the
2312        // observable contract for the builder's `helper_path()` override (roadmap 3.3).
2313        let bogus = std::path::PathBuf::from("/nonexistent/vst3-host-helper-xyz");
2314        let err = match PluginHostProcess::new(Some(bogus.clone()), DEFAULT_RESPONSE_TIMEOUT) {
2315            Ok(_) => panic!("a missing override path must error, not spawn"),
2316            Err(e) => e,
2317        };
2318        assert!(
2319            err.contains("does not exist"),
2320            "error should explain the missing path, got: {err}"
2321        );
2322        assert!(
2323            err.contains("vst3-host-helper-xyz"),
2324            "error should name the offending path, got: {err}"
2325        );
2326    }
2327
2328    #[test]
2329    fn non_finite_samples_survive_the_audio_wire_format() {
2330        // JSON has no spelling for NaN/±∞ — serde_json writes `null`, which will not
2331        // deserialize back into an f32 — so one such sample used to fail every Process
2332        // exchange. The bit-pattern encoding carries them (and -0.0) exactly.
2333        let channel = vec![
2334            f32::NAN,
2335            f32::INFINITY,
2336            f32::NEG_INFINITY,
2337            -0.0,
2338            0.5,
2339            f32::MIN_POSITIVE,
2340        ];
2341        let resp = HostResponse::AudioOutput {
2342            outputs: vec![channel.clone(), vec![]],
2343            output_events: Vec::new(),
2344        };
2345        let json = serde_json::to_string(&resp).expect("serialize");
2346        assert!(
2347            !json.contains("null"),
2348            "non-finite samples must not become null"
2349        );
2350        match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
2351            HostResponse::AudioOutput { outputs, .. } => {
2352                assert_eq!(outputs.len(), 2);
2353                assert!(outputs[1].is_empty());
2354                let bits: Vec<u32> = outputs[0].iter().map(|s| s.to_bits()).collect();
2355                let want: Vec<u32> = channel.iter().map(|s| s.to_bits()).collect();
2356                assert_eq!(bits, want, "samples must round-trip bit-exactly");
2357            }
2358            other => panic!("round-trip changed the variant: {other:?}"),
2359        }
2360
2361        // The same for the host -> helper direction.
2362        let cmd = HostCommand::Process {
2363            inputs: vec![vec![f32::NAN, 1.0]],
2364            frames: 2,
2365        };
2366        let json = serde_json::to_string(&cmd).expect("serialize Process");
2367        match serde_json::from_str::<HostCommand>(&json).expect("deserialize Process") {
2368            HostCommand::Process { inputs, frames } => {
2369                assert_eq!(frames, 2);
2370                assert!(inputs[0][0].is_nan());
2371                assert_eq!(inputs[0][1], 1.0);
2372            }
2373            other => panic!("Process round-trip changed the variant: {other:?}"),
2374        }
2375    }
2376
2377    #[test]
2378    fn bus_audio_wire_preserves_bus_boundaries_activation_and_sample_bits() {
2379        let command = HostCommand::ProcessBuses {
2380            inputs: vec![
2381                crate::audio::AudioBusBuffer {
2382                    active: true,
2383                    channels: vec![vec![f32::NAN, 1.0], vec![2.0, 3.0]],
2384                },
2385                crate::audio::AudioBusBuffer {
2386                    active: false,
2387                    channels: vec![vec![99.0, 99.0]],
2388                },
2389            ],
2390            outputs: vec![
2391                crate::audio::AudioBusConfig {
2392                    channel_count: 2,
2393                    active: true,
2394                },
2395                crate::audio::AudioBusConfig {
2396                    channel_count: 1,
2397                    active: false,
2398                },
2399            ],
2400            frames: 2,
2401        };
2402        let json = serde_json::to_string(&command).expect("serialize ProcessBuses");
2403        match serde_json::from_str::<HostCommand>(&json).expect("deserialize ProcessBuses") {
2404            HostCommand::ProcessBuses {
2405                inputs,
2406                outputs,
2407                frames,
2408            } => {
2409                assert_eq!(frames, 2);
2410                assert_eq!(inputs.len(), 2);
2411                assert!(inputs[0].active);
2412                assert!(!inputs[1].active);
2413                assert!(inputs[0].channels[0][0].is_nan());
2414                assert_eq!(inputs[1].channels[0], [99.0, 99.0]);
2415                assert_eq!(outputs[1].channel_count, 1);
2416                assert!(!outputs[1].active);
2417            }
2418            other => panic!("ProcessBuses round-trip changed the variant: {other:?}"),
2419        }
2420    }
2421
2422    #[test]
2423    fn non_finite_parameter_values_survive_the_wire() {
2424        // A plugin can hand back a non-finite normalized value; it must not poison the
2425        // exchange. Finite values stay plain JSON numbers.
2426        for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2427            let json = serde_json::to_string(&HostResponse::ParameterValue { value })
2428                .expect("serialize ParameterValue");
2429            match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
2430                HostResponse::ParameterValue { value: back } => {
2431                    if value.is_nan() {
2432                        assert!(back.is_nan(), "NaN must survive the wire");
2433                    } else {
2434                        assert_eq!(back, value);
2435                    }
2436                }
2437                other => panic!("round-trip changed the variant: {other:?}"),
2438            }
2439        }
2440
2441        let json = serde_json::to_string(&HostResponse::ParameterValue { value: 0.25 })
2442            .expect("serialize");
2443        assert!(
2444            json.contains("0.25") && !json.contains("\"0.25\""),
2445            "finite values stay JSON numbers, got {json}"
2446        );
2447        let cmd = HostCommand::SetParameter {
2448            id: 3,
2449            value: f64::NAN,
2450        };
2451        let json = serde_json::to_string(&cmd).expect("serialize SetParameter");
2452        match serde_json::from_str::<HostCommand>(&json).expect("deserialize") {
2453            HostCommand::SetParameter { id, value } => {
2454                assert_eq!(id, 3);
2455                assert!(value.is_nan());
2456            }
2457            other => panic!("SetParameter round-trip changed the variant: {other:?}"),
2458        }
2459    }
2460
2461    #[test]
2462    fn audio_codec_round_trips_and_shrinks_the_payload() {
2463        // Every length (including the two partial base64 groups) must round-trip.
2464        for len in 0..9usize {
2465            let samples: Vec<f32> = (0..len).map(|i| i as f32 * -0.3125).collect();
2466            let encoded = audio_codec::encode_channel(&samples);
2467            let decoded = audio_codec::decode_channel(&encoded).expect("decode");
2468            assert_eq!(decoded, samples, "round-trip failed at len {len}");
2469        }
2470        assert!(audio_codec::decode_channel("!!!!").is_none());
2471        assert!(audio_codec::decode_channel("AAA").is_none(), "bad length");
2472        assert!(
2473            audio_codec::decode_channel("AAAA").is_none(),
2474            "3 bytes is not a whole f32"
2475        );
2476
2477        // The bit-pattern form is also a good deal smaller than a JSON number array.
2478        let block: Vec<Vec<f32>> = (0..2)
2479            .map(|c| {
2480                (0..512)
2481                    .map(|i| ((i * 7 + c) as f32 / 512.0).sin())
2482                    .collect()
2483            })
2484            .collect();
2485        let plain = serde_json::to_string(&block).expect("plain json").len();
2486        let encoded = serde_json::to_string(&HostResponse::AudioOutput {
2487            outputs: block,
2488            output_events: Vec::new(),
2489        })
2490        .expect("encoded json")
2491        .len();
2492        assert!(
2493            encoded < plain,
2494            "base64 payload ({encoded}) should be smaller than the number array ({plain})"
2495        );
2496    }
2497
2498    #[test]
2499    fn wire_provided_counts_are_clamped_on_receipt() {
2500        // The host sizes buffers from these numbers, so a bogus helper reply must not be
2501        // taken at face value.
2502        let json = r#"{"PluginInfo":{"vendor":"v","name":"n","version":"1","category":"",
2503            "uid":"u","has_gui":false,"audio_inputs":-4,"audio_outputs":999999,
2504            "output_channels":2000000,"has_midi_input":true,"has_midi_output":false}}"#;
2505        match serde_json::from_str::<HostResponse>(json).expect("deserialize PluginInfo") {
2506            HostResponse::PluginInfo {
2507                audio_inputs,
2508                audio_outputs,
2509                output_channels,
2510                ..
2511            } => {
2512                assert_eq!(audio_inputs, 0);
2513                assert_eq!(audio_outputs, MAX_WIRE_BUSES);
2514                assert_eq!(output_channels, MAX_WIRE_CHANNELS as i32);
2515            }
2516            other => panic!("PluginInfo round-trip changed the variant: {other:?}"),
2517        }
2518
2519        // Channel counts on the audio payload are clamped the same way.
2520        let channels: Vec<String> = (0..MAX_WIRE_CHANNELS + 5)
2521            .map(|_| audio_codec::encode_channel(&[0.0]))
2522            .collect();
2523        let json = serde_json::to_string(&serde_json::json!({
2524            "AudioOutput": { "outputs": channels, "output_events": [] }
2525        }))
2526        .expect("serialize");
2527        match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
2528            HostResponse::AudioOutput { outputs, .. } => {
2529                assert_eq!(outputs.len(), MAX_WIRE_CHANNELS)
2530            }
2531            other => panic!("AudioOutput round-trip changed the variant: {other:?}"),
2532        }
2533    }
2534
2535    #[test]
2536    fn oversized_lines_are_discarded_rather_than_buffered() {
2537        // A helper that never terminates a line must not be able to grow host memory.
2538        let mut input: Vec<u8> = Vec::new();
2539        input.extend_from_slice(b"short\n");
2540        input.extend_from_slice(&[b'x'; 64]);
2541        input.push(b'\n');
2542        input.extend_from_slice(b"ok\n");
2543        let mut reader = std::io::BufReader::new(std::io::Cursor::new(input));
2544
2545        assert!(matches!(read_bounded_line(&mut reader, 8), ReadLine::Line(l) if l == b"short\n"));
2546        assert!(matches!(
2547            read_bounded_line(&mut reader, 8),
2548            ReadLine::Oversized
2549        ));
2550        assert!(matches!(read_bounded_line(&mut reader, 8), ReadLine::Line(l) if l == b"ok\n"));
2551        assert!(matches!(read_bounded_line(&mut reader, 8), ReadLine::Eof));
2552    }
2553
2554    #[test]
2555    fn slow_commands_are_classified_apart_from_the_per_block_ones() {
2556        assert!(is_slow_command(&HostCommand::SaveState));
2557        assert!(is_slow_command(&HostCommand::LoadState {
2558            data: vec![],
2559            context: crate::plugin::StateContext::Project,
2560        }));
2561        assert!(is_slow_command(&HostCommand::LoadPlugin {
2562            path: "x".into(),
2563            sample_rate: 44100.0,
2564            block_size: 512,
2565            tempo: 120.0,
2566            time_sig_numerator: 4,
2567            time_sig_denominator: 4,
2568            class_id: None,
2569        }));
2570        assert!(!is_slow_command(&HostCommand::Process {
2571            inputs: vec![],
2572            frames: 64
2573        }));
2574        assert!(!is_slow_command(&HostCommand::GetAllParameters));
2575    }
2576
2577    /// A helper that never responds (a hung plugin) must not hang the host: `send_command`
2578    /// returns an error within the timeout and kills the child.
2579    #[cfg(unix)]
2580    #[test]
2581    fn hung_helper_times_out_and_is_killed_not_blocking() {
2582        use std::io::Write;
2583        use std::os::unix::fs::PermissionsExt;
2584        use std::time::{Duration, Instant};
2585
2586        // Fake helper: read nothing, write nothing, just sleep — i.e. hang forever.
2587        let dir = std::env::temp_dir().join(format!("vst3_hang_{}", std::process::id()));
2588        std::fs::create_dir_all(&dir).unwrap();
2589        let fake = dir.join("hung-helper");
2590        let mut f = std::fs::File::create(&fake).unwrap();
2591        // `exec` so the shell is replaced by sleep (no orphaned child holding the stdout
2592        // pipe); killing the helper then closes the pipe and ends the reader thread promptly.
2593        writeln!(f, "#!/bin/sh\nexec sleep 30").unwrap();
2594        drop(f);
2595        std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
2596
2597        let mut proc =
2598            PluginHostProcess::spawn(fake.clone(), Duration::from_millis(200)).expect("spawn");
2599        let started = Instant::now();
2600        let res = proc.send_command(HostCommand::Shutdown);
2601        let elapsed = started.elapsed();
2602
2603        assert!(
2604            res.is_err(),
2605            "a hung helper must yield an error, got {res:?}"
2606        );
2607        assert!(
2608            elapsed < Duration::from_secs(3),
2609            "send_command must return promptly on timeout, took {elapsed:?}"
2610        );
2611        // The child was killed; a follow-up command also errors rather than hanging.
2612        assert!(proc.send_command(HostCommand::Shutdown).is_err());
2613
2614        let _ = std::fs::remove_dir_all(&dir);
2615    }
2616
2617    /// A helper that dies on its Nth load and answers otherwise, so a test can pin exactly how
2618    /// many load attempts the host makes. `crashing_loads` is how many of the first loads —
2619    /// counted across every process spawned from this script — end in a killed helper.
2620    #[cfg(unix)]
2621    struct FlakyLoadHelper {
2622        dir: std::path::PathBuf,
2623        script: std::path::PathBuf,
2624        attempts: std::path::PathBuf,
2625    }
2626
2627    #[cfg(unix)]
2628    impl FlakyLoadHelper {
2629        fn new(name: &str, crashing_loads: u32) -> Self {
2630            use std::io::Write;
2631            use std::os::unix::fs::PermissionsExt;
2632
2633            let dir = std::env::temp_dir().join(format!(
2634                "vst3_flaky_{name}_{}_{:?}",
2635                std::process::id(),
2636                std::thread::current().id()
2637            ));
2638            let _ = std::fs::remove_dir_all(&dir);
2639            std::fs::create_dir_all(&dir).expect("temp dir");
2640            let script = dir.join("flaky-helper");
2641            let attempts = dir.join("attempts");
2642
2643            let mut f = std::fs::File::create(&script).expect("create script");
2644            // Every LoadPlugin bumps a shared counter; the first `crashing_loads` of them make
2645            // the helper exit without answering, which is what a plugin crashing inside its own
2646            // initialization looks like from the host's side.
2647            write!(
2648                f,
2649                "#!/bin/sh\n\
2650                 while IFS= read -r line; do\n\
2651                 \x20 case \"$line\" in\n\
2652                 \x20   *LoadPlugin*)\n\
2653                 \x20     n=$(cat '{attempts}' 2>/dev/null || echo 0)\n\
2654                 \x20     n=$((n+1))\n\
2655                 \x20     printf '%s' \"$n\" > '{attempts}'\n\
2656                 \x20     if [ \"$n\" -le {crashing_loads} ]; then exit 3; fi\n\
2657                 \x20     printf '%s\\n' '{{\"PluginInfo\":{{\"vendor\":\"v\",\"name\":\"n\",\"version\":\"1\",\"category\":\"\",\"uid\":\"u\",\"has_gui\":false,\"audio_inputs\":0,\"audio_outputs\":1,\"output_channels\":2,\"has_midi_input\":true,\"has_midi_output\":false}}}}' ;;\n\
2658                 \x20   *) exit 3 ;;\n\
2659                 \x20 esac\n\
2660                 done\n",
2661                attempts = attempts.display(),
2662                crashing_loads = crashing_loads,
2663            )
2664            .expect("write script");
2665            drop(f);
2666            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))
2667                .expect("chmod");
2668            Self {
2669                dir,
2670                script,
2671                attempts,
2672            }
2673        }
2674
2675        fn load_attempts(&self) -> u32 {
2676            std::fs::read_to_string(&self.attempts)
2677                .ok()
2678                .and_then(|s| s.trim().parse().ok())
2679                .unwrap_or(0)
2680        }
2681
2682        fn load_command() -> HostCommand {
2683            HostCommand::LoadPlugin {
2684                path: "/tmp/flaky.vst3".to_string(),
2685                sample_rate: 44100.0,
2686                block_size: 512,
2687                tempo: 120.0,
2688                time_sig_numerator: 4,
2689                time_sig_denominator: 4,
2690                class_id: None,
2691            }
2692        }
2693    }
2694
2695    #[cfg(unix)]
2696    impl Drop for FlakyLoadHelper {
2697        fn drop(&mut self) {
2698            let _ = std::fs::remove_dir_all(&self.dir);
2699        }
2700    }
2701
2702    /// Real plugins lose races inside their own cold-start initialization and take the helper
2703    /// down with them. A fresh helper is an independent roll and the dead one held nothing, so
2704    /// the load is replayed rather than reported.
2705    #[cfg(unix)]
2706    #[test]
2707    fn a_load_that_kills_the_helper_is_replayed_against_a_fresh_one() {
2708        let fake = FlakyLoadHelper::new("recovers", 1);
2709        let mut proc = PluginHostProcess::spawn(fake.script.clone(), Duration::from_secs(5))
2710            .expect("spawn flaky helper");
2711        let first_pid = proc.helper_pid().expect("helper pid");
2712
2713        let response = proc
2714            .send_command(FlakyLoadHelper::load_command())
2715            .expect("a crashed load must be retried, not reported");
2716        assert!(matches!(response, HostResponse::PluginInfo { .. }));
2717        assert_eq!(fake.load_attempts(), 2, "the load should be tried twice");
2718        assert_ne!(
2719            proc.helper_pid().expect("helper pid after retry"),
2720            first_pid,
2721            "the retry must run against a freshly spawned helper"
2722        );
2723        assert!(proc.is_alive(), "the handle must be usable after the retry");
2724    }
2725
2726    /// The retry is bounded: a plugin that genuinely cannot load reports that after one extra
2727    /// attempt rather than respawning forever.
2728    #[cfg(unix)]
2729    #[test]
2730    fn a_load_that_always_crashes_gives_up_after_one_retry() {
2731        let fake = FlakyLoadHelper::new("always", 99);
2732        let mut proc = PluginHostProcess::spawn(fake.script.clone(), Duration::from_secs(5))
2733            .expect("spawn flaky helper");
2734
2735        let error = proc
2736            .send_command(FlakyLoadHelper::load_command())
2737            .expect_err("a load that always crashes must still fail");
2738        assert!(
2739            error.to_lowercase().contains("crash") || error.to_lowercase().contains("exited"),
2740            "the reported failure must still read as a crash, got {error}"
2741        );
2742        assert_eq!(
2743            fake.load_attempts(),
2744            1 + LOAD_CRASH_RETRIES,
2745            "exactly one retry, no more"
2746        );
2747    }
2748
2749    /// Only the load is replayed. Every other command runs against a helper holding live
2750    /// plugin state, which a fresh process would not have — silently redoing those would hand
2751    /// the caller a default-initialized plugin dressed up as a success.
2752    #[cfg(unix)]
2753    #[test]
2754    fn a_crash_on_any_other_command_is_reported_not_retried() {
2755        let fake = FlakyLoadHelper::new("other", 0);
2756        let mut proc = PluginHostProcess::spawn(fake.script.clone(), Duration::from_secs(5))
2757            .expect("spawn flaky helper");
2758        let pid = proc.helper_pid().expect("helper pid");
2759
2760        assert!(
2761            proc.send_command(HostCommand::GetAllParameters).is_err(),
2762            "a helper that dies mid-command must surface as an error"
2763        );
2764        assert_eq!(
2765            proc.helper_pid(),
2766            Some(pid),
2767            "no other command may respawn the helper"
2768        );
2769        assert!(!proc.is_alive());
2770    }
2771}
2772
2773/// Crash protection utilities for in-process plugins
2774pub mod crash_protection {
2775    use std::panic::catch_unwind;
2776    use std::panic::UnwindSafe;
2777    use std::time::Duration;
2778
2779    /// Status of a plugin after a protected call
2780    #[derive(Debug, Clone, PartialEq)]
2781    pub enum PluginStatus {
2782        /// Plugin executed successfully
2783        Ok,
2784        /// Plugin crashed with panic
2785        Crashed(String),
2786        /// Plugin took too long to execute
2787        Timeout(Duration),
2788    }
2789
2790    /// Execute a function with panic protection
2791    pub fn protected_call<F, R>(f: F) -> Result<R, String>
2792    where
2793        F: FnOnce() -> R + UnwindSafe,
2794    {
2795        catch_unwind(f).map_err(|e| {
2796            if let Some(s) = e.downcast_ref::<&str>() {
2797                format!("Plugin panicked: {}", s)
2798            } else if let Some(s) = e.downcast_ref::<String>() {
2799                format!("Plugin panicked: {}", s)
2800            } else {
2801                "Plugin panicked with unknown error".to_string()
2802            }
2803        })
2804    }
2805}