use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
pub(crate) const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) const DEFAULT_SLOW_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_STATE_BASE64_BYTES: usize = crate::plugin::MAX_STATE_SNAPSHOT_BYTES.div_ceil(3) * 4;
const MAX_RESPONSE_LINE_BYTES: usize = MAX_STATE_BASE64_BYTES + 1024 * 1024;
const MAX_QUEUED_RESPONSES: usize = 64;
const MAX_WIRE_CHANNELS: usize = 256;
const MAX_WIRE_FRAMES: usize = 1 << 20;
const MAX_WIRE_BUSES: i32 = 256;
const MAX_WIRE_PARAMETER_CHANGES: usize = 8192;
pub(crate) fn is_slow_command(command: &HostCommand) -> bool {
matches!(
command,
HostCommand::LoadPlugin { .. }
| HostCommand::SaveState
| HostCommand::LoadState { .. }
| HostCommand::GetProgramData { .. }
| HostCommand::SetProgramData { .. }
| HostCommand::GetUnitData { .. }
| HostCommand::SetUnitData { .. }
)
}
pub(crate) mod audio_codec {
use super::{Deserialize, Deserializer, Serializer, MAX_WIRE_CHANNELS, MAX_WIRE_FRAMES};
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub(super) fn encode_channel(samples: &[f32]) -> String {
let mut bytes = Vec::with_capacity(samples.len() * 4);
for s in samples {
bytes.extend_from_slice(&s.to_bits().to_le_bytes());
}
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
let n = (u32::from(chunk[0]) << 16) | (u32::from(b1) << 8) | u32::from(b2);
out.push(ALPHABET[(n >> 18) as usize & 63] as char);
out.push(ALPHABET[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 {
ALPHABET[(n >> 6) as usize & 63] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[n as usize & 63] as char
} else {
'='
});
}
out
}
fn sextet(c: u8) -> Option<u32> {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => return None,
};
Some(u32::from(v))
}
pub(super) fn decode_channel(encoded: &str) -> Option<Vec<f32>> {
let bytes = encoded.as_bytes();
if bytes.len() % 4 != 0 {
return None;
}
let mut raw = Vec::with_capacity(bytes.len() / 4 * 3);
for chunk in bytes.chunks(4) {
let pad = chunk.iter().rev().take_while(|&&c| c == b'=').count();
if pad > 2 {
return None;
}
let mut n = 0u32;
for (i, &c) in chunk.iter().enumerate() {
if c == b'=' {
if i < 4 - pad {
return None; }
continue;
}
n |= sextet(c)? << (18 - 6 * i);
}
raw.push((n >> 16) as u8);
if pad < 2 {
raw.push((n >> 8) as u8);
}
if pad < 1 {
raw.push(n as u8);
}
}
if raw.len() % 4 != 0 {
return None;
}
Some(
raw.chunks_exact(4)
.map(|b| f32::from_bits(u32::from_le_bytes([b[0], b[1], b[2], b[3]])))
.collect(),
)
}
pub(crate) fn serialize<S: Serializer>(
channels: &[Vec<f32>],
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.collect_seq(channels.iter().map(|c| encode_channel(c)))
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<Vec<f32>>, D::Error> {
let encoded = Vec::<String>::deserialize(deserializer)?;
if encoded.len() > MAX_WIRE_CHANNELS {
log::warn!(
"isolation: clamping {} wire channels to {MAX_WIRE_CHANNELS}",
encoded.len()
);
}
encoded
.iter()
.take(MAX_WIRE_CHANNELS)
.map(|c| {
let mut samples = decode_channel(c).ok_or_else(|| {
serde::de::Error::custom("malformed base64 audio channel payload")
})?;
if samples.len() > MAX_WIRE_FRAMES {
log::warn!(
"isolation: clamping {} wire frames to {MAX_WIRE_FRAMES}",
samples.len()
);
samples.truncate(MAX_WIRE_FRAMES);
}
Ok(samples)
})
.collect()
}
}
pub(crate) mod state_codec {
use super::{Deserialize, Deserializer, Serializer, MAX_STATE_BASE64_BYTES};
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const MAX_STATE_BYTES: usize = crate::plugin::MAX_STATE_SNAPSHOT_BYTES;
pub(crate) fn serialize<S: Serializer>(state: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
if state.len() > MAX_STATE_BYTES {
return Err(serde::ser::Error::custom("plugin state exceeds wire limit"));
}
let mut out = String::with_capacity(state.len().div_ceil(3) * 4);
for chunk in state.chunks(3) {
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
let n = (u32::from(chunk[0]) << 16) | (u32::from(b1) << 8) | u32::from(b2);
out.push(ALPHABET[(n >> 18) as usize & 63] as char);
out.push(ALPHABET[(n >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 {
ALPHABET[(n >> 6) as usize & 63] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[n as usize & 63] as char
} else {
'='
});
}
serializer.serialize_str(&out)
}
fn sextet(byte: u8) -> Option<u32> {
Some(u32::from(match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => return None,
}))
}
fn decode(encoded: &str) -> Option<Vec<u8>> {
let bytes = encoded.as_bytes();
if bytes.len() > MAX_STATE_BASE64_BYTES || bytes.len() % 4 != 0 {
return None;
}
let mut raw = Vec::with_capacity(bytes.len() / 4 * 3);
let chunk_count = bytes.len() / 4;
for (chunk_index, chunk) in bytes.chunks(4).enumerate() {
let pad = chunk.iter().rev().take_while(|&&byte| byte == b'=').count();
if pad > 2 || (pad != 0 && chunk_index + 1 != chunk_count) {
return None;
}
let mut n = 0u32;
for (index, &byte) in chunk.iter().enumerate() {
if byte == b'=' {
if index < 4 - pad {
return None;
}
} else {
n |= sextet(byte)? << (18 - 6 * index);
}
}
raw.push((n >> 16) as u8);
if pad < 2 {
raw.push((n >> 8) as u8);
}
if pad == 0 {
raw.push(n as u8);
}
}
(raw.len() <= MAX_STATE_BYTES).then_some(raw)
}
#[derive(Deserialize)]
#[serde(untagged)]
enum StateWire {
Base64(String),
Legacy(Vec<u8>),
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<u8>, D::Error> {
match StateWire::deserialize(deserializer)? {
StateWire::Base64(encoded) => decode(&encoded)
.ok_or_else(|| serde::de::Error::custom("malformed or oversized plugin state")),
StateWire::Legacy(state) if state.len() <= MAX_STATE_BYTES => Ok(state),
StateWire::Legacy(_) => {
Err(serde::de::Error::custom("plugin state exceeds wire limit"))
}
}
}
}
mod lossless_f64 {
use super::{Deserializer, Serializer};
use std::fmt;
pub(super) fn serialize<S: Serializer>(value: &f64, serializer: S) -> Result<S::Ok, S::Error> {
if value.is_finite() {
serializer.serialize_f64(*value)
} else if value.is_nan() {
serializer.serialize_str("NaN")
} else if *value > 0.0 {
serializer.serialize_str("inf")
} else {
serializer.serialize_str("-inf")
}
}
struct AnyF64;
impl serde::de::Visitor<'_> for AnyF64 {
type Value = f64;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a number or a non-finite float spelled as a string")
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<f64, E> {
Ok(v)
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<f64, E> {
Ok(v as f64)
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<f64, E> {
Ok(v as f64)
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<f64, E> {
v.parse::<f64>()
.map_err(|_| E::custom(format!("not a float: {v}")))
}
fn visit_unit<E: serde::de::Error>(self) -> Result<f64, E> {
Ok(f64::NAN)
}
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
deserializer.deserialize_any(AnyF64)
}
}
mod parameter_changes_codec {
use super::{Deserializer, Serializer, MAX_WIRE_PARAMETER_CHANGES};
use serde::de::{SeqAccess, Visitor};
use serde::ser::SerializeSeq;
use std::fmt;
pub(super) fn serialize<S: Serializer>(
changes: &[(u32, f64)],
serializer: S,
) -> Result<S::Ok, S::Error> {
if changes.len() > MAX_WIRE_PARAMETER_CHANGES {
return Err(serde::ser::Error::custom(
"parameter feedback exceeds wire limit",
));
}
let mut sequence = serializer.serialize_seq(Some(changes.len()))?;
for &(id, value) in changes {
sequence.serialize_element(&(id, value.to_bits()))?;
}
sequence.end()
}
struct ParameterChangesVisitor;
impl<'de> Visitor<'de> for ParameterChangesVisitor {
type Value = Vec<(u32, f64)>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"at most {MAX_WIRE_PARAMETER_CHANGES} parameter-id/value-bit pairs"
)
}
fn visit_seq<A: SeqAccess<'de>>(self, mut sequence: A) -> Result<Self::Value, A::Error> {
let capacity = sequence
.size_hint()
.unwrap_or(0)
.min(MAX_WIRE_PARAMETER_CHANGES);
let mut changes = Vec::with_capacity(capacity);
while let Some((id, bits)) = sequence.next_element::<(u32, u64)>()? {
if changes.len() >= MAX_WIRE_PARAMETER_CHANGES {
return Err(serde::de::Error::custom(
"parameter feedback exceeds wire limit",
));
}
changes.push((id, f64::from_bits(bits)));
}
Ok(changes)
}
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<(u32, f64)>, D::Error> {
deserializer.deserialize_seq(ParameterChangesVisitor)
}
}
fn clamped_channel_count<'de, D: Deserializer<'de>>(deserializer: D) -> Result<i32, D::Error> {
let raw = i32::deserialize(deserializer)?;
Ok(raw.clamp(0, MAX_WIRE_CHANNELS as i32))
}
fn clamped_bus_count<'de, D: Deserializer<'de>>(deserializer: D) -> Result<i32, D::Error> {
let raw = i32::deserialize(deserializer)?;
Ok(raw.clamp(0, MAX_WIRE_BUSES))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum HostCommand {
LoadPlugin {
path: String,
#[serde(with = "lossless_f64")]
sample_rate: f64,
block_size: u32,
#[serde(with = "lossless_f64")]
tempo: f64,
time_sig_numerator: i32,
time_sig_denominator: i32,
#[serde(default)]
class_id: Option<String>,
},
UnloadPlugin,
CreateGui,
CloseGui,
StartProcessing,
StopProcessing,
Reconfigure {
#[serde(with = "lossless_f64")]
sample_rate: f64,
block_size: u32,
},
SetProcessMode {
mode: crate::plugin::ProcessMode,
},
SetParameter {
id: u32,
#[serde(with = "lossless_f64")]
value: f64,
},
SetParameterAt {
id: u32,
#[serde(with = "lossless_f64")]
value: f64,
offset: i32,
},
SetTempo {
#[serde(with = "lossless_f64")]
bpm: f64,
},
SetTimeSignature {
numerator: i32,
denominator: i32,
},
SetPlaying {
playing: bool,
},
GetParameter {
id: u32,
},
GetAllParameters,
FormatParameter {
id: u32,
#[serde(with = "lossless_f64")]
normalized: f64,
},
SendMidi {
event: crate::midi::MidiEvent,
},
SendMidiAt {
event: crate::midi::MidiEvent,
sample_offset: i32,
},
SendPluginEvent {
event: crate::midi::PluginEvent,
},
MidiPanic,
Process {
#[serde(with = "audio_codec")]
inputs: Vec<Vec<f32>>,
frames: u32,
},
ProcessBuses {
inputs: Vec<crate::audio::AudioBusBuffer>,
outputs: Vec<crate::audio::AudioBusConfig>,
frames: u32,
},
AudioBusLayout,
SaveState,
LoadState {
#[serde(with = "state_codec")]
data: Vec<u8>,
#[serde(default)]
context: crate::plugin::StateContext,
},
NoteOn {
channel: u8,
note: u8,
velocity: u8,
sample_offset: i32,
},
NoteOff {
note_id: i32,
sample_offset: i32,
},
SendNoteExpression {
note_id: i32,
kind: crate::midi::NoteExpressionType,
#[serde(with = "lossless_f64")]
value: f64,
sample_offset: i32,
},
NoteExpressions {
bus: i32,
channel: i16,
},
SelectProgram {
unit_id: i32,
program_index: i32,
},
SetBusActive {
media_type: crate::audio::MediaType,
direction: crate::audio::BusDirection,
bus_index: i32,
active: bool,
},
BusArrangements,
SetBusArrangements {
inputs: Vec<crate::audio::SpeakerArrangement>,
outputs: Vec<crate::audio::SpeakerArrangement>,
},
GetUnits,
GetSelectedUnit,
SelectUnit {
unit_id: i32,
},
ProgramPitchNames {
program_list_id: i32,
program_index: i32,
},
GetProgramData {
program_list_id: i32,
program_index: i32,
},
SetProgramData {
program_list_id: i32,
program_index: i32,
#[serde(with = "state_codec")]
data: Vec<u8>,
},
GetUnitData {
unit_id: i32,
},
SetUnitData {
unit_id: i32,
#[serde(with = "state_codec")]
data: Vec<u8>,
},
BeginHostEdit {
parameter_id: u32,
},
EndHostEdit {
parameter_id: u32,
},
SendMidiLearn {
bus: i32,
channel: i16,
controller: u16,
},
SetAutomationState {
state: crate::plugin::AutomationState,
},
RemapParameterId {
old_plugin_uid: String,
old_param_id: u32,
},
LatencySamples,
TailSamples,
MidiCcToParameter {
bus: i32,
channel: i16,
cc: u16,
},
TakeParameterEdits,
TakeParameterChanges,
TakeHostNotifications,
TakeDataExchangeBlocks,
ExecuteContextMenuItem {
menu_id: u64,
item_id: u32,
},
DismissContextMenu {
menu_id: u64,
},
TakeRestartFlags,
ServiceHostRequests,
Shutdown,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum HostResponse {
Success {
message: String,
},
Error {
message: String,
},
Crashed {
message: String,
},
AudioOutput {
#[serde(with = "audio_codec")]
outputs: Vec<Vec<f32>>,
output_events: Vec<crate::midi::PluginEvent>,
},
BusAudioOutput {
outputs: Vec<crate::audio::AudioBusBuffer>,
output_events: Vec<crate::midi::PluginEvent>,
},
AudioBusLayout {
layout: crate::audio::AudioBusLayout,
},
ParameterValue {
#[serde(with = "lossless_f64")]
value: f64,
},
ParameterString {
value: String,
},
Parameters {
params: Vec<crate::parameters::Parameter>,
},
State {
#[serde(with = "state_codec")]
data: Vec<u8>,
},
GuiCreated {
width: i32,
height: i32,
},
PluginInfo {
vendor: String,
name: String,
version: String,
category: String,
uid: String,
has_gui: bool,
#[serde(deserialize_with = "clamped_bus_count")]
audio_inputs: i32,
#[serde(deserialize_with = "clamped_bus_count")]
audio_outputs: i32,
#[serde(deserialize_with = "clamped_channel_count")]
output_channels: i32,
has_midi_input: bool,
has_midi_output: bool,
#[serde(default)]
compatibility: Vec<crate::discovery::ClassCompatibility>,
},
NoteStarted {
note_id: i32,
},
NoteExpressions {
expressions: Vec<crate::midi::NoteExpressionInfo>,
},
ParameterEdits {
edits: Vec<crate::plugin::ParameterEdit>,
},
ParameterChanges {
#[serde(with = "parameter_changes_codec")]
changes: Vec<(u32, f64)>,
},
HostNotifications {
notifications: Vec<crate::plugin::HostNotification>,
},
DataExchangeBlocks {
blocks: Vec<crate::plugin::DataExchangeBlock>,
},
RestartFlags {
bits: i32,
},
BusArrangements {
arrangements: crate::audio::BusArrangements,
},
Units {
units: Vec<crate::plugin::PluginUnit>,
},
SelectedUnit {
unit_id: Option<i32>,
},
ProgramPitchNames {
names: Vec<crate::plugin::ProgramPitchName>,
},
OpaqueData {
supported: bool,
#[serde(with = "state_codec")]
data: Vec<u8>,
},
LatencySamples {
samples: u32,
},
TailSamples {
samples: u32,
},
MidiParameterMapping {
id: Option<u32>,
},
RemappedParameter {
id: Option<u32>,
},
}
pub struct ProtocolChannel {
inner: ProtocolChannelInner,
}
#[cfg(unix)]
type ProtocolChannelInner = std::fs::File;
#[cfg(not(unix))]
type ProtocolChannelInner = std::io::Stdout;
impl ProtocolChannel {
#[cfg(unix)]
pub fn claim() -> Self {
use std::os::fd::FromRawFd;
let fd = unsafe {
let private = match libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD_CLOEXEC, 3) {
fd if fd >= 0 => Some(fd),
_ => match libc::dup(libc::STDOUT_FILENO) {
fd if fd >= 0 => Some(fd),
_ => None,
},
};
match private {
Some(fd) => {
libc::dup2(libc::STDERR_FILENO, libc::STDOUT_FILENO);
fd
}
None => {
eprintln!("helper: could not privatise the protocol channel; plugin writes to stdout may corrupt it");
libc::STDOUT_FILENO
}
}
};
Self {
inner: unsafe { std::fs::File::from_raw_fd(fd) },
}
}
#[cfg(not(unix))]
pub fn claim() -> Self {
Self {
inner: std::io::stdout(),
}
}
}
impl Write for ProtocolChannel {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.inner.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
pub struct PluginHostProcess {
process: Option<Child>,
stdin: Option<ChildStdin>,
responses: Receiver<String>,
reader: Option<JoinHandle<()>>,
reader_finished: Arc<AtomicBool>,
queued: Arc<AtomicUsize>,
discarded_by_reader: Arc<AtomicU64>,
unparsed_lines: u64,
timeout: Duration,
slow_timeout: Duration,
dead: bool,
helper_path: std::path::PathBuf,
}
const LOAD_CRASH_RETRIES: u32 = 1;
const LOAD_CRASH_RETRY_BACKOFF: Duration = Duration::from_millis(250);
enum ExchangeError {
AlreadyDead(String),
DiedDuringCommand(String),
TimedOut(String),
Encoding(String),
}
impl From<ExchangeError> for String {
fn from(error: ExchangeError) -> String {
match error {
ExchangeError::AlreadyDead(message)
| ExchangeError::DiedDuringCommand(message)
| ExchangeError::TimedOut(message)
| ExchangeError::Encoding(message) => message,
}
}
}
enum ReadLine {
Line(Vec<u8>),
Oversized,
Eof,
}
fn read_bounded_line(reader: &mut impl BufRead, max: usize) -> ReadLine {
let mut line = Vec::new();
let mut oversized = false;
loop {
let budget = (max + 1 - line.len()) as u64;
let mut chunk = Vec::new();
let read = match reader.by_ref().take(budget).read_until(b'\n', &mut chunk) {
Ok(n) => n,
Err(_) => return ReadLine::Eof,
};
let complete = chunk.last() == Some(&b'\n');
if !oversized {
line.extend_from_slice(&chunk);
if line.len() > max {
oversized = true;
line = Vec::new();
}
}
if read == 0 {
return if oversized {
ReadLine::Oversized
} else if line.is_empty() {
ReadLine::Eof
} else {
ReadLine::Line(line)
};
}
if complete {
return if oversized {
ReadLine::Oversized
} else {
ReadLine::Line(line)
};
}
}
}
impl PluginHostProcess {
pub fn new(
helper_override: Option<std::path::PathBuf>,
timeout: Duration,
) -> Result<Self, String> {
let override_path = helper_override
.or_else(|| std::env::var_os("VST3_HOST_HELPER_PATH").map(std::path::PathBuf::from));
if let Some(p) = override_path {
if !p.exists() {
return Err(format!(
"Configured helper path does not exist: {}",
p.display()
));
}
return Self::spawn(p, timeout);
}
let exe_path =
std::env::current_exe().map_err(|e| format!("Failed to get current exe: {}", e))?;
let exe_dir = exe_path.parent().ok_or("Failed to get exe directory")?;
let helper_names = ["vst3-host-helper", "vst3-inspector-helper"];
let mut helper_path = None;
for name in &helper_names {
let path = exe_dir.join(name);
if path.exists() {
helper_path = Some(path);
break;
}
}
if helper_path.is_none() && exe_dir.file_name() == Some(std::ffi::OsStr::new("examples")) {
if let Some(parent_dir) = exe_dir.parent() {
for name in &helper_names {
let path = parent_dir.join(name);
if path.exists() {
helper_path = Some(path);
break;
}
}
}
}
if helper_path.is_none() && crate::discovery::running_from_cargo_target(exe_dir) {
let mut current_dir = exe_dir;
while let Some(parent) = current_dir.parent() {
let debug_path = parent.join("target").join("debug").join("vst3-host-helper");
let release_path = parent
.join("target")
.join("release")
.join("vst3-host-helper");
if debug_path.exists() {
helper_path = Some(debug_path);
break;
} else if release_path.exists() {
helper_path = Some(release_path);
break;
}
if parent.join("Cargo.toml").exists() {
break;
}
current_dir = parent;
}
}
let helper_path = helper_path
.ok_or_else(|| format!("Helper executable not found. Searched in {:?} and parent directories. Make sure to build with --bins flag.", exe_dir))?;
Self::spawn(helper_path, timeout)
}
fn spawn(helper_path: std::path::PathBuf, timeout: Duration) -> Result<Self, String> {
let mut child = Command::new(&helper_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("Failed to spawn helper process: {}", e))?;
let stdin = child.stdin.take().ok_or("Failed to get stdin")?;
let stdout = child.stdout.take().ok_or("Failed to get stdout")?;
let (tx, rx) = mpsc::channel::<String>();
let queued = Arc::new(AtomicUsize::new(0));
let discarded = Arc::new(AtomicU64::new(0));
let finished = Arc::new(AtomicBool::new(false));
let reader = std::thread::spawn({
let queued = Arc::clone(&queued);
let discarded = Arc::clone(&discarded);
let finished = Arc::clone(&finished);
move || {
let mut reader = BufReader::new(stdout);
loop {
match read_bounded_line(&mut reader, MAX_RESPONSE_LINE_BYTES) {
ReadLine::Eof => break,
ReadLine::Oversized => {
discarded.fetch_add(1, Ordering::Relaxed);
}
ReadLine::Line(bytes) => {
if queued.load(Ordering::Relaxed) >= MAX_QUEUED_RESPONSES {
discarded.fetch_add(1, Ordering::Relaxed);
continue;
}
queued.fetch_add(1, Ordering::Relaxed);
let line = String::from_utf8_lossy(&bytes).into_owned();
if tx.send(line).is_err() {
break; }
}
}
}
finished.store(true, Ordering::Release);
}
});
Ok(Self {
process: Some(child),
stdin: Some(stdin),
responses: rx,
reader: Some(reader),
reader_finished: finished,
queued,
discarded_by_reader: discarded,
unparsed_lines: 0,
timeout,
slow_timeout: DEFAULT_SLOW_COMMAND_TIMEOUT.max(timeout),
dead: false,
helper_path,
})
}
fn respawn(&mut self) -> Result<(), String> {
self.shutdown();
let slow_timeout = self.slow_timeout;
let unparsed_lines = self.unparsed_lines;
let discarded = self.discarded_by_reader.load(Ordering::Relaxed);
*self = Self::spawn(self.helper_path.clone(), self.timeout)?;
self.slow_timeout = slow_timeout;
self.unparsed_lines = unparsed_lines;
self.discarded_by_reader
.fetch_add(discarded, Ordering::Relaxed);
Ok(())
}
pub fn set_timeout(&mut self, timeout: Duration) {
self.timeout = timeout;
}
pub fn set_slow_command_timeout(&mut self, timeout: Duration) {
self.slow_timeout = timeout;
}
fn timeout_for(&self, command: &HostCommand) -> Duration {
if is_slow_command(command) {
self.slow_timeout.max(self.timeout)
} else {
self.timeout
}
}
fn drop_stale_lines(&mut self) {
let mut stale = 0u64;
while self.responses.try_recv().is_ok() {
self.queued.fetch_sub(1, Ordering::Relaxed);
stale += 1;
}
if stale > 0 {
self.unparsed_lines += stale;
log::warn!("isolation: dropped {stale} unsolicited line(s) from the helper");
}
}
fn exit_status(&mut self) -> Option<std::process::ExitStatus> {
self.process
.as_mut()
.and_then(|p| p.try_wait().ok().flatten())
}
pub fn discarded_line_count(&self) -> u64 {
self.unparsed_lines + self.discarded_by_reader.load(Ordering::Relaxed)
}
pub fn send_command(&mut self, command: HostCommand) -> Result<HostResponse, String> {
if !matches!(command, HostCommand::LoadPlugin { .. }) {
return self.exchange(command).map_err(String::from);
}
let mut retries_left = LOAD_CRASH_RETRIES;
loop {
match self.exchange(command.clone()) {
Ok(response) => return Ok(response),
Err(ExchangeError::DiedDuringCommand(detail)) if retries_left > 0 => {
retries_left -= 1;
log::warn!(
"isolation: the helper died while loading the plugin ({detail}); \
retrying once with a fresh helper"
);
std::thread::sleep(LOAD_CRASH_RETRY_BACKOFF);
if let Err(spawn_error) = self.respawn() {
log::warn!("isolation: could not respawn the helper: {spawn_error}");
return Err(detail);
}
}
Err(other) => return Err(String::from(other)),
}
}
}
fn exchange(&mut self, command: HostCommand) -> Result<HostResponse, ExchangeError> {
if self.dead {
return Err(ExchangeError::AlreadyDead(
"Helper process is no longer running".to_string(),
));
}
let command_json = serde_json::to_string(&command)
.map_err(|e| ExchangeError::Encoding(format!("Failed to serialize command: {}", e)))?;
self.drop_stale_lines();
{
let Some(stdin) = self.stdin.as_mut() else {
return Err(ExchangeError::AlreadyDead("No stdin available".to_string()));
};
if let Err(e) = writeln!(stdin, "{}", command_json).and_then(|()| stdin.flush()) {
self.dead = true;
return Err(ExchangeError::DiedDuringCommand(format!(
"Failed to write command (helper gone?): {}",
e
)));
}
}
let timeout = self.timeout_for(&command);
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
match self.responses.recv_timeout(remaining) {
Ok(line) => {
self.queued.fetch_sub(1, Ordering::Relaxed);
match serde_json::from_str::<HostResponse>(&line) {
Ok(response) => return Ok(response),
Err(parse_error) => {
if let Some(status) = self.exit_status() {
self.dead = true;
return Err(ExchangeError::DiedDuringCommand(format!(
"Helper process crashed: exited with {status} while writing a response ({parse_error})"
)));
}
self.unparsed_lines += 1;
log::warn!(
"isolation: dropping unparseable line from the helper ({parse_error})"
);
}
}
}
Err(RecvTimeoutError::Timeout) => {
self.dead = true;
if let Some(ref mut process) = self.process {
let _ = process.kill();
}
return Err(ExchangeError::TimedOut(format!(
"Timed out after {:?} waiting for helper response (plugin may have hung)",
timeout
)));
}
Err(RecvTimeoutError::Disconnected) => {
self.dead = true;
let detail = match self.check_process_status() {
Err(status) => format!("Helper process crashed: {}", status),
Ok(()) => "Helper process exited unexpectedly".to_string(),
};
return Err(ExchangeError::DiedDuringCommand(detail));
}
}
}
}
pub fn is_alive(&self) -> bool {
!self.dead
}
pub fn helper_pid(&self) -> Option<u32> {
self.process.as_ref().map(|c| c.id())
}
pub fn check_process_status(&mut self) -> Result<(), String> {
if let Some(ref mut process) = self.process {
match process.try_wait() {
Ok(Some(status)) => {
if !status.success() {
return Err(format!("Helper process exited with status: {}", status));
}
}
Ok(None) => {
return Ok(());
}
Err(e) => {
return Err(format!("Failed to check process status: {}", e));
}
}
}
Ok(())
}
pub fn shutdown(&mut self) {
if !self.dead {
if let (Some(stdin), Ok(json)) = (
self.stdin.as_mut(),
serde_json::to_string(&HostCommand::Shutdown),
) {
let _ = writeln!(stdin, "{}", json);
let _ = stdin.flush();
}
}
self.stdin = None;
if let Some(mut process) = self.process.take() {
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
match process.try_wait() {
Ok(Some(_)) => break,
Ok(None) if std::time::Instant::now() >= deadline => {
let _ = process.kill();
let _ = process.wait();
break;
}
Ok(None) => std::thread::sleep(Duration::from_millis(10)),
Err(_) => {
let _ = process.kill();
break;
}
}
}
}
if let Some(reader) = self.reader.take() {
let deadline = std::time::Instant::now() + Duration::from_millis(250);
while !self.reader_finished.load(Ordering::Acquire) {
if std::time::Instant::now() >= deadline {
log::debug!("isolation: helper stdout still open, detaching reader thread");
break;
}
std::thread::sleep(Duration::from_millis(5));
}
if self.reader_finished.load(Ordering::Acquire) {
let _ = reader.join();
}
}
self.dead = true;
}
}
impl Drop for PluginHostProcess {
fn drop(&mut self) {
self.shutdown();
}
}
pub type IsolationResult<T> = std::result::Result<T, IsolationError>;
#[derive(Debug, thiserror::Error)]
pub enum IsolationError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Plugin error: {0}")]
Plugin(String),
#[error("Plugin crashed: {0}")]
Crashed(String),
#[error("Helper process not running")]
NotRunning,
#[error("Unexpected response from helper")]
UnexpectedResponse,
}
#[cfg(test)]
mod wire_tests {
use super::*;
use crate::midi::{MidiChannel, MidiEvent};
#[test]
fn audio_output_carries_midi_across_the_wire() {
let resp = HostResponse::AudioOutput {
outputs: vec![vec![0.0, 0.5], vec![-0.5, 0.0]],
output_events: vec![
MidiEvent::NoteOn {
channel: MidiChannel::Ch1,
note: 60,
velocity: 100,
}
.into(),
MidiEvent::NoteOff {
channel: MidiChannel::Ch1,
note: 60,
velocity: 0,
}
.into(),
],
};
let json = serde_json::to_string(&resp).expect("serialize");
let back: HostResponse = serde_json::from_str(&json).expect("deserialize");
match back {
HostResponse::AudioOutput {
outputs,
output_events,
} => {
assert_eq!(outputs, vec![vec![0.0, 0.5], vec![-0.5, 0.0]]);
assert_eq!(output_events.len(), 2);
assert_eq!(
output_events[0].to_midi(),
Some(MidiEvent::NoteOn {
channel: MidiChannel::Ch1,
note: 60,
velocity: 100
})
);
}
other => panic!("round-trip changed the variant: {other:?}"),
}
}
#[test]
fn state_commands_round_trip_across_the_wire() {
let blob: Vec<u8> = vec![0, 1, 2, 250, 255, 42];
let save = serde_json::to_string(&HostCommand::SaveState).expect("serialize SaveState");
assert!(matches!(
serde_json::from_str::<HostCommand>(&save).expect("deserialize SaveState"),
HostCommand::SaveState
));
let load = HostCommand::LoadState {
data: blob.clone(),
context: crate::plugin::StateContext::Project,
};
let load_json = serde_json::to_string(&load).expect("serialize LoadState");
assert!(
load_json.contains("\"data\":\""),
"state should use compact base64, not a JSON integer array"
);
match serde_json::from_str::<HostCommand>(&load_json).expect("deserialize LoadState") {
HostCommand::LoadState { data, context } => {
assert_eq!(data, blob);
assert_eq!(context, crate::plugin::StateContext::Project);
}
other => panic!("LoadState round-trip changed the variant: {other:?}"),
}
let legacy = r#"{"LoadState":{"data":[0,1,2,250,255,42]}}"#;
match serde_json::from_str::<HostCommand>(legacy).expect("deserialize legacy LoadState") {
HostCommand::LoadState { data, context } => {
assert_eq!(data, blob);
assert_eq!(context, crate::plugin::StateContext::Project);
}
other => panic!("legacy LoadState changed the variant: {other:?}"),
}
let state = HostResponse::State { data: blob.clone() };
let state_json = serde_json::to_string(&state).expect("serialize State");
match serde_json::from_str::<HostResponse>(&state_json).expect("deserialize State") {
HostResponse::State { data } => assert_eq!(data, blob),
other => panic!("State round-trip changed the variant: {other:?}"),
}
}
#[test]
fn set_parameter_at_round_trips_across_the_wire() {
let cmd = HostCommand::SetParameterAt {
id: 42,
value: 0.75,
offset: 256,
};
let json = serde_json::to_string(&cmd).expect("serialize SetParameterAt");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetParameterAt") {
HostCommand::SetParameterAt { id, value, offset } => {
assert_eq!(id, 42);
assert_eq!(value, 0.75);
assert_eq!(offset, 256);
}
other => panic!("round-trip changed the variant: {other:?}"),
}
}
#[test]
fn scheduled_midi_offset_ipc_round_trips() {
use crate::midi::{MidiChannel, MidiEvent};
let cmd = HostCommand::SendMidiAt {
event: MidiEvent::NoteOn {
channel: MidiChannel::Ch1,
note: 60,
velocity: 100,
},
sample_offset: 256,
};
let json = serde_json::to_string(&cmd).expect("serialize SendMidiAt");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize SendMidiAt") {
HostCommand::SendMidiAt {
event,
sample_offset,
} => {
assert_eq!(
event,
MidiEvent::NoteOn {
channel: MidiChannel::Ch1,
note: 60,
velocity: 100
}
);
assert_eq!(sample_offset, 256);
}
other => panic!("round-trip changed the variant: {other:?}"),
}
}
#[test]
fn owned_sysex_round_trips_in_commands_and_process_output() {
let event = crate::midi::PluginEvent::sysex(vec![0xf0, 0x7d, 1, 2, 0xf7]).at(37);
let command = HostCommand::SendPluginEvent {
event: event.clone(),
};
let json = serde_json::to_string(&command).expect("serialize owned event");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize owned event") {
HostCommand::SendPluginEvent { event: decoded } => assert_eq!(decoded, event),
other => panic!("owned event command changed variant: {other:?}"),
}
let response = HostResponse::AudioOutput {
outputs: Vec::new(),
output_events: vec![event.clone()],
};
let json = serde_json::to_string(&response).expect("serialize owned output");
match serde_json::from_str::<HostResponse>(&json).expect("deserialize owned output") {
HostResponse::AudioOutput { output_events, .. } => {
assert_eq!(output_events, vec![event])
}
other => panic!("owned event response changed variant: {other:?}"),
}
}
#[test]
fn select_program_round_trips_across_the_wire() {
let cmd = HostCommand::SelectProgram {
unit_id: 0,
program_index: 17,
};
let json = serde_json::to_string(&cmd).expect("serialize SelectProgram");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize SelectProgram") {
HostCommand::SelectProgram {
unit_id,
program_index,
} => {
assert_eq!(unit_id, 0);
assert_eq!(program_index, 17);
}
other => panic!("round-trip changed the variant: {other:?}"),
}
}
#[test]
fn transport_commands_round_trip_across_the_wire() {
let tempo = HostCommand::SetTempo { bpm: 137.5 };
let json = serde_json::to_string(&tempo).expect("serialize SetTempo");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetTempo") {
HostCommand::SetTempo { bpm } => assert_eq!(bpm, 137.5),
other => panic!("round-trip changed the variant: {other:?}"),
}
let ts = HostCommand::SetTimeSignature {
numerator: 7,
denominator: 8,
};
let json = serde_json::to_string(&ts).expect("serialize SetTimeSignature");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetTimeSignature") {
HostCommand::SetTimeSignature {
numerator,
denominator,
} => assert_eq!((numerator, denominator), (7, 8)),
other => panic!("round-trip changed the variant: {other:?}"),
}
let playing = HostCommand::SetPlaying { playing: false };
let json = serde_json::to_string(&playing).expect("serialize SetPlaying");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetPlaying") {
HostCommand::SetPlaying { playing } => assert!(!playing),
other => panic!("round-trip changed the variant: {other:?}"),
}
}
#[test]
fn set_bus_active_round_trips_across_the_wire() {
use crate::audio::{BusDirection, MediaType};
let cmd = HostCommand::SetBusActive {
media_type: MediaType::Audio,
direction: BusDirection::Input,
bus_index: 1,
active: true,
};
let json = serde_json::to_string(&cmd).expect("serialize SetBusActive");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize SetBusActive") {
HostCommand::SetBusActive {
media_type,
direction,
bus_index,
active,
} => {
assert_eq!(media_type, MediaType::Audio);
assert_eq!(direction, BusDirection::Input);
assert_eq!(bus_index, 1);
assert!(active);
}
other => panic!("round-trip changed the variant: {other:?}"),
}
}
#[test]
fn bus_arrangements_round_trip_across_the_wire() {
use crate::audio::{BusArrangements, SpeakerArrangement};
let cmd = serde_json::to_string(&HostCommand::BusArrangements)
.expect("serialize BusArrangements");
assert!(matches!(
serde_json::from_str::<HostCommand>(&cmd).expect("deserialize BusArrangements"),
HostCommand::BusArrangements
));
let set = HostCommand::SetBusArrangements {
inputs: vec![],
outputs: vec![SpeakerArrangement::STEREO],
};
let set_json = serde_json::to_string(&set).expect("serialize SetBusArrangements");
match serde_json::from_str::<HostCommand>(&set_json).expect("deserialize") {
HostCommand::SetBusArrangements { inputs, outputs } => {
assert!(inputs.is_empty());
assert_eq!(outputs, vec![SpeakerArrangement::STEREO]);
}
other => panic!("SetBusArrangements round-trip changed the variant: {other:?}"),
}
let arrangements = BusArrangements {
inputs: vec![],
outputs: vec![SpeakerArrangement::STEREO],
};
let resp = HostResponse::BusArrangements {
arrangements: arrangements.clone(),
};
let resp_json = serde_json::to_string(&resp).expect("serialize BusArrangements response");
match serde_json::from_str::<HostResponse>(&resp_json).expect("deserialize") {
HostResponse::BusArrangements { arrangements: back } => {
assert_eq!(back, arrangements);
}
other => panic!("BusArrangements response round-trip changed the variant: {other:?}"),
}
}
#[test]
fn get_units_round_trips_across_the_wire() {
use crate::plugin::PluginUnit;
let cmd = serde_json::to_string(&HostCommand::GetUnits).expect("serialize GetUnits");
assert!(matches!(
serde_json::from_str::<HostCommand>(&cmd).expect("deserialize GetUnits"),
HostCommand::GetUnits
));
let units = vec![PluginUnit {
id: 0,
parent_id: -1,
name: "Root".to_string(),
program_list_id: Some(12),
programs: vec!["Init".to_string(), "Lead".to_string()],
}];
let resp = HostResponse::Units {
units: units.clone(),
};
let resp_json = serde_json::to_string(&resp).expect("serialize Units");
match serde_json::from_str::<HostResponse>(&resp_json).expect("deserialize Units") {
HostResponse::Units { units: back } => assert_eq!(back, units),
other => panic!("Units round-trip changed the variant: {other:?}"),
}
}
#[test]
fn latency_and_tail_round_trip_across_the_wire() {
let latency_cmd = serde_json::to_string(&HostCommand::LatencySamples).expect("serialize");
assert!(matches!(
serde_json::from_str::<HostCommand>(&latency_cmd).expect("deserialize"),
HostCommand::LatencySamples
));
let tail_cmd = serde_json::to_string(&HostCommand::TailSamples).expect("serialize");
assert!(matches!(
serde_json::from_str::<HostCommand>(&tail_cmd).expect("deserialize"),
HostCommand::TailSamples
));
let latency_resp = HostResponse::LatencySamples { samples: 128 };
let json = serde_json::to_string(&latency_resp).expect("serialize");
match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
HostResponse::LatencySamples { samples } => assert_eq!(samples, 128),
other => panic!("LatencySamples round-trip changed the variant: {other:?}"),
}
let tail_resp = HostResponse::TailSamples { samples: 44100 };
let json = serde_json::to_string(&tail_resp).expect("serialize");
match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
HostResponse::TailSamples { samples } => assert_eq!(samples, 44100),
other => panic!("TailSamples round-trip changed the variant: {other:?}"),
}
}
#[test]
fn midi_cc_to_parameter_round_trips_across_the_wire() {
let cmd = HostCommand::MidiCcToParameter {
bus: 0,
channel: 1,
cc: 74,
};
let json = serde_json::to_string(&cmd).expect("serialize MidiCcToParameter");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize") {
HostCommand::MidiCcToParameter { bus, channel, cc } => {
assert_eq!((bus, channel, cc), (0, 1, 74));
}
other => panic!("MidiCcToParameter round-trip changed the variant: {other:?}"),
}
let resp = HostResponse::MidiParameterMapping { id: Some(42) };
let json = serde_json::to_string(&resp).expect("serialize MidiParameterMapping");
match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
HostResponse::MidiParameterMapping { id } => assert_eq!(id, Some(42)),
other => panic!("MidiParameterMapping round-trip changed the variant: {other:?}"),
}
let none_resp = HostResponse::MidiParameterMapping { id: None };
let json = serde_json::to_string(&none_resp).expect("serialize");
match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
HostResponse::MidiParameterMapping { id } => assert_eq!(id, None),
other => {
panic!("MidiParameterMapping (None) round-trip changed the variant: {other:?}")
}
}
}
#[test]
fn parameter_id_remapping_round_trips_uid_and_optional_result() {
let uid = "123456789ABCDEF01122334455667788";
let command = HostCommand::RemapParameterId {
old_plugin_uid: uid.to_string(),
old_param_id: 0xDEAD_BEEF,
};
let json = serde_json::to_string(&command).expect("serialize RemapParameterId");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize RemapParameterId") {
HostCommand::RemapParameterId {
old_plugin_uid,
old_param_id,
} => {
assert_eq!(old_plugin_uid, uid);
assert_eq!(old_param_id, 0xDEAD_BEEF);
assert!(crate::internal::utils::parse_class_uid(&old_plugin_uid).is_some());
}
other => panic!("RemapParameterId round-trip changed the variant: {other:?}"),
}
for id in [Some(42), None] {
let response = HostResponse::RemappedParameter { id };
let json = serde_json::to_string(&response).expect("serialize RemappedParameter");
match serde_json::from_str::<HostResponse>(&json)
.expect("deserialize RemappedParameter")
{
HostResponse::RemappedParameter { id: decoded } => assert_eq!(decoded, id),
other => panic!("RemappedParameter round-trip changed the variant: {other:?}"),
}
}
let invalid = HostCommand::RemapParameterId {
old_plugin_uid: "1234-not-a-uid".to_string(),
old_param_id: 1,
};
let json = serde_json::to_string(&invalid).expect("serialize invalid UID");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize invalid UID") {
HostCommand::RemapParameterId { old_plugin_uid, .. } => {
assert!(crate::internal::utils::parse_class_uid(&old_plugin_uid).is_none());
}
other => panic!("invalid RemapParameterId changed the variant: {other:?}"),
}
}
#[test]
fn parameter_edits_round_trip_across_the_wire() {
use crate::plugin::{ParameterEdit, ParameterEditKind};
let cmd = serde_json::to_string(&HostCommand::TakeParameterEdits)
.expect("serialize TakeParameterEdits");
assert!(matches!(
serde_json::from_str::<HostCommand>(&cmd).expect("deserialize TakeParameterEdits"),
HostCommand::TakeParameterEdits
));
let edits = vec![
ParameterEdit {
id: 9,
kind: ParameterEditKind::BeginGesture,
value: None,
},
ParameterEdit {
id: 9,
kind: ParameterEditKind::ValueChange,
value: Some(0.3),
},
ParameterEdit {
id: 9,
kind: ParameterEditKind::EndGesture,
value: None,
},
];
let resp = HostResponse::ParameterEdits {
edits: edits.clone(),
};
let resp_json = serde_json::to_string(&resp).expect("serialize ParameterEdits");
match serde_json::from_str::<HostResponse>(&resp_json).expect("deserialize ParameterEdits")
{
HostResponse::ParameterEdits { edits: back } => assert_eq!(back, edits),
other => panic!("ParameterEdits round-trip changed the variant: {other:?}"),
}
}
#[test]
fn parameter_feedback_round_trips_losslessly_and_is_bounded() {
let command = serde_json::to_string(&HostCommand::TakeParameterChanges)
.expect("serialize TakeParameterChanges");
assert!(matches!(
serde_json::from_str::<HostCommand>(&command)
.expect("deserialize TakeParameterChanges"),
HostCommand::TakeParameterChanges
));
let changes = vec![
(1, 0.25),
(2, -0.0),
(3, f64::NAN),
(4, f64::INFINITY),
(5, f64::NEG_INFINITY),
];
let response = HostResponse::ParameterChanges {
changes: changes.clone(),
};
let json = serde_json::to_string(&response).expect("serialize parameter feedback");
let HostResponse::ParameterChanges { changes: decoded } =
serde_json::from_str::<HostResponse>(&json).expect("deserialize parameter feedback")
else {
panic!("parameter feedback changed response variant");
};
assert_eq!(
decoded
.iter()
.map(|&(id, value)| (id, value.to_bits()))
.collect::<Vec<_>>(),
changes
.iter()
.map(|&(id, value)| (id, value.to_bits()))
.collect::<Vec<_>>()
);
let over_limit = HostResponse::ParameterChanges {
changes: vec![(1, 0.5); MAX_WIRE_PARAMETER_CHANGES + 1],
};
assert!(
serde_json::to_string(&over_limit).is_err(),
"the helper must not emit an oversized feedback response"
);
let entries = (0..=MAX_WIRE_PARAMETER_CHANGES)
.map(|_| "[1,0]")
.collect::<Vec<_>>()
.join(",");
let oversized_json = format!("{{\"ParameterChanges\":{{\"changes\":[{entries}]}}}}");
assert!(
serde_json::from_str::<HostResponse>(&oversized_json).is_err(),
"the host must reject oversized feedback before collecting it"
);
}
#[test]
fn host_notifications_and_restart_requests_round_trip_across_the_wire() {
use crate::plugin::HostNotification;
for command in [
HostCommand::TakeHostNotifications,
HostCommand::ExecuteContextMenuItem {
menu_id: 19,
item_id: 3,
},
HostCommand::DismissContextMenu { menu_id: 20 },
HostCommand::TakeRestartFlags,
HostCommand::ServiceHostRequests,
] {
let json = serde_json::to_string(&command).expect("serialize host request");
let decoded = serde_json::from_str::<HostCommand>(&json).expect("deserialize");
assert_eq!(
std::mem::discriminant(&decoded),
std::mem::discriminant(&command)
);
}
let notifications = vec![
HostNotification::DirtyChanged(true),
HostNotification::OpenEditorRequested {
name: Some("editor".to_string()),
},
HostNotification::GroupEditStarted,
HostNotification::GroupEditFinished,
HostNotification::ContextMenuRequested {
menu_id: 19,
parameter_id: Some(44),
x: 12,
y: 24,
items: vec![crate::plugin::ContextMenuItem {
item_id: 0,
name: "Reset".to_string(),
tag: 7,
flags: 0,
}],
},
];
let response = HostResponse::HostNotifications {
notifications: notifications.clone(),
};
let json = serde_json::to_string(&response).expect("serialize notifications");
match serde_json::from_str::<HostResponse>(&json).expect("deserialize notifications") {
HostResponse::HostNotifications {
notifications: decoded,
} => assert_eq!(decoded, notifications),
other => panic!("HostNotifications changed variant: {other:?}"),
}
let response = HostResponse::RestartFlags { bits: 0x345 };
let json = serde_json::to_string(&response).expect("serialize restart flags");
match serde_json::from_str::<HostResponse>(&json).expect("deserialize restart flags") {
HostResponse::RestartFlags { bits } => assert_eq!(bits, 0x345),
other => panic!("RestartFlags changed variant: {other:?}"),
}
}
#[test]
fn note_expression_commands_round_trip_across_the_wire() {
use crate::midi::{NoteExpressionInfo, NoteExpressionType};
let on = HostCommand::NoteOn {
channel: 0,
note: 60,
velocity: 100,
sample_offset: 0,
};
let on_json = serde_json::to_string(&on).expect("serialize NoteOn");
match serde_json::from_str::<HostCommand>(&on_json).expect("deserialize NoteOn") {
HostCommand::NoteOn {
channel,
note,
velocity,
sample_offset,
} => {
assert_eq!((channel, note, velocity, sample_offset), (0, 60, 100, 0));
}
other => panic!("NoteOn round-trip changed the variant: {other:?}"),
}
let expr = HostCommand::SendNoteExpression {
note_id: 7,
kind: NoteExpressionType::Tuning,
value: 1.0,
sample_offset: 0,
};
let expr_json = serde_json::to_string(&expr).expect("serialize SendNoteExpression");
match serde_json::from_str::<HostCommand>(&expr_json).expect("deserialize") {
HostCommand::SendNoteExpression {
note_id,
kind,
value,
..
} => {
assert_eq!(note_id, 7);
assert_eq!(kind, NoteExpressionType::Tuning);
assert_eq!(value, 1.0);
}
other => panic!("SendNoteExpression round-trip changed the variant: {other:?}"),
}
let started = HostResponse::NoteStarted { note_id: 42 };
let started_json = serde_json::to_string(&started).expect("serialize NoteStarted");
match serde_json::from_str::<HostResponse>(&started_json).expect("deserialize") {
HostResponse::NoteStarted { note_id } => assert_eq!(note_id, 42),
other => panic!("NoteStarted round-trip changed the variant: {other:?}"),
}
let info = NoteExpressionInfo {
kind: NoteExpressionType::Tuning,
title: "Tuning".to_string(),
short_title: "Tun".to_string(),
units: String::new(),
default_value: 0.5,
min: 0.0,
max: 1.0,
step_count: 0,
is_bipolar: true,
is_one_shot: false,
is_absolute: false,
};
let resp = HostResponse::NoteExpressions {
expressions: vec![info.clone()],
};
let resp_json = serde_json::to_string(&resp).expect("serialize NoteExpressions");
match serde_json::from_str::<HostResponse>(&resp_json).expect("deserialize") {
HostResponse::NoteExpressions { expressions } => {
assert_eq!(expressions, vec![info]);
}
other => panic!("NoteExpressions round-trip changed the variant: {other:?}"),
}
}
#[test]
fn explicit_helper_override_missing_path_reports_clearly() {
let bogus = std::path::PathBuf::from("/nonexistent/vst3-host-helper-xyz");
let err = match PluginHostProcess::new(Some(bogus.clone()), DEFAULT_RESPONSE_TIMEOUT) {
Ok(_) => panic!("a missing override path must error, not spawn"),
Err(e) => e,
};
assert!(
err.contains("does not exist"),
"error should explain the missing path, got: {err}"
);
assert!(
err.contains("vst3-host-helper-xyz"),
"error should name the offending path, got: {err}"
);
}
#[test]
fn non_finite_samples_survive_the_audio_wire_format() {
let channel = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
0.5,
f32::MIN_POSITIVE,
];
let resp = HostResponse::AudioOutput {
outputs: vec![channel.clone(), vec![]],
output_events: Vec::new(),
};
let json = serde_json::to_string(&resp).expect("serialize");
assert!(
!json.contains("null"),
"non-finite samples must not become null"
);
match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
HostResponse::AudioOutput { outputs, .. } => {
assert_eq!(outputs.len(), 2);
assert!(outputs[1].is_empty());
let bits: Vec<u32> = outputs[0].iter().map(|s| s.to_bits()).collect();
let want: Vec<u32> = channel.iter().map(|s| s.to_bits()).collect();
assert_eq!(bits, want, "samples must round-trip bit-exactly");
}
other => panic!("round-trip changed the variant: {other:?}"),
}
let cmd = HostCommand::Process {
inputs: vec![vec![f32::NAN, 1.0]],
frames: 2,
};
let json = serde_json::to_string(&cmd).expect("serialize Process");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize Process") {
HostCommand::Process { inputs, frames } => {
assert_eq!(frames, 2);
assert!(inputs[0][0].is_nan());
assert_eq!(inputs[0][1], 1.0);
}
other => panic!("Process round-trip changed the variant: {other:?}"),
}
}
#[test]
fn bus_audio_wire_preserves_bus_boundaries_activation_and_sample_bits() {
let command = HostCommand::ProcessBuses {
inputs: vec![
crate::audio::AudioBusBuffer {
active: true,
channels: vec![vec![f32::NAN, 1.0], vec![2.0, 3.0]],
},
crate::audio::AudioBusBuffer {
active: false,
channels: vec![vec![99.0, 99.0]],
},
],
outputs: vec![
crate::audio::AudioBusConfig {
channel_count: 2,
active: true,
},
crate::audio::AudioBusConfig {
channel_count: 1,
active: false,
},
],
frames: 2,
};
let json = serde_json::to_string(&command).expect("serialize ProcessBuses");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize ProcessBuses") {
HostCommand::ProcessBuses {
inputs,
outputs,
frames,
} => {
assert_eq!(frames, 2);
assert_eq!(inputs.len(), 2);
assert!(inputs[0].active);
assert!(!inputs[1].active);
assert!(inputs[0].channels[0][0].is_nan());
assert_eq!(inputs[1].channels[0], [99.0, 99.0]);
assert_eq!(outputs[1].channel_count, 1);
assert!(!outputs[1].active);
}
other => panic!("ProcessBuses round-trip changed the variant: {other:?}"),
}
}
#[test]
fn non_finite_parameter_values_survive_the_wire() {
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let json = serde_json::to_string(&HostResponse::ParameterValue { value })
.expect("serialize ParameterValue");
match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
HostResponse::ParameterValue { value: back } => {
if value.is_nan() {
assert!(back.is_nan(), "NaN must survive the wire");
} else {
assert_eq!(back, value);
}
}
other => panic!("round-trip changed the variant: {other:?}"),
}
}
let json = serde_json::to_string(&HostResponse::ParameterValue { value: 0.25 })
.expect("serialize");
assert!(
json.contains("0.25") && !json.contains("\"0.25\""),
"finite values stay JSON numbers, got {json}"
);
let cmd = HostCommand::SetParameter {
id: 3,
value: f64::NAN,
};
let json = serde_json::to_string(&cmd).expect("serialize SetParameter");
match serde_json::from_str::<HostCommand>(&json).expect("deserialize") {
HostCommand::SetParameter { id, value } => {
assert_eq!(id, 3);
assert!(value.is_nan());
}
other => panic!("SetParameter round-trip changed the variant: {other:?}"),
}
}
#[test]
fn audio_codec_round_trips_and_shrinks_the_payload() {
for len in 0..9usize {
let samples: Vec<f32> = (0..len).map(|i| i as f32 * -0.3125).collect();
let encoded = audio_codec::encode_channel(&samples);
let decoded = audio_codec::decode_channel(&encoded).expect("decode");
assert_eq!(decoded, samples, "round-trip failed at len {len}");
}
assert!(audio_codec::decode_channel("!!!!").is_none());
assert!(audio_codec::decode_channel("AAA").is_none(), "bad length");
assert!(
audio_codec::decode_channel("AAAA").is_none(),
"3 bytes is not a whole f32"
);
let block: Vec<Vec<f32>> = (0..2)
.map(|c| {
(0..512)
.map(|i| ((i * 7 + c) as f32 / 512.0).sin())
.collect()
})
.collect();
let plain = serde_json::to_string(&block).expect("plain json").len();
let encoded = serde_json::to_string(&HostResponse::AudioOutput {
outputs: block,
output_events: Vec::new(),
})
.expect("encoded json")
.len();
assert!(
encoded < plain,
"base64 payload ({encoded}) should be smaller than the number array ({plain})"
);
}
#[test]
fn wire_provided_counts_are_clamped_on_receipt() {
let json = r#"{"PluginInfo":{"vendor":"v","name":"n","version":"1","category":"",
"uid":"u","has_gui":false,"audio_inputs":-4,"audio_outputs":999999,
"output_channels":2000000,"has_midi_input":true,"has_midi_output":false}}"#;
match serde_json::from_str::<HostResponse>(json).expect("deserialize PluginInfo") {
HostResponse::PluginInfo {
audio_inputs,
audio_outputs,
output_channels,
..
} => {
assert_eq!(audio_inputs, 0);
assert_eq!(audio_outputs, MAX_WIRE_BUSES);
assert_eq!(output_channels, MAX_WIRE_CHANNELS as i32);
}
other => panic!("PluginInfo round-trip changed the variant: {other:?}"),
}
let channels: Vec<String> = (0..MAX_WIRE_CHANNELS + 5)
.map(|_| audio_codec::encode_channel(&[0.0]))
.collect();
let json = serde_json::to_string(&serde_json::json!({
"AudioOutput": { "outputs": channels, "output_events": [] }
}))
.expect("serialize");
match serde_json::from_str::<HostResponse>(&json).expect("deserialize") {
HostResponse::AudioOutput { outputs, .. } => {
assert_eq!(outputs.len(), MAX_WIRE_CHANNELS)
}
other => panic!("AudioOutput round-trip changed the variant: {other:?}"),
}
}
#[test]
fn oversized_lines_are_discarded_rather_than_buffered() {
let mut input: Vec<u8> = Vec::new();
input.extend_from_slice(b"short\n");
input.extend_from_slice(&[b'x'; 64]);
input.push(b'\n');
input.extend_from_slice(b"ok\n");
let mut reader = std::io::BufReader::new(std::io::Cursor::new(input));
assert!(matches!(read_bounded_line(&mut reader, 8), ReadLine::Line(l) if l == b"short\n"));
assert!(matches!(
read_bounded_line(&mut reader, 8),
ReadLine::Oversized
));
assert!(matches!(read_bounded_line(&mut reader, 8), ReadLine::Line(l) if l == b"ok\n"));
assert!(matches!(read_bounded_line(&mut reader, 8), ReadLine::Eof));
}
#[test]
fn slow_commands_are_classified_apart_from_the_per_block_ones() {
assert!(is_slow_command(&HostCommand::SaveState));
assert!(is_slow_command(&HostCommand::LoadState {
data: vec![],
context: crate::plugin::StateContext::Project,
}));
assert!(is_slow_command(&HostCommand::LoadPlugin {
path: "x".into(),
sample_rate: 44100.0,
block_size: 512,
tempo: 120.0,
time_sig_numerator: 4,
time_sig_denominator: 4,
class_id: None,
}));
assert!(!is_slow_command(&HostCommand::Process {
inputs: vec![],
frames: 64
}));
assert!(!is_slow_command(&HostCommand::GetAllParameters));
}
#[cfg(unix)]
#[test]
fn hung_helper_times_out_and_is_killed_not_blocking() {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};
let dir = std::env::temp_dir().join(format!("vst3_hang_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fake = dir.join("hung-helper");
let mut f = std::fs::File::create(&fake).unwrap();
writeln!(f, "#!/bin/sh\nexec sleep 30").unwrap();
drop(f);
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut proc =
PluginHostProcess::spawn(fake.clone(), Duration::from_millis(200)).expect("spawn");
let started = Instant::now();
let res = proc.send_command(HostCommand::Shutdown);
let elapsed = started.elapsed();
assert!(
res.is_err(),
"a hung helper must yield an error, got {res:?}"
);
assert!(
elapsed < Duration::from_secs(3),
"send_command must return promptly on timeout, took {elapsed:?}"
);
assert!(proc.send_command(HostCommand::Shutdown).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
struct FlakyLoadHelper {
dir: std::path::PathBuf,
script: std::path::PathBuf,
attempts: std::path::PathBuf,
}
#[cfg(unix)]
impl FlakyLoadHelper {
fn new(name: &str, crashing_loads: u32) -> Self {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!(
"vst3_flaky_{name}_{}_{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let script = dir.join("flaky-helper");
let attempts = dir.join("attempts");
let mut f = std::fs::File::create(&script).expect("create script");
write!(
f,
"#!/bin/sh\n\
while IFS= read -r line; do\n\
\x20 case \"$line\" in\n\
\x20 *LoadPlugin*)\n\
\x20 n=$(cat '{attempts}' 2>/dev/null || echo 0)\n\
\x20 n=$((n+1))\n\
\x20 printf '%s' \"$n\" > '{attempts}'\n\
\x20 if [ \"$n\" -le {crashing_loads} ]; then exit 3; fi\n\
\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\
\x20 *) exit 3 ;;\n\
\x20 esac\n\
done\n",
attempts = attempts.display(),
crashing_loads = crashing_loads,
)
.expect("write script");
drop(f);
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))
.expect("chmod");
Self {
dir,
script,
attempts,
}
}
fn load_attempts(&self) -> u32 {
std::fs::read_to_string(&self.attempts)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0)
}
fn load_command() -> HostCommand {
HostCommand::LoadPlugin {
path: "/tmp/flaky.vst3".to_string(),
sample_rate: 44100.0,
block_size: 512,
tempo: 120.0,
time_sig_numerator: 4,
time_sig_denominator: 4,
class_id: None,
}
}
}
#[cfg(unix)]
impl Drop for FlakyLoadHelper {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[cfg(unix)]
#[test]
fn a_load_that_kills_the_helper_is_replayed_against_a_fresh_one() {
let fake = FlakyLoadHelper::new("recovers", 1);
let mut proc = PluginHostProcess::spawn(fake.script.clone(), Duration::from_secs(5))
.expect("spawn flaky helper");
let first_pid = proc.helper_pid().expect("helper pid");
let response = proc
.send_command(FlakyLoadHelper::load_command())
.expect("a crashed load must be retried, not reported");
assert!(matches!(response, HostResponse::PluginInfo { .. }));
assert_eq!(fake.load_attempts(), 2, "the load should be tried twice");
assert_ne!(
proc.helper_pid().expect("helper pid after retry"),
first_pid,
"the retry must run against a freshly spawned helper"
);
assert!(proc.is_alive(), "the handle must be usable after the retry");
}
#[cfg(unix)]
#[test]
fn a_load_that_always_crashes_gives_up_after_one_retry() {
let fake = FlakyLoadHelper::new("always", 99);
let mut proc = PluginHostProcess::spawn(fake.script.clone(), Duration::from_secs(5))
.expect("spawn flaky helper");
let error = proc
.send_command(FlakyLoadHelper::load_command())
.expect_err("a load that always crashes must still fail");
assert!(
error.to_lowercase().contains("crash") || error.to_lowercase().contains("exited"),
"the reported failure must still read as a crash, got {error}"
);
assert_eq!(
fake.load_attempts(),
1 + LOAD_CRASH_RETRIES,
"exactly one retry, no more"
);
}
#[cfg(unix)]
#[test]
fn a_crash_on_any_other_command_is_reported_not_retried() {
let fake = FlakyLoadHelper::new("other", 0);
let mut proc = PluginHostProcess::spawn(fake.script.clone(), Duration::from_secs(5))
.expect("spawn flaky helper");
let pid = proc.helper_pid().expect("helper pid");
assert!(
proc.send_command(HostCommand::GetAllParameters).is_err(),
"a helper that dies mid-command must surface as an error"
);
assert_eq!(
proc.helper_pid(),
Some(pid),
"no other command may respawn the helper"
);
assert!(!proc.is_alive());
}
}
pub mod crash_protection {
use std::panic::catch_unwind;
use std::panic::UnwindSafe;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq)]
pub enum PluginStatus {
Ok,
Crashed(String),
Timeout(Duration),
}
pub fn protected_call<F, R>(f: F) -> Result<R, String>
where
F: FnOnce() -> R + UnwindSafe,
{
catch_unwind(f).map_err(|e| {
if let Some(s) = e.downcast_ref::<&str>() {
format!("Plugin panicked: {}", s)
} else if let Some(s) = e.downcast_ref::<String>() {
format!("Plugin panicked: {}", s)
} else {
"Plugin panicked with unknown error".to_string()
}
})
}
}