zwire_host/
stryke_runner.rs1use std::io::{Read, Write};
18use std::path::{Path, PathBuf};
19use std::process::{Command, Stdio};
20use std::sync::OnceLock;
21use std::time::{Duration, Instant};
22
23const MAX_OUTPUT_BYTES: usize = 256 * 1024;
25
26const POLL_INTERVAL: Duration = Duration::from_millis(20);
28
29static STRYKE_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
30
31pub 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 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
77pub struct RunOutcome {
79 pub stdout: String,
80 pub stderr: String,
81 pub code: Option<i32>,
82 pub timed_out: bool,
83}
84
85fn 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
106pub 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 if let Some(mut stdin) = child.stdin.take() {
130 let _ = stdin.write_all(event_json.as_bytes());
131 }
132
133 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
173pub 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 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 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}