Skip to main content

zwire_host/
stryke_runner.rs

1//! Subprocess bridge to the `stryke` interpreter for lifecycle hook scripts.
2//!
3//! zwire does **not** embed strykelang in-process — `VMHelper` is `!Send`/`!Sync`
4//! and the runtime's dependency tree (cranelift JIT, a second `bundled` rusqlite)
5//! makes in-process embedding both un-buildable and bloated. Instead each hook
6//! spawns the standalone `stryke` binary, feeds the event payload as JSON on
7//! stdin, and reads `{"actions":[...]}` back on stdout. Process isolation means a
8//! panicking or runaway script can never take down the host, and stryke versions
9//! independently.
10//!
11//! Ported verbatim from the Audio-Haxor engine (`src-tauri/src/stryke_runner.rs`)
12//! into the shared zwire-host: only the app-specific env-var names change
13//! (`AH_EVENT`/`AUDIO_HAXOR_HOOK`/`AUDIO_HAXOR_STRYKE` -> `ZWIRE_*`). The awk-style
14//! `run_n`/`filter_keys`/`transform_records` row-pipeline helpers are a separate
15//! feature and are intentionally not carried over here.
16
17use std::io::{Read, Write};
18use std::path::{Path, PathBuf};
19use std::process::{Command, Stdio};
20use std::sync::OnceLock;
21use std::time::{Duration, Instant};
22
23/// Cap on retained stdout/stderr bytes from a single hook script.
24const MAX_OUTPUT_BYTES: usize = 256 * 1024;
25
26/// Poll interval while waiting for a hook child to exit.
27const POLL_INTERVAL: Duration = Duration::from_millis(20);
28
29static STRYKE_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
30
31/// Resolve the `stryke` binary once and cache it for the process lifetime.
32/// Order: `ZWIRE_STRYKE` override, the sibling next to the host executable, then
33/// every `PATH` entry, then the common cargo / Homebrew install locations.
34pub fn resolve_stryke() -> Option<PathBuf> {
35    STRYKE_PATH
36        .get_or_init(|| {
37            let exe_name = if cfg!(windows) {
38                "stryke.exe"
39            } else {
40                "stryke"
41            };
42
43            if let Some(p) = std::env::var_os("ZWIRE_STRYKE") {
44                let pb = PathBuf::from(p);
45                if pb.is_file() {
46                    return Some(pb);
47                }
48            }
49            // A `stryke` shipped next to the host executable, so a packaged zwire
50            // uses its own bundled copy rather than whatever is on PATH.
51            if let Ok(exe) = std::env::current_exe() {
52                if let Some(sib) = exe.parent().map(|d| d.join(exe_name)) {
53                    if sib.is_file() {
54                        return Some(sib);
55                    }
56                }
57            }
58            if let Some(path) = std::env::var_os("PATH") {
59                for dir in std::env::split_paths(&path) {
60                    let cand = dir.join(exe_name);
61                    if cand.is_file() {
62                        return Some(cand);
63                    }
64                }
65            }
66            let mut fixed: Vec<PathBuf> = Vec::new();
67            if let Some(home) = std::env::var_os("HOME") {
68                fixed.push(PathBuf::from(home).join(".cargo/bin").join(exe_name));
69            }
70            fixed.push(PathBuf::from("/opt/homebrew/bin/stryke"));
71            fixed.push(PathBuf::from("/usr/local/bin/stryke"));
72            fixed.into_iter().find(|c| c.is_file())
73        })
74        .clone()
75}
76
77/// Outcome of running a hook script.
78pub struct RunOutcome {
79    pub stdout: String,
80    pub stderr: String,
81    pub code: Option<i32>,
82    pub timed_out: bool,
83}
84
85/// Read a pipe into a `String`, stopping after `MAX_OUTPUT_BYTES` and lossily
86/// decoding UTF-8 (hook output is data, not a terminal — ANSI is not expected).
87fn read_capped<R: Read>(mut r: R) -> String {
88    let mut buf = Vec::with_capacity(8192);
89    let mut chunk = [0u8; 8192];
90    loop {
91        match r.read(&mut chunk) {
92            Ok(0) => break,
93            Ok(n) => {
94                let room = MAX_OUTPUT_BYTES.saturating_sub(buf.len());
95                if room == 0 {
96                    break;
97                }
98                buf.extend_from_slice(&chunk[..n.min(room)]);
99            }
100            Err(_) => break,
101        }
102    }
103    String::from_utf8_lossy(&buf).into_owned()
104}
105
106/// Run `stryke <script_path>` with `event_json` on stdin and `ZWIRE_EVENT` set to
107/// `event_name`. Kills the child if it runs longer than `timeout`. Output is
108/// truncated to [`MAX_OUTPUT_BYTES`].
109pub fn run_script(
110    script_path: &Path,
111    event_name: &str,
112    event_json: &str,
113    timeout: Duration,
114) -> Result<RunOutcome, String> {
115    let stryke = resolve_stryke().ok_or_else(|| "stryke binary not found on PATH".to_string())?;
116
117    let mut child = Command::new(&stryke)
118        .arg(script_path)
119        .env("ZWIRE_EVENT", event_name)
120        .env("ZWIRE_HOOK", "1")
121        .stdin(Stdio::piped())
122        .stdout(Stdio::piped())
123        .stderr(Stdio::piped())
124        .spawn()
125        .map_err(|e| format!("spawn stryke: {e}"))?;
126
127    // Feed the payload, then drop stdin to signal EOF so the script's
128    // `<>` / slurp returns.
129    if let Some(mut stdin) = child.stdin.take() {
130        let _ = stdin.write_all(event_json.as_bytes());
131    }
132
133    // Drain stdout/stderr on dedicated threads so a chatty script can't
134    // deadlock by filling a pipe buffer while we poll for exit.
135    let out_pipe = child.stdout.take();
136    let err_pipe = child.stderr.take();
137    let out_t = std::thread::spawn(move || out_pipe.map(read_capped).unwrap_or_default());
138    let err_t = std::thread::spawn(move || err_pipe.map(read_capped).unwrap_or_default());
139
140    let start = Instant::now();
141    let mut timed_out = false;
142    let code;
143    loop {
144        match child.try_wait() {
145            Ok(Some(status)) => {
146                code = status.code();
147                break;
148            }
149            Ok(None) => {
150                if start.elapsed() >= timeout {
151                    let _ = child.kill();
152                    let _ = child.wait();
153                    timed_out = true;
154                    code = None;
155                    break;
156                }
157                std::thread::sleep(POLL_INTERVAL);
158            }
159            Err(e) => return Err(format!("wait stryke: {e}")),
160        }
161    }
162
163    let stdout = out_t.join().unwrap_or_default();
164    let stderr = err_t.join().unwrap_or_default();
165    Ok(RunOutcome {
166        stdout,
167        stderr,
168        code,
169        timed_out,
170    })
171}
172
173/// Run inline stryke code (`stryke -E <code>`) with an optional `stdin_data`
174/// string; same drain-on-threads + timeout + output-cap discipline as
175/// [`run_script`]. Backs the command-wizard "stryke script" step.
176pub fn run_code(code: &str, stdin_data: &str, timeout: Duration) -> Result<RunOutcome, String> {
177    let stryke = resolve_stryke().ok_or_else(|| "stryke binary not found on PATH".to_string())?;
178
179    let mut child = Command::new(&stryke)
180        .arg("-E")
181        .arg(code)
182        .stdin(Stdio::piped())
183        .stdout(Stdio::piped())
184        .stderr(Stdio::piped())
185        .spawn()
186        .map_err(|e| format!("spawn stryke: {e}"))?;
187
188    if let Some(mut si) = child.stdin.take() {
189        let _ = si.write_all(stdin_data.as_bytes());
190    }
191
192    let out_pipe = child.stdout.take();
193    let err_pipe = child.stderr.take();
194    let out_t = std::thread::spawn(move || out_pipe.map(read_capped).unwrap_or_default());
195    let err_t = std::thread::spawn(move || err_pipe.map(read_capped).unwrap_or_default());
196
197    let start = Instant::now();
198    let mut timed_out = false;
199    let code_out;
200    loop {
201        match child.try_wait() {
202            Ok(Some(status)) => {
203                code_out = status.code();
204                break;
205            }
206            Ok(None) => {
207                if start.elapsed() >= timeout {
208                    let _ = child.kill();
209                    let _ = child.wait();
210                    timed_out = true;
211                    code_out = None;
212                    break;
213                }
214                std::thread::sleep(POLL_INTERVAL);
215            }
216            Err(e) => return Err(format!("wait stryke: {e}")),
217        }
218    }
219
220    Ok(RunOutcome {
221        stdout: out_t.join().unwrap_or_default(),
222        stderr: err_t.join().unwrap_or_default(),
223        code: code_out,
224        timed_out,
225    })
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn read_capped_truncates_at_limit() {
234        let big = vec![b'x'; MAX_OUTPUT_BYTES + 4096];
235        let s = read_capped(&big[..]);
236        assert_eq!(s.len(), MAX_OUTPUT_BYTES);
237    }
238
239    #[test]
240    fn read_capped_passes_small_input() {
241        let s = read_capped(&b"hello"[..]);
242        assert_eq!(s, "hello");
243    }
244
245    #[test]
246    fn run_script_errors_without_binary() {
247        // Only asserts the Err path shape when stryke is genuinely absent, so CI
248        // without stryke still passes.
249        if resolve_stryke().is_none() {
250            let r = run_script(
251                Path::new("/nonexistent.st"),
252                "test",
253                "{}",
254                Duration::from_secs(1),
255            );
256            assert!(r.is_err());
257        }
258    }
259
260    #[test]
261    fn run_code_runs_inline_when_stryke_present() {
262        // Only asserts behavior when stryke is available; CI without it passes.
263        if resolve_stryke().is_some() {
264            let r = run_code("p 6 * 7", "", Duration::from_secs(5)).expect("run");
265            assert!(!r.timed_out);
266            assert!(r.stdout.contains("42"), "stdout: {:?}", r.stdout);
267        }
268    }
269}