1use 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
35pub const HOST_ENV: &str = "SOUND_TO_PATCH_HOST";
37pub const HOST_NAME: &str = "sound-to-patch-host";
39const HEADER_BYTES: u64 = 4096;
42const TAIL_LINES: usize = 20;
44const TAIL_LINE_BYTES: usize = 1000;
46const MAX_LINE: u64 = 64 * 1024;
51const ROSETTA: &str = "/Library/Apple/usr/libexec/oah/libRosettaRuntime";
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum Arch {
57 X86_64,
59 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 #[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 #[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#[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 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 Some(0xcffa_edfe) => le(4).and_then(Arch::of_cpu).into_iter().collect(),
124 _ => Vec::new(),
125 }
126}
127
128pub 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 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
177fn 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
186fn 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#[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
233pub enum Launch {
234 Direct,
236 As(Arch),
238}
239
240pub fn pick_launch(
249 plugin: &[Arch],
250 host: &[Arch],
251 current: Arch,
252 rosetta: bool,
253) -> Result<Launch, RenderError> {
254 if plugin.contains(¤t) {
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
283type 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
298fn 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
305fn 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#[derive(Clone, Copy, Debug)]
329struct Timeouts {
330 open: Duration,
332 render: Duration,
334 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
346type Incoming = Result<Reply, String>;
349
350#[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 scratch: tempfile::TempDir,
365}
366
367impl PluginRenderer {
368 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 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 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 #[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 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 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 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 fn stop(&mut self) {
587 let _ = self.child.kill();
588 let _ = self.child.wait();
589 self.join_readers();
590 }
591
592 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
624fn 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
651fn 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
669fn 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 keep(
687 tail,
688 &format!("(a reply cut off after {} bytes)", line.len()),
689 );
690 return false;
691 }
692 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
703type Pipes = (ChildStdin, Receiver<Incoming>, Tail, Vec<JoinHandle<()>>);
706
707fn 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 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 #[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 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 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 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 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}