Skip to main content

sound_to_patch/render/
plugin.rs

1//! The renderer that plays a job through a real plug-in.
2//!
3//! The plug-in runs in a child process, `sound-to-patch-host`, so a crash
4//! or a hang of the plug-in never takes this process down, and the host can
5//! run as another architecture. The two talk the [`super::wire`] protocol.
6//!
7//! The host picks its architecture from the plug-in bundle: a bundle with a
8//! slice for this process's architecture loads directly; a bundle with only
9//! an `x86_64` slice loads in an `x86_64` host under Rosetta on Apple
10//! silicon.
11//! Rendering through a plug-in runs on macOS only.
12
13use std::collections::VecDeque;
14use std::fs::File;
15use std::io::{self, BufRead, BufReader, Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::{Child, ChildStdin, Command, Stdio};
18use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
19use std::sync::{Arc, Mutex};
20use std::thread::{self, JoinHandle};
21use std::time::{Duration, Instant};
22
23use base64::Engine as _;
24use base64::engine::general_purpose::STANDARD;
25
26use super::wire::{self, PROTOCOL_VERSION, Reply, Request, WireControl, WireControlKind, WireNote};
27use super::{
28    ClipEnd, ControlKind, HostIdentity, RECIPE_VERSION, RenderError, RenderJob, RenderRecord,
29    Rendered, Renderer,
30};
31use crate::audio::decode;
32use crate::synth::Synth;
33use crate::units::Seconds;
34
35/// The environment variable that names the host binary.
36pub const HOST_ENV: &str = "SOUND_TO_PATCH_HOST";
37/// The file name of the host binary.
38pub const HOST_NAME: &str = "sound-to-patch-host";
39/// How many bytes of an executable [`mach_o_archs`] needs: the header of
40/// a universal binary with up to 16 slices fits with room to spare.
41const HEADER_BYTES: u64 = 4096;
42/// How many lines of the host's output an error message quotes.
43const TAIL_LINES: usize = 20;
44/// How many bytes of one output line an error message quotes.
45const TAIL_LINE_BYTES: usize = 1000;
46/// The longest line of plug-in output the renderer reads. The rest of a
47/// longer line is skipped, so output without line ends cannot grow memory
48/// without bound. A reply line is read whole, because a saved state can be
49/// many megabytes long.
50const MAX_LINE: u64 = 64 * 1024;
51/// The Rosetta runtime that runs `x86_64` code on Apple silicon.
52const ROSETTA: &str = "/Library/Apple/usr/libexec/oah/libRosettaRuntime";
53
54/// A CPU architecture of a Mach-O binary.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum Arch {
57    /// 64-bit Intel.
58    X86_64,
59    /// 64-bit ARM, Apple silicon.
60    Arm64,
61}
62
63impl Arch {
64    const CPU_X86_64: u32 = 0x0100_0007;
65    const CPU_ARM64: u32 = 0x0100_000c;
66
67    fn of_cpu(cpu: u32) -> Option<Self> {
68        match cpu {
69            Self::CPU_X86_64 => Some(Self::X86_64),
70            Self::CPU_ARM64 => Some(Self::Arm64),
71            _ => None,
72        }
73    }
74
75    /// The architecture of this process.
76    #[must_use]
77    pub fn current() -> Option<Self> {
78        match std::env::consts::ARCH {
79            "x86_64" => Some(Self::X86_64),
80            "aarch64" => Some(Self::Arm64),
81            _ => None,
82        }
83    }
84
85    /// The name `arch` and `lipo` use.
86    #[must_use]
87    pub fn name(self) -> &'static str {
88        match self {
89            Self::X86_64 => "x86_64",
90            Self::Arm64 => "arm64",
91        }
92    }
93}
94
95/// Reads the architectures of a Mach-O binary from its header: a thin
96/// binary has one, a universal binary lists each slice. Unknown CPU types
97/// are left out; a file that is no Mach-O binary gives none.
98#[must_use]
99pub fn mach_o_archs(bytes: &[u8]) -> Vec<Arch> {
100    let be = |at: usize| {
101        bytes
102            .get(at..at + 4)
103            .and_then(|b| b.try_into().ok())
104            .map(u32::from_be_bytes)
105    };
106    let le = |at: usize| {
107        bytes
108            .get(at..at + 4)
109            .and_then(|b| b.try_into().ok())
110            .map(u32::from_le_bytes)
111    };
112    match be(0) {
113        // A universal binary: 20-byte slice entries, or 32 bytes in the
114        // 64-bit form, each starting with the CPU type.
115        Some(magic @ (0xcafe_babe | 0xcafe_babf)) => {
116            let entry = if magic == 0xcafe_babe { 20 } else { 32 };
117            let count = be(4).map_or(0, |n| usize::try_from(n).unwrap_or(0));
118            (0..count.min(16))
119                .filter_map(|i| be(8 + i * entry).and_then(Arch::of_cpu))
120                .collect()
121        }
122        // A thin 64-bit binary, little-endian.
123        Some(0xcffa_edfe) => le(4).and_then(Arch::of_cpu).into_iter().collect(),
124        _ => Vec::new(),
125    }
126}
127
128/// The executable inside a plug-in bundle: `Contents/MacOS/<name>`.
129///
130/// `<name>` is the `CFBundleExecutable` of the bundle's XML `Info.plist`.
131/// A binary `Info.plist` gives the bundle's name without `.vst3`. A path
132/// that is a file is its own executable.
133///
134/// # Errors
135///
136/// Returns [`RenderError::Host`] when `plugin` is a folder without
137/// `Contents/Info.plist` or without the executable the bundle names, and
138/// when its `Info.plist` cannot be read.
139pub fn bundle_executable(plugin: &Path) -> Result<PathBuf, RenderError> {
140    if plugin.is_file() {
141        return Ok(plugin.to_owned());
142    }
143    let not_a_bundle = |what: String| {
144        host_error(
145            format!("{} is no plug-in bundle: {what}", plugin.display()),
146            "Pass --plugin with the path of the plug-in's .vst3 bundle",
147        )
148    };
149    let plist = plugin.join("Contents").join("Info.plist");
150    let info = std::fs::read(&plist).map_err(|error| {
151        // A dead link reads as not found too, but the bundle is there.
152        if error.kind() == io::ErrorKind::NotFound && plist.symlink_metadata().is_err() {
153            not_a_bundle("it has no Contents/Info.plist".to_owned())
154        } else {
155            host_error(
156                format!("cannot read {} ({error})", plist.display()),
157                "Check that it is a readable file; reinstall the plug-in if it is not",
158            )
159        }
160    })?;
161    let name = plist_executable(&String::from_utf8_lossy(&info))
162        .or_else(|| {
163            plugin
164                .file_stem()
165                .map(|stem| stem.to_string_lossy().into_owned())
166        })
167        .unwrap_or_default();
168    let executable = plugin.join("Contents").join("MacOS").join(&name);
169    if name.is_empty() || name.contains('/') || !executable.is_file() {
170        return Err(not_a_bundle(format!(
171            "it has no executable Contents/MacOS/{name}"
172        )));
173    }
174    Ok(executable)
175}
176
177/// The `CFBundleExecutable` string of an XML property list.
178fn plist_executable(plist: &str) -> Option<String> {
179    const KEY: &str = "<key>CFBundleExecutable</key>";
180    let after = &plist[plist.find(KEY)? + KEY.len()..];
181    let value = after.trim_start().strip_prefix("<string>")?;
182    let name = value[..value.find("</string>")?].trim();
183    (!name.is_empty()).then(|| name.to_owned())
184}
185
186/// Reads the first [`HEADER_BYTES`] of a file.
187fn header(path: &Path) -> io::Result<Vec<u8>> {
188    let mut bytes = Vec::new();
189    File::open(path)?
190        .take(HEADER_BYTES)
191        .read_to_end(&mut bytes)?;
192    Ok(bytes)
193}
194
195/// The standard VST3 bundle of `synth`: the system folder first, then the
196/// user's.
197#[must_use]
198pub fn find_plugin(synth: Synth) -> Option<PathBuf> {
199    let bundle = match synth {
200        Synth::Vital => "Vital.vst3",
201        Synth::Serum2 => "Serum2.vst3",
202    };
203    let system = PathBuf::from("/Library/Audio/Plug-Ins/VST3");
204    let user = std::env::var_os("HOME")
205        .map(|home| PathBuf::from(home).join("Library/Audio/Plug-Ins/VST3"));
206    std::iter::once(system)
207        .chain(user)
208        .map(|dir| dir.join(bundle))
209        .find(|path| path.exists())
210}
211
212/// The host binary: the path in [`HOST_ENV`], else the file next to this
213/// program.
214#[must_use]
215pub fn host_binary() -> Option<PathBuf> {
216    std::env::var_os(HOST_ENV).map(PathBuf::from).or_else(|| {
217        std::env::current_exe()
218            .ok()
219            .and_then(|exe| exe.parent().map(|dir| dir.join(HOST_NAME)))
220    })
221}
222
223fn host_error(cause: impl Into<String>, fix: impl Into<String>) -> RenderError {
224    RenderError::Host {
225        cause: cause.into(),
226        fix: fix.into(),
227    }
228}
229
230/// How to start the host for a plug-in: directly, or under `arch` as
231/// another architecture.
232#[derive(Clone, Copy, Debug, PartialEq, Eq)]
233pub enum Launch {
234    /// Start the host as it is.
235    Direct,
236    /// Start the host through `/usr/bin/arch -<arch>`.
237    As(Arch),
238}
239
240/// Picks how to start the host. `rosetta` says whether this machine can
241/// run `x86_64` code on Apple silicon.
242///
243/// # Errors
244///
245/// Returns [`RenderError::Host`] when the plug-in has no slice this
246/// machine runs, the host binary lacks the slice the plug-in needs, or
247/// Rosetta is missing.
248pub fn pick_launch(
249    plugin: &[Arch],
250    host: &[Arch],
251    current: Arch,
252    rosetta: bool,
253) -> Result<Launch, RenderError> {
254    if plugin.contains(&current) {
255        return Ok(Launch::Direct);
256    }
257    if current == Arch::Arm64 && plugin.contains(&Arch::X86_64) {
258        if !host.contains(&Arch::X86_64) {
259            return Err(host_error(
260                "the plug-in is x86_64 only, and the host binary has no x86_64 slice",
261                "Use the universal sound-to-patch-host from the release archive",
262            ));
263        }
264        if !rosetta {
265            return Err(host_error(
266                "the plug-in is x86_64 only, and Rosetta is not installed",
267                "Install Rosetta with `softwareupdate --install-rosetta`, then render again",
268            ));
269        }
270        return Ok(Launch::As(Arch::X86_64));
271    }
272    let names: Vec<&str> = plugin.iter().map(|arch| arch.name()).collect();
273    Err(host_error(
274        format!(
275            "the plug-in holds code for [{}], and this machine runs {}",
276            names.join(", "),
277            current.name()
278        ),
279        "Install a build of the plug-in for this machine",
280    ))
281}
282
283/// The last lines a child wrote, kept for error messages.
284type Tail = Arc<Mutex<VecDeque<String>>>;
285
286fn keep(tail: &Tail, line: &str) {
287    if line.is_empty() {
288        return;
289    }
290    if let Ok(mut lines) = tail.lock() {
291        if lines.len() == TAIL_LINES {
292            lines.pop_front();
293        }
294        lines.push_back(cut(line).to_owned());
295    }
296}
297
298/// [`cut`] `text`, with `...` where it was cut.
299fn cut_marked(text: &str) -> String {
300    let quoted = cut(text);
301    let more = if quoted.len() < text.len() { "..." } else { "" };
302    format!("{quoted}{more}")
303}
304
305/// The first [`TAIL_LINE_BYTES`] of `text`, so a reply of several MB
306/// never lands whole in a message.
307fn cut(text: &str) -> &str {
308    let mut end = text.len().min(TAIL_LINE_BYTES);
309    while !text.is_char_boundary(end) {
310        end -= 1;
311    }
312    &text[..end]
313}
314
315fn quote(tail: &Tail) -> String {
316    let lines = tail
317        .lock()
318        .map(|lines| lines.iter().cloned().collect::<Vec<_>>().join(" | "))
319        .unwrap_or_default();
320    if lines.is_empty() {
321        "none".to_owned()
322    } else {
323        lines
324    }
325}
326
327/// How long the renderer waits for the host.
328#[derive(Clone, Copy, Debug)]
329struct Timeouts {
330    /// Loading the plug-in, or answering a short request.
331    open: Duration,
332    /// A render, on top of the clip's own length.
333    render: Duration,
334    /// Exiting after its output closed or after `quit`.
335    exit: Duration,
336}
337
338impl Timeouts {
339    const DEFAULT: Self = Self {
340        open: Duration::from_secs(60),
341        render: Duration::from_secs(120),
342        exit: Duration::from_secs(5),
343    };
344}
345
346/// What arrives on the reply channel: a reply, or a reply line this
347/// program cannot read.
348type Incoming = Result<Reply, String>;
349
350/// Plays jobs through one plug-in instance in a host process.
351#[derive(Debug)]
352pub struct PluginRenderer {
353    child: Child,
354    stdin: ChildStdin,
355    replies: Receiver<Incoming>,
356    tail: Tail,
357    readers: Vec<JoinHandle<()>>,
358    identity: HostIdentity,
359    renders: u64,
360    loaded: bool,
361    last_state_load: Option<Seconds>,
362    timeouts: Timeouts,
363    /// A private folder for the clips the host writes.
364    scratch: tempfile::TempDir,
365}
366
367impl PluginRenderer {
368    /// Starts the host at `host` and loads the plug-in at `plugin` as
369    /// `synth`.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`RenderError::Host`] when the platform, the plug-in or the
374    /// host binary cannot render, and the host's own errors.
375    pub fn start(synth: Synth, plugin: &Path, host: &Path) -> Result<Self, RenderError> {
376        if !plugin.exists() {
377            return Err(host_error(
378                format!("there is no plug-in at {}", plugin.display()),
379                "Pass --plugin with the path of the plug-in's .vst3 bundle",
380            ));
381        }
382        if !cfg!(target_os = "macos") {
383            return Err(host_error(
384                "rendering through a plug-in runs on macOS only",
385                "Render on a Mac",
386            ));
387        }
388        // `arch` takes no `--`, so an absolute path keeps a host path that
389        // starts with `-` from reading as an option.
390        let host = std::path::absolute(host).map_err(|source| {
391            host_error(
392                format!("cannot resolve the host path {} ({source})", host.display()),
393                format!("Set {HOST_ENV} to the path of sound-to-patch-host"),
394            )
395        })?;
396        let read = |path: &Path, what: &str| {
397            header(path).map_err(|source| {
398                host_error(
399                    format!("cannot read the {what} at {} ({source})", path.display()),
400                    format!("Check the path of the {what}"),
401                )
402            })
403        };
404        let executable = bundle_executable(plugin)?;
405        let plugin_archs = mach_o_archs(&read(&executable, "plug-in")?);
406        if plugin_archs.is_empty() {
407            return Err(host_error(
408                format!(
409                    "{} is no Mach-O binary for x86_64 or arm64",
410                    executable.display()
411                ),
412                "Pass --plugin with the path of the plug-in's .vst3 bundle",
413            ));
414        }
415        let host_archs = mach_o_archs(&read(&host, "host binary")?);
416        if host_archs.is_empty() {
417            return Err(host_error(
418                format!(
419                    "the host binary {} is no Mach-O binary for x86_64 or arm64",
420                    host.display()
421                ),
422                format!("Set {HOST_ENV} to the sound-to-patch-host of the release archive"),
423            ));
424        }
425        let current = Arch::current().ok_or_else(|| {
426            host_error("this machine's architecture is unknown", "Render on a Mac")
427        })?;
428        let launch = pick_launch(
429            &plugin_archs,
430            &host_archs,
431            current,
432            Path::new(ROSETTA).exists(),
433        )?;
434        let command = match launch {
435            Launch::Direct => Command::new(&host),
436            Launch::As(arch) => {
437                let mut command = Command::new("/usr/bin/arch");
438                command.arg(format!("-{}", arch.name())).arg(&host);
439                command
440            }
441        };
442        Self::spawn(command, synth, plugin, Timeouts::DEFAULT)
443    }
444
445    /// Runs `command` as the host and loads the plug-in.
446    fn spawn(
447        mut command: Command,
448        synth: Synth,
449        plugin: &Path,
450        timeouts: Timeouts,
451    ) -> Result<Self, RenderError> {
452        let mut scratch = tempfile::Builder::new();
453        scratch.prefix("sound-to-patch-render-");
454        // Only this user may reach the clips, so no one can plant a link
455        // where the host writes.
456        #[cfg(unix)]
457        scratch.permissions(std::os::unix::fs::PermissionsExt::from_mode(0o700));
458        let scratch = scratch.tempdir().map_err(|source| {
459            host_error(
460                format!("cannot make a folder for the rendered clips ({source})"),
461                "Check that the temporary folder is writable",
462            )
463        })?;
464        let mut child = command
465            .stdin(Stdio::piped())
466            .stdout(Stdio::piped())
467            .stderr(Stdio::piped())
468            .spawn()
469            .map_err(|source| {
470                host_error(
471                    format!("cannot start {command:?} ({source})"),
472                    format!("Check that the host binary runs, or set {HOST_ENV} to its path"),
473                )
474            })?;
475        let (stdin, replies, tail, readers) = connect(&mut child)?;
476        let mut renderer = Self {
477            child,
478            stdin,
479            replies,
480            tail,
481            readers,
482            identity: HostIdentity {
483                synth,
484                build: String::new(),
485                host: String::new(),
486                arch: String::new(),
487            },
488            renders: 0,
489            loaded: false,
490            last_state_load: None,
491            timeouts,
492            scratch,
493        };
494        let open = Request::Open {
495            synth,
496            plugin: plugin.to_owned(),
497        };
498        match renderer.ask(&open, renderer.timeouts.open)? {
499            Reply::Opened {
500                build,
501                host,
502                arch,
503                protocol,
504                recipe_version,
505                ..
506            } => {
507                if protocol != PROTOCOL_VERSION {
508                    return Err(another_release(format!(
509                        "the host speaks protocol {protocol}, and this program speaks {PROTOCOL_VERSION}"
510                    )));
511                }
512                if recipe_version != RECIPE_VERSION {
513                    return Err(another_release(format!(
514                        "the host renders with recipe version {recipe_version}, and this program uses {RECIPE_VERSION}"
515                    )));
516                }
517                renderer.identity.build = build;
518                renderer.identity.host = host;
519                renderer.identity.arch = arch;
520                Ok(renderer)
521            }
522            other => Err(unexpected(&other)),
523        }
524    }
525
526    /// Sends a request and waits for its reply.
527    fn ask(&mut self, request: &Request, timeout: Duration) -> Result<Reply, RenderError> {
528        let line = serde_json::to_string(request).map_err(|source| {
529            RenderError::Internal(format!("cannot encode a request ({source})"))
530        })?;
531        if writeln!(self.stdin, "{line}")
532            .and_then(|()| self.stdin.flush())
533            .is_err()
534        {
535            return Err(self.crashed());
536        }
537        match self.replies.recv_timeout(timeout) {
538            Ok(Ok(Reply::Failed { cause, fix })) => Err(RenderError::Host { cause, fix }),
539            Ok(Ok(reply)) => Ok(reply),
540            Ok(Err(line)) => {
541                self.stop();
542                Err(another_release(format!(
543                    "the host sent a reply this program cannot read: {line}"
544                )))
545            }
546            Err(RecvTimeoutError::Timeout) => {
547                self.stop();
548                Err(RenderError::TimedOut {
549                    after: Seconds::from_const(timeout.as_secs_f64()),
550                    output: quote(&self.tail),
551                })
552            }
553            Err(RecvTimeoutError::Disconnected) => Err(self.crashed()),
554        }
555    }
556
557    /// The error of a host whose reply pipe closed. A host that does not
558    /// exit soon after is stopped, so this never waits on a live process.
559    fn crashed(&mut self) -> RenderError {
560        let cause = match self.reap() {
561            Some(status) => format!("it exited with {status}"),
562            None => "it closed its reply pipe and did not exit, so it was stopped".to_owned(),
563        };
564        RenderError::Crashed(format!("{cause}; its last output: {}", quote(&self.tail)))
565    }
566
567    /// Waits up to the exit timeout for the host to exit, then stops it.
568    /// Returns the exit status, or `None` when it had to be stopped.
569    fn reap(&mut self) -> Option<String> {
570        let deadline = Instant::now() + self.timeouts.exit;
571        let status = loop {
572            match self.child.try_wait() {
573                Ok(Some(status)) => break Some(status.to_string()),
574                Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
575                _ => break None,
576            }
577        };
578        if status.is_none() {
579            self.stop();
580        }
581        self.join_readers();
582        status
583    }
584
585    /// Kills the host and waits for it.
586    fn stop(&mut self) {
587        let _ = self.child.kill();
588        let _ = self.child.wait();
589        self.join_readers();
590    }
591
592    /// Waits up to one second for the pipe readers to finish, so the tail
593    /// holds the host's last lines. A reader whose pipe a stray process
594    /// still holds open is left behind.
595    fn join_readers(&mut self) {
596        let deadline = Instant::now() + Duration::from_secs(1);
597        while self.readers.iter().any(|reader| !reader.is_finished()) && Instant::now() < deadline {
598            thread::sleep(Duration::from_millis(5));
599        }
600        let (done, running) = std::mem::take(&mut self.readers)
601            .into_iter()
602            .partition(JoinHandle::is_finished);
603        self.readers = running;
604        for reader in done {
605            let _ = reader.join();
606        }
607    }
608}
609
610fn unexpected(reply: &Reply) -> RenderError {
611    RenderError::Crashed(format!(
612        "it gave the reply {} out of turn",
613        cut(&format!("{reply:?}"))
614    ))
615}
616
617fn another_release(cause: String) -> RenderError {
618    host_error(
619        cause,
620        "Install the host binary of the same release as this program",
621    )
622}
623
624/// Reads a pipe line by line until it closes. Bytes that are not UTF-8
625/// are replaced, so plug-in output of any kind never stops the reader. A
626/// line stops at [`MAX_LINE`] bytes unless `whole` accepts its start.
627/// `line` gets each line and whether a line end closed it: the last line
628/// of a pipe that closed in the middle of a line has none.
629fn each_line(
630    pipe: impl Read,
631    whole: impl Fn(&[u8]) -> bool,
632    mut line: impl FnMut(&str, bool) -> bool,
633) {
634    let mut reader = BufReader::new(pipe);
635    let mut bytes = Vec::new();
636    loop {
637        bytes.clear();
638        match read_line(&mut reader, &mut bytes, &whole) {
639            Ok(0) | Err(_) => break,
640            Ok(_) => {
641                let text = String::from_utf8_lossy(&bytes);
642                let ended = bytes.last() == Some(&b'\n');
643                if !line(text.trim_end_matches(['\n', '\r']), ended) {
644                    break;
645                }
646            }
647        }
648    }
649}
650
651/// Reads one line of at most [`MAX_LINE`] bytes and skips the rest of a
652/// longer one, unless `whole` accepts the first [`MAX_LINE`] bytes.
653fn read_line(
654    reader: &mut impl BufRead,
655    bytes: &mut Vec<u8>,
656    whole: impl Fn(&[u8]) -> bool,
657) -> io::Result<usize> {
658    let read = reader.by_ref().take(MAX_LINE).read_until(b'\n', bytes)?;
659    if bytes.last() != Some(&b'\n') && crate::cast::usize_to_u64(read) == MAX_LINE {
660        if whole(bytes) {
661            reader.read_until(b'\n', bytes)?;
662        } else {
663            reader.skip_until(b'\n')?;
664        }
665    }
666    Ok(read)
667}
668
669/// Whether a line starts a reply, which may be longer than [`MAX_LINE`].
670fn holds_reply(bytes: &[u8]) -> bool {
671    let prefix = wire::REPLY_PREFIX.as_bytes();
672    bytes.windows(prefix.len()).any(|window| window == prefix)
673}
674
675fn read_replies(stdout: impl Read, send: &Sender<Incoming>, tail: &Tail) {
676    each_line(stdout, holds_reply, |line, ended| {
677        let Some((output, reply)) = wire::parse_reply(line) else {
678            keep(tail, line);
679            return true;
680        };
681        keep(tail, output);
682        if !ended {
683            // A reply without its line end is a host that died while it
684            // wrote. Dropping it closes the channel, so the caller reports
685            // the crash with the exit status, not a protocol fault.
686            keep(
687                tail,
688                &format!("(a reply cut off after {} bytes)", line.len()),
689            );
690            return false;
691        }
692        // The quote and the cause are cut apart, so a long reply keeps its
693        // parse error, and a parse error that echoes a long value stays
694        // bounded too.
695        let incoming = reply.map_err(|error| {
696            let error = error.to_string();
697            format!("{} ({})", cut_marked(line), cut_marked(&error))
698        });
699        send.send(incoming).is_ok()
700    });
701}
702
703/// The ends of a child's pipes: its input, the reply channel, the tail and
704/// the threads that read its output.
705type Pipes = (ChildStdin, Receiver<Incoming>, Tail, Vec<JoinHandle<()>>);
706
707/// Wires up the child's pipes: requests go to its input, reply lines come
708/// back on a channel, and every other line lands in the tail.
709fn connect(child: &mut Child) -> Result<Pipes, RenderError> {
710    let missing = || RenderError::Crashed("its pipes did not open".to_owned());
711    let stdin = child.stdin.take().ok_or_else(missing)?;
712    let stdout = child.stdout.take().ok_or_else(missing)?;
713    let stderr = child.stderr.take().ok_or_else(missing)?;
714    let tail = Tail::default();
715    let (send, replies) = mpsc::channel();
716    let out_tail = Arc::clone(&tail);
717    let err_tail = Arc::clone(&tail);
718    let readers = vec![
719        thread::spawn(move || read_replies(stdout, &send, &out_tail)),
720        thread::spawn(move || {
721            each_line(
722                stderr,
723                |_| false,
724                |line, _| {
725                    keep(&err_tail, line);
726                    true
727                },
728            );
729        }),
730    ];
731    Ok((stdin, replies, tail, readers))
732}
733
734impl PluginRenderer {
735    /// Renders the job's notes, with its state or with the plug-in's init
736    /// patch.
737    fn play(&mut self, job: &RenderJob, load: bool) -> Result<Rendered, RenderError> {
738        if job.state().synth() != self.identity.synth {
739            return Err(RenderError::WrongSynth {
740                state: job.state().synth(),
741                renderer: self.identity.synth,
742            });
743        }
744        self.renders += 1;
745        let audio = self
746            .scratch
747            .path()
748            .join(format!("clip-{}.wav", self.renders));
749        let request = Request::Render {
750            state: load.then(|| STANDARD.encode(job.state().bytes())),
751            notes: job
752                .notes()
753                .iter()
754                .map(|note| WireNote {
755                    key: note.key(),
756                    onset: note.onset().get(),
757                    offset: note.offset().get(),
758                    velocity: note.velocity().get(),
759                })
760                .collect(),
761            controls: job
762                .controls()
763                .iter()
764                .map(|control| WireControl {
765                    time: control.time.get(),
766                    kind: match control.kind {
767                        ControlKind::PitchBend(value) => WireControlKind::PitchBend { value },
768                        ControlKind::ModWheel(value) => WireControlKind::ModWheel { value },
769                    },
770                })
771                .collect(),
772            bpm: job.bpm().get(),
773            audio: audio.clone(),
774        };
775        self.loaded |= load;
776        let timeout = self.timeouts.render + Duration::from_secs_f64(job.last_note_off().get());
777        self.last_state_load = None;
778        let reply = self.ask(&request, timeout);
779        let read = reply.and_then(|reply| match reply {
780            Reply::Rendered {
781                frames,
782                capped,
783                state_load_s,
784            } => {
785                self.last_state_load = state_load_s.and_then(|s| Seconds::new(s).ok());
786                let decoded = decode(&audio).map_err(|source| {
787                    RenderError::Crashed(format!(
788                        "the host wrote a clip that does not decode ({source})"
789                    ))
790                })?;
791                Ok((decoded.into_audio(), ClipEnd { frames, capped }))
792            }
793            other => Err(unexpected(&other)),
794        });
795        let _ = std::fs::remove_file(&audio);
796        let (audio, end) = read?;
797        if audio.frames() != end.frames {
798            return Err(RenderError::Crashed(format!(
799                "the host reported {} frames and wrote {}",
800                end.frames,
801                audio.frames()
802            )));
803        }
804        Ok(Rendered {
805            audio,
806            record: RenderRecord::new(self.identity(), job, end),
807        })
808    }
809}
810
811impl Renderer for PluginRenderer {
812    fn identity(&self) -> HostIdentity {
813        self.identity.clone()
814    }
815
816    fn render(&mut self, job: &RenderJob) -> Result<Rendered, RenderError> {
817        self.play(job, true)
818    }
819
820    fn render_init(&mut self, job: &RenderJob) -> Result<Rendered, RenderError> {
821        if self.loaded {
822            return Err(host_error(
823                "the plug-in already holds a loaded state, so its init patch is gone",
824                "Render the init patch before the first render of a preset",
825            ));
826        }
827        self.play(job, false)
828    }
829
830    fn last_state_load(&self) -> Option<Seconds> {
831        self.last_state_load
832    }
833
834    fn save_state(&mut self) -> Result<Vec<u8>, RenderError> {
835        match self.ask(&Request::SaveState, self.timeouts.open)? {
836            Reply::Saved { state } => STANDARD.decode(state).map_err(|source| {
837                RenderError::Crashed(format!(
838                    "the host sent a state that is not base64 ({source})"
839                ))
840            }),
841            other => Err(unexpected(&other)),
842        }
843    }
844}
845
846impl Drop for PluginRenderer {
847    fn drop(&mut self) {
848        let _ = writeln!(self.stdin, r#"{{"op":"quit"}}"#);
849        let _ = self.stdin.flush();
850        let _ = self.replies.recv_timeout(self.timeouts.exit);
851        let _ = self.reap();
852    }
853}
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858
859    fn thin(cpu: u32) -> Vec<u8> {
860        let mut bytes = 0xfeed_facf_u32.to_le_bytes().to_vec();
861        bytes.extend_from_slice(&cpu.to_le_bytes());
862        bytes.extend_from_slice(&[0; 24]);
863        bytes
864    }
865
866    fn fat(cpus: &[u32]) -> Vec<u8> {
867        let mut bytes = 0xcafe_babe_u32.to_be_bytes().to_vec();
868        bytes.extend_from_slice(&u32::try_from(cpus.len()).unwrap().to_be_bytes());
869        for cpu in cpus {
870            bytes.extend_from_slice(&cpu.to_be_bytes());
871            bytes.extend_from_slice(&[0; 16]);
872        }
873        bytes
874    }
875
876    #[test]
877    fn the_header_names_each_slice() {
878        assert_eq!(mach_o_archs(&thin(Arch::CPU_X86_64)), [Arch::X86_64]);
879        assert_eq!(mach_o_archs(&thin(Arch::CPU_ARM64)), [Arch::Arm64]);
880        assert_eq!(
881            mach_o_archs(&fat(&[Arch::CPU_X86_64, Arch::CPU_ARM64])),
882            [Arch::X86_64, Arch::Arm64]
883        );
884        assert_eq!(mach_o_archs(&fat(&[7, Arch::CPU_ARM64])), [Arch::Arm64]);
885        assert_eq!(mach_o_archs(b"#!/bin/sh\n"), []);
886        assert_eq!(mach_o_archs(&[0xca, 0xfe]), []);
887    }
888
889    #[test]
890    fn a_native_slice_loads_directly() {
891        let both = [Arch::X86_64, Arch::Arm64];
892        assert_eq!(
893            pick_launch(&both, &[Arch::Arm64], Arch::Arm64, false).unwrap(),
894            Launch::Direct
895        );
896        assert_eq!(
897            pick_launch(&[Arch::X86_64], &[Arch::X86_64], Arch::X86_64, false).unwrap(),
898            Launch::Direct
899        );
900    }
901
902    #[test]
903    fn an_x86_64_plug_in_on_apple_silicon_needs_rosetta_and_a_universal_host() {
904        let plugin = [Arch::X86_64];
905        let universal = [Arch::X86_64, Arch::Arm64];
906        assert_eq!(
907            pick_launch(&plugin, &universal, Arch::Arm64, true).unwrap(),
908            Launch::As(Arch::X86_64)
909        );
910        let message = |result: Result<Launch, RenderError>| result.unwrap_err().to_string();
911        assert!(
912            message(pick_launch(&plugin, &[Arch::Arm64], Arch::Arm64, true))
913                .contains("universal sound-to-patch-host")
914        );
915        assert!(
916            message(pick_launch(&plugin, &universal, Arch::Arm64, false))
917                .contains("softwareupdate --install-rosetta")
918        );
919        assert!(
920            message(pick_launch(&[Arch::Arm64], &universal, Arch::X86_64, true))
921                .contains("[arm64]")
922        );
923    }
924
925    fn lines(input: &[u8]) -> Vec<String> {
926        let mut out = Vec::new();
927        each_line(input, holds_reply, |line, _| {
928            out.push(line.to_owned());
929            true
930        });
931        out
932    }
933
934    #[test]
935    fn a_long_line_of_output_is_cut_and_a_long_reply_is_read_whole() {
936        let long = usize::try_from(MAX_LINE).unwrap() * 3;
937        let mut flood = vec![b'x'; long];
938        flood.extend_from_slice(b"\nnext\n");
939        let read = lines(&flood);
940        assert_eq!(read.len(), 2);
941        assert_eq!(read[0].len(), usize::try_from(MAX_LINE).unwrap());
942        assert_eq!(read[1], "next");
943        let mut reply = wire::REPLY_PREFIX.as_bytes().to_vec();
944        reply.extend(std::iter::repeat_n(b'y', long));
945        reply.push(b'\n');
946        let read = lines(&reply);
947        assert_eq!(read.len(), 1);
948        assert_eq!(read[0].len(), wire::REPLY_PREFIX.len() + long);
949    }
950
951    #[test]
952    fn the_tail_keeps_the_start_of_a_long_line() {
953        let tail = Tail::default();
954        keep(&tail, &"é".repeat(TAIL_LINE_BYTES));
955        let quoted = quote(&tail);
956        assert!(quoted.len() <= TAIL_LINE_BYTES, "{}", quoted.len());
957        assert!(quoted.starts_with('é'));
958    }
959
960    fn bundle(plist: &str, executable: &str) -> tempfile::TempDir {
961        let dir = tempfile::tempdir().unwrap();
962        let contents = dir.path().join("Synth.vst3").join("Contents");
963        std::fs::create_dir_all(contents.join("MacOS")).unwrap();
964        std::fs::write(contents.join("Info.plist"), plist).unwrap();
965        std::fs::write(contents.join("MacOS").join(executable), b"").unwrap();
966        dir
967    }
968
969    #[test]
970    fn the_bundle_executable_is_the_one_its_plist_names() {
971        let plist = "<dict>\n\t<key>CFBundleExecutable</key>\n\t<string>Engine</string>\n</dict>";
972        let dir = bundle(plist, "Engine");
973        let plugin = dir.path().join("Synth.vst3");
974        assert_eq!(
975            bundle_executable(&plugin).unwrap(),
976            plugin.join("Contents/MacOS/Engine")
977        );
978        let dir = bundle("bplist00", "Synth");
979        let plugin = dir.path().join("Synth.vst3");
980        assert_eq!(
981            bundle_executable(&plugin).unwrap(),
982            plugin.join("Contents/MacOS/Synth")
983        );
984    }
985
986    #[test]
987    fn a_folder_that_is_no_bundle_is_named_as_such() {
988        let dir = tempfile::tempdir().unwrap();
989        let message = bundle_executable(dir.path()).unwrap_err().to_string();
990        assert!(message.contains("is no plug-in bundle: it has no Contents/Info.plist"));
991        assert!(message.contains("Pass --plugin"));
992        let dir = bundle(
993            "<key>CFBundleExecutable</key><string>Gone</string>",
994            "Other",
995        );
996        let message = bundle_executable(&dir.path().join("Synth.vst3"))
997            .unwrap_err()
998            .to_string();
999        assert!(message.contains("it has no executable Contents/MacOS/Gone"));
1000        let dir = bundle("<key>CFBundleExecutable</key><string>../x</string>", "x");
1001        assert!(bundle_executable(&dir.path().join("Synth.vst3")).is_err());
1002    }
1003
1004    #[test]
1005    fn a_reply_out_of_turn_is_quoted_in_bounded_length() {
1006        let reply = Reply::Saved {
1007            state: "a".repeat(64 * TAIL_LINE_BYTES),
1008        };
1009        let message = unexpected(&reply).to_string();
1010        assert!(message.len() < 2 * TAIL_LINE_BYTES, "{}", message.len());
1011        assert!(message.contains("Saved"), "{message}");
1012    }
1013
1014    #[test]
1015    fn an_info_plist_that_cannot_be_read_is_named_with_its_cause() {
1016        let dir = tempfile::tempdir().unwrap();
1017        let contents = dir.path().join("Synth.vst3").join("Contents");
1018        std::fs::create_dir_all(contents.join("Info.plist")).unwrap();
1019        let message = bundle_executable(&dir.path().join("Synth.vst3"))
1020            .unwrap_err()
1021            .to_string();
1022        assert!(message.contains("cannot read"), "{message}");
1023        assert!(
1024            !message.contains("it has no Contents/Info.plist"),
1025            "{message}"
1026        );
1027        assert!(!message.contains("Pass --plugin"), "{message}");
1028        #[cfg(unix)]
1029        {
1030            let dir = tempfile::tempdir().unwrap();
1031            let contents = dir.path().join("Synth.vst3").join("Contents");
1032            std::fs::create_dir_all(&contents).unwrap();
1033            std::os::unix::fs::symlink(dir.path().join("gone"), contents.join("Info.plist"))
1034                .unwrap();
1035            let message = bundle_executable(&dir.path().join("Synth.vst3"))
1036                .unwrap_err()
1037                .to_string();
1038            assert!(message.contains("cannot read"), "{message}");
1039            assert!(!message.contains("Pass --plugin"), "{message}");
1040        }
1041    }
1042
1043    /// A host played by a shell script, for the paths a real plug-in
1044    /// rarely takes. `$OPENED` holds a valid `opened` reply line.
1045    #[cfg(unix)]
1046    mod stub {
1047        use std::os::unix::fs::PermissionsExt as _;
1048
1049        use super::*;
1050        use crate::notes::Velocity;
1051        use crate::render::{PresetState, RenderNote};
1052        use crate::units::Bpm;
1053
1054        const SHORT: Timeouts = Timeouts {
1055            open: Duration::from_secs(5),
1056            render: Duration::from_millis(300),
1057            exit: Duration::from_millis(200),
1058        };
1059
1060        fn opened(protocol: u32, recipe: u32) -> String {
1061            format!(
1062                r#"{}{{"reply":"opened","name":"Vital","build":"1.0.7","host":"stub","arch":"x86_64","protocol":{protocol},"recipe_version":{recipe}}}"#,
1063                wire::REPLY_PREFIX
1064            )
1065        }
1066
1067        fn command(script: &str) -> Command {
1068            let mut command = Command::new("/bin/sh");
1069            command
1070                .arg("-c")
1071                .arg(script)
1072                .env("OPENED", opened(PROTOCOL_VERSION, RECIPE_VERSION))
1073                .env("PREFIX", wire::REPLY_PREFIX);
1074            command
1075        }
1076
1077        fn start(script: &str) -> Result<PluginRenderer, RenderError> {
1078            PluginRenderer::spawn(
1079                command(script),
1080                Synth::Vital,
1081                Path::new("Vital.vst3"),
1082                SHORT,
1083            )
1084        }
1085
1086        fn job() -> RenderJob {
1087            let note = RenderNote::new(
1088                60,
1089                Seconds::new(0.0).unwrap(),
1090                Seconds::new(0.1).unwrap(),
1091                Velocity::new(100).unwrap(),
1092            )
1093            .unwrap();
1094            RenderJob::new(
1095                PresetState::new(Synth::Vital, b"state".to_vec()),
1096                vec![note],
1097                Bpm::new(120.0).unwrap(),
1098            )
1099            .unwrap()
1100        }
1101
1102        const OPEN: &str = r#"read -r line; printf '%s\n' "$OPENED"; "#;
1103
1104        #[test]
1105        fn a_host_that_exits_is_a_crash_with_its_last_output() {
1106            let error = start("echo 'cannot load' >&2; exit 3").unwrap_err();
1107            let message = error.to_string();
1108            assert!(matches!(error, RenderError::Crashed(_)), "{message}");
1109            assert!(message.contains("exit status: 3"), "{message}");
1110            assert!(message.contains("cannot load"), "{message}");
1111        }
1112
1113        #[test]
1114        fn a_host_that_hangs_is_killed_and_its_output_quoted() {
1115            let mut renderer = start(&format!(
1116                "{OPEN}read -r line; echo 'stuck in a loop' >&2; exec sleep 30"
1117            ))
1118            .unwrap();
1119            let before = Instant::now();
1120            let error = renderer.render(&job()).unwrap_err();
1121            assert!(
1122                before.elapsed() < Duration::from_secs(3),
1123                "{:?}",
1124                before.elapsed()
1125            );
1126            let RenderError::TimedOut { output, .. } = &error else {
1127                panic!("{error}");
1128            };
1129            assert!(output.contains("stuck in a loop"), "{error}");
1130            assert!(renderer.child.try_wait().unwrap().is_some());
1131        }
1132
1133        #[test]
1134        fn output_that_is_not_utf8_or_has_no_line_end_keeps_the_reply() {
1135            let script = r#"read -r line; printf '\377\376 noise'; printf 'no line end'; printf '%s\n' "$OPENED"; read -r line; exit 4"#;
1136            let mut renderer = start(script).unwrap();
1137            let message = renderer.render(&job()).unwrap_err().to_string();
1138            assert!(message.contains("exit status: 4"), "{message}");
1139            assert!(message.contains("noise"), "{message}");
1140            assert!(message.contains("no line end"), "{message}");
1141        }
1142
1143        #[test]
1144        fn a_host_of_another_release_is_refused() {
1145            let other = |reply: String| {
1146                let mut command =
1147                    command(r#"read -r line; printf '%s\n' "$REPLY_LINE"; exec sleep 30"#);
1148                command.env("REPLY_LINE", reply);
1149                PluginRenderer::spawn(command, Synth::Vital, Path::new("Vital.vst3"), SHORT)
1150                    .unwrap_err()
1151                    .to_string()
1152            };
1153            let message = other(opened(PROTOCOL_VERSION, RECIPE_VERSION + 1));
1154            assert!(message.contains("recipe version"), "{message}");
1155            assert!(message.contains("same release"), "{message}");
1156            let message = other(opened(PROTOCOL_VERSION + 1, RECIPE_VERSION));
1157            assert!(message.contains("protocol"), "{message}");
1158            let message = other(format!(
1159                r#"{}{{"reply":"opened","name":"Vital"}}"#,
1160                wire::REPLY_PREFIX
1161            ));
1162            assert!(message.contains("cannot read"), "{message}");
1163            assert!(message.contains("same release"), "{message}");
1164        }
1165
1166        #[test]
1167        fn a_host_that_closes_its_pipes_and_lingers_is_stopped() {
1168            let mut renderer = start(&format!(
1169                "{OPEN}read -r line; exec 1>&- 2>&-; exec sleep 30"
1170            ))
1171            .unwrap();
1172            let before = Instant::now();
1173            let message = renderer.render(&job()).unwrap_err().to_string();
1174            assert!(
1175                before.elapsed() < Duration::from_secs(3),
1176                "{:?}",
1177                before.elapsed()
1178            );
1179            assert!(
1180                message.contains("did not exit, so it was stopped"),
1181                "{message}"
1182            );
1183        }
1184
1185        #[test]
1186        fn a_host_that_never_answers_quit_is_stopped() {
1187            let renderer = start(&format!("{OPEN}read -r line; exec sleep 30")).unwrap();
1188            let pid = renderer.child.id();
1189            let before = Instant::now();
1190            drop(renderer);
1191            assert!(
1192                before.elapsed() < Duration::from_secs(3),
1193                "{:?}",
1194                before.elapsed()
1195            );
1196            assert!(!alive(pid), "the host still runs after the drop");
1197        }
1198
1199        /// Whether the process `pid` still runs.
1200        fn alive(pid: u32) -> bool {
1201            Command::new("kill")
1202                .args(["-0", &pid.to_string()])
1203                .stderr(Stdio::null())
1204                .status()
1205                .unwrap()
1206                .success()
1207        }
1208
1209        #[test]
1210        fn a_host_that_dies_in_a_reply_is_a_crash_with_a_bounded_quote() {
1211            let cut = format!(
1212                r#"{OPEN}read -r line; printf '%s{{"reply":"saved","state":"' "$PREFIX"; head -c 300000 /dev/zero | tr '\0' y; exit 9"#
1213            );
1214            let mut renderer = start(&cut).unwrap();
1215            let error = renderer.render(&job()).unwrap_err();
1216            let message = error.to_string();
1217            assert!(matches!(error, RenderError::Crashed(_)), "{message}");
1218            assert!(message.contains("exit status: 9"), "{message}");
1219            assert!(message.len() < 8 * TAIL_LINE_BYTES, "{}", message.len());
1220        }
1221
1222        #[test]
1223        fn an_unreadable_reply_is_quoted_in_bounded_length() {
1224            let junk = format!(
1225                r#"{OPEN}read -r line; printf '%s{{"reply":"opened","x":"' "$PREFIX"; head -c 300000 /dev/zero | tr '\0' y; printf '\n'; exec sleep 30"#
1226            );
1227            let mut renderer = start(&junk).unwrap();
1228            let message = renderer.render(&job()).unwrap_err().to_string();
1229            assert!(message.contains("cannot read"), "{message}");
1230            assert!(message.len() < 4 * TAIL_LINE_BYTES, "{}", message.len());
1231        }
1232
1233        #[test]
1234        fn a_long_unreadable_reply_keeps_its_parse_error() {
1235            // 2000 bytes of reply: the quote is cut, the cause is not.
1236            let junk = format!(
1237                r#"{OPEN}read -r line; printf '%s{{"reply":"nonsense","x":"' "$PREFIX"; head -c 2000 /dev/zero | tr '\0' y; printf '"}}\n'; exec sleep 30"#
1238            );
1239            let mut renderer = start(&junk).unwrap();
1240            let message = renderer.render(&job()).unwrap_err().to_string();
1241            assert!(message.contains("unknown variant `nonsense`"), "{message}");
1242            assert!(message.len() < 4 * TAIL_LINE_BYTES, "{}", message.len());
1243        }
1244
1245        #[test]
1246        fn a_parse_error_that_echoes_a_long_tag_is_cut_too() {
1247            // The parse error quotes the 50 000-byte tag back.
1248            let junk = format!(
1249                r#"{OPEN}read -r line; printf '%s{{"reply":"' "$PREFIX"; head -c 50000 /dev/zero | tr '\0' y; printf '"}}\n'; exec sleep 30"#
1250            );
1251            let mut renderer = start(&junk).unwrap();
1252            let message = renderer.render(&job()).unwrap_err().to_string();
1253            assert!(message.contains("unknown variant"), "{message}");
1254            assert!(message.len() < 4 * TAIL_LINE_BYTES, "{}", message.len());
1255        }
1256
1257        #[test]
1258        fn a_host_crash_in_the_load_check_is_a_render_fault() {
1259            let mut renderer = start(&format!(
1260                "{OPEN}read -r line; echo 'plug-in crashed' >&2; exit 6"
1261            ))
1262            .unwrap();
1263            let fault = crate::render::check::render_checked(&mut renderer, &job()).unwrap_err();
1264            assert!(
1265                matches!(
1266                    fault,
1267                    crate::render::check::LoadFault::Render(RenderError::Crashed(_))
1268                ),
1269                "{fault}"
1270            );
1271            assert!(fault.to_string().contains("plug-in crashed"), "{fault}");
1272        }
1273
1274        #[test]
1275        fn a_host_that_lingers_after_bye_is_stopped() {
1276            let script = format!(
1277                r#"{OPEN}read -r line; printf '%s{{"reply":"bye"}}\n' "$PREFIX"; exec sleep 30"#
1278            );
1279            let renderer = start(&script).unwrap();
1280            let pid = renderer.child.id();
1281            let before = Instant::now();
1282            drop(renderer);
1283            assert!(
1284                before.elapsed() < Duration::from_secs(3),
1285                "{:?}",
1286                before.elapsed()
1287            );
1288            assert!(!alive(pid));
1289        }
1290
1291        #[test]
1292        fn a_session_retries_a_crashed_host_on_a_new_one() {
1293            use crate::render::session::{BoxedRenderer, RenderSession};
1294            let dir = tempfile::tempdir().unwrap();
1295            let mark = dir.path().join("crashed");
1296            let clip = dir.path().join("clip.wav");
1297            let rate = crate::audio::SampleRate::new(44_100).unwrap();
1298            let audio = crate::audio::Audio::new(rate, vec![vec![0.5; 100], vec![0.5; 100]]);
1299            crate::audio::write_wav(&clip, &audio.unwrap()).unwrap();
1300            // The first host exits on its first render; the next one copies
1301            // a prepared clip to the path the request names.
1302            let script = format!(
1303                r#"{OPEN}read -r line; if [ -e "$MARK" ]; then path=$(printf '%s' "$line" | sed 's/.*"audio":"\([^"]*\)".*/\1/'); cp "$CLIP" "$path"; printf '%s{{"reply":"rendered","frames":100,"capped":false}}\n' "$PREFIX"; read -r line; else : > "$MARK"; echo 'host fell over' >&2; exit 3; fi"#
1304            );
1305            let session = RenderSession::new(std::num::NonZeroUsize::MIN, move |_| {
1306                let mut command = command(&script);
1307                command.env("MARK", &mark).env("CLIP", &clip);
1308                PluginRenderer::spawn(command, Synth::Vital, Path::new("Vital.vst3"), SHORT)
1309                    .map(|renderer| Box::new(renderer) as BoxedRenderer)
1310            });
1311            let outcomes = session.render_all(&[job()]);
1312            let rendered = outcomes[0].rendered().unwrap();
1313            assert_eq!(rendered.audio.frames(), 100);
1314            assert_eq!(session.starts(), [2]);
1315        }
1316
1317        #[test]
1318        fn a_clip_that_does_not_decode_is_removed() {
1319            let mark = tempfile::tempdir().unwrap();
1320            let marked = mark.path().join("audio");
1321            let script = format!(
1322                r#"{OPEN}read -r line; path=$(printf '%s' "$line" | sed 's/.*"audio":"\([^"]*\)".*/\1/'); printf junk > "$path"; printf '%s' "$path" > "$MARK"; printf '%s{{"reply":"rendered","frames":1,"capped":false}}\n' "$PREFIX"; read -r line"#
1323            );
1324            let mut command = command(&script);
1325            command.env("MARK", &marked);
1326            let mut renderer =
1327                PluginRenderer::spawn(command, Synth::Vital, Path::new("Vital.vst3"), SHORT)
1328                    .unwrap();
1329            let message = renderer.render(&job()).unwrap_err().to_string();
1330            assert!(message.contains("does not decode"), "{message}");
1331            let clip = PathBuf::from(std::fs::read_to_string(&marked).unwrap());
1332            assert!(!clip.exists());
1333            let folder = clip.parent().unwrap().to_owned();
1334            assert_eq!(folder, renderer.scratch.path());
1335            let mode = std::fs::metadata(&folder).unwrap().permissions().mode();
1336            assert_eq!(mode & 0o077, 0, "{mode:o}");
1337            drop(renderer);
1338            assert!(!folder.exists());
1339        }
1340    }
1341}