1use std::io::{BufRead, BufReader, Read, Write};
14use std::path::{Path, PathBuf};
15use std::process::{Child, Command, Stdio};
16use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
17use std::sync::{Arc, Mutex};
18use std::thread;
19use std::time::{Duration, SystemTime};
20
21use tauri::{AppHandle, Emitter};
22
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25pub struct AudioEngineStub {
26 pub state: String,
27}
28
29impl Default for AudioEngineStub {
30 fn default() -> Self {
31 Self {
32 state: "not_started".to_string(),
33 }
34 }
35}
36
37struct EngineChild {
38 child: Child,
39 stdin: std::process::ChildStdin,
40 stdout: BufReader<std::process::ChildStdout>,
41 stderr_tail: Arc<Mutex<String>>,
43 binary_path: PathBuf,
45 binary_identity: Option<(SystemTime, u64)>,
47}
48
49fn format_stderr_suffix(tail: &Arc<Mutex<String>>) -> String {
50 tail.lock()
51 .ok()
52 .map(|g| {
53 let s = g.trim();
54 if s.is_empty() {
55 String::new()
56 } else {
57 format!(
58 " | stderr (last): {}",
59 s.chars().take(800).collect::<String>()
60 )
61 }
62 })
63 .unwrap_or_default()
64}
65
66fn log_ipc_failure(msg: impl Into<String>, tail: Option<&Arc<Mutex<String>>>) {
68 let msg = msg.into();
69 let suffix = tail.map(format_stderr_suffix).unwrap_or_default();
70 crate::write_app_log(format!("audio-engine IPC error: {msg}{suffix}"));
71}
72
73static ENGINE_CHILD: Mutex<Option<EngineChild>> = Mutex::new(None);
74static ENGINE_CHILD_PID: AtomicU32 = AtomicU32::new(0);
77
78static EOF_WATCHDOG_ACTIVE: AtomicBool = AtomicBool::new(false);
83
84const EOF_WATCHDOG_POLL_MS: u64 = 1000;
87
88#[inline]
89fn record_engine_pid(child: &Child) {
90 ENGINE_CHILD_PID.store(child.id(), Ordering::SeqCst);
91}
92
93#[inline]
94fn clear_engine_pid() {
95 ENGINE_CHILD_PID.store(0, Ordering::SeqCst);
96}
97
98#[inline]
100pub fn audio_engine_child_pid() -> u32 {
101 ENGINE_CHILD_PID.load(Ordering::SeqCst)
102}
103
104fn kill_pid_raw(pid: u32) {
106 if pid == 0 {
107 return;
108 }
109 #[cfg(unix)]
110 unsafe {
111 let _ = libc::kill(pid as libc::pid_t, libc::SIGKILL);
112 }
113 #[cfg(windows)]
114 {
115 use std::os::windows::process::CommandExt;
116 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
117 let _ = std::process::Command::new("taskkill")
118 .args(["/F", "/PID", &pid.to_string()])
119 .creation_flags(CREATE_NO_WINDOW)
120 .status();
121 }
122}
123
124fn take_and_reap_engine_child(guard: &mut Option<EngineChild>) {
127 if let Some(mut eng) = guard.take() {
128 clear_engine_pid();
129 let _ = eng.child.kill();
130 let _ = eng.child.wait();
131 }
132}
133
134fn binary_name() -> &'static str {
135 if cfg!(target_os = "windows") {
136 "audio-engine.exe"
137 } else {
138 "audio-engine"
139 }
140}
141
142pub fn resolve_audio_engine_binary() -> Result<PathBuf, String> {
158 if let Ok(p) = std::env::var("AUDIO_HAXOR_AUDIO_ENGINE") {
159 let p = PathBuf::from(p.trim());
160 if p.is_file() {
161 return Ok(p);
162 }
163 }
164
165 let exe = std::env::current_exe().map_err(|e| e.to_string())?;
166 let dir = exe
167 .parent()
168 .ok_or_else(|| "current_exe has no parent directory".to_string())?;
169 let sibling = dir.join(binary_name());
170
171 #[cfg(target_os = "macos")]
190 {
191 let helper = dir
192 .join("AudioHaxorEngineHelper.app")
193 .join("Contents")
194 .join("MacOS")
195 .join(binary_name());
196 if helper.is_file() {
197 return Ok(helper);
198 }
199 }
200
201 if let Some(p) = find_audio_engine_under_target_ancestors(&exe) {
202 return Ok(p);
203 }
204
205 if sibling.is_file() {
206 return Ok(sibling);
207 }
208
209 if let Some(triple) = option_env!("AUDIO_HAXOR_TARGET_TRIPLE") {
210 let suffixed = if cfg!(target_os = "windows") {
211 dir.join(format!("audio-engine-{triple}.exe"))
212 } else {
213 dir.join(format!("audio-engine-{triple}"))
214 };
215 if suffixed.is_file() {
216 return Ok(suffixed);
217 }
218 }
219
220 Err(format!(
221 "audio engine binary not found (tried helper .app in Contents/MacOS/, workspace walk, `{}`, and `audio-engine-{}` next to host)",
222 sibling.display(),
223 option_env!("AUDIO_HAXOR_TARGET_TRIPLE").unwrap_or("?")
224 ))
225}
226
227fn find_audio_engine_under_target_ancestors(exe: &Path) -> Option<PathBuf> {
229 let mut dir = exe.parent()?;
230 for _ in 0..40 {
231 let ae_dbg = dir
232 .join("audio-engine-artifacts")
233 .join("debug")
234 .join(binary_name());
235 if ae_dbg.is_file() {
236 return Some(ae_dbg);
237 }
238 let ae_rel = dir
239 .join("audio-engine-artifacts")
240 .join("release")
241 .join(binary_name());
242 if ae_rel.is_file() {
243 return Some(ae_rel);
244 }
245 let dbg = dir.join("target").join("debug").join(binary_name());
246 if dbg.is_file() {
247 return Some(dbg);
248 }
249 let rel = dir.join("target").join("release").join(binary_name());
250 if rel.is_file() {
251 return Some(rel);
252 }
253 dir = dir.parent()?;
254 }
255 None
256}
257
258fn child_dead(child: &mut Child) -> bool {
259 match child.try_wait() {
260 Ok(Some(_)) => true,
261 Ok(None) => false,
262 Err(_) => true,
263 }
264}
265
266fn spawn_engine_child(path: &Path) -> Result<EngineChild, String> {
267 let identity = std::fs::metadata(path).ok().map(|m| (m.modified().unwrap_or_else(|_| SystemTime::UNIX_EPOCH), m.len()));
268 let data_dir = crate::history::get_data_dir();
269 let engine_log = data_dir.join("engine.log");
270 let app_log = data_dir.join("app.log");
271 let scan_timeout_sec = crate::history::get_preference("pluginScanTimeoutSec")
272 .and_then(|v| v.as_u64())
273 .unwrap_or(30);
274 let mut child = Command::new(path)
275 .env("AUDIO_HAXOR_ENGINE_LOG", engine_log.as_os_str())
276 .env("AUDIO_HAXOR_APP_LOG", app_log.as_os_str())
277 .env(
278 "AUDIO_HAXOR_PARENT_PID",
279 format!("{}", std::process::id()),
280 )
281 .env("AUDIO_HAXOR_PLUGIN_SCAN_TIMEOUT_SEC", scan_timeout_sec.to_string())
282 .stdin(Stdio::piped())
283 .stdout(Stdio::piped())
284 .stderr(Stdio::piped())
285 .spawn()
286 .map_err(|e| {
287 log_ipc_failure(format!("failed to spawn {}: {e}", path.display()), None);
288 format!("spawn {}: {e}", path.display())
289 })?;
290 let stdin = child.stdin.take().ok_or_else(|| "audio-engine: no stdin".to_string())?;
291 let stdout = child.stdout.take().ok_or_else(|| "audio-engine: no stdout".to_string())?;
292 let stderr = child.stderr.take().ok_or_else(|| "audio-engine: no stderr".to_string())?;
293
294 let stderr_tail = Arc::new(Mutex::new(String::new()));
295 let tail_for_thread = Arc::clone(&stderr_tail);
296 thread::spawn(move || {
297 let mut reader = BufReader::new(stderr);
298 let mut line = String::new();
299 loop {
300 line.clear();
301 match reader.read_line(&mut line) {
302 Ok(0) => break,
303 Ok(_) => {
304 if let Ok(mut g) = tail_for_thread.lock() {
305 g.push_str(&line);
306 if g.len() > 8192 {
307 let trim = g.len().saturating_sub(4096);
308 g.drain(..trim);
309 }
310 }
311 }
312 Err(_) => break,
313 }
314 }
315 });
316
317 let stdout = BufReader::new(stdout);
318 record_engine_pid(&child);
319 Ok(EngineChild {
320 child,
321 stdin,
322 stdout,
323 stderr_tail,
324 binary_path: path.to_path_buf(),
325 binary_identity: identity,
326 })
327}
328
329fn ensure_engine_child(path: &Path) -> Result<(), String> {
330 let mut guard = ENGINE_CHILD
331 .lock()
332 .map_err(|_| "audio-engine child mutex poisoned")?;
333 let disk_identity = std::fs::metadata(path).ok().map(|m| (m.modified().unwrap_or_else(|_| SystemTime::UNIX_EPOCH), m.len()));
334 let need_spawn = match guard.as_mut() {
335 None => true,
336 Some(eng) => {
337 child_dead(&mut eng.child)
338 || eng.binary_path != path
339 || disk_identity.is_some() && disk_identity != eng.binary_identity
340 }
341 };
342 if need_spawn {
343 if guard.is_some() {
344 take_and_reap_engine_child(&mut *guard);
345 }
346 *guard = Some(spawn_engine_child(path)?);
347 }
348 Ok(())
349}
350
351pub fn restart_audio_engine_child() -> Result<(), String> {
358 audio_engine_eof_watchdog_stop();
359 let pid = ENGINE_CHILD_PID.swap(0, Ordering::SeqCst);
360 if pid != 0 {
361 kill_pid_raw(pid);
362 }
363 std::thread::spawn(|| {
364 let reaped = clear_engine_slot_after_os_kill();
365 if reaped {
366 crate::write_app_log("audio-engine: AudioEngine process restarted (user request)".to_string());
367 } else {
368 log_ipc_failure(
369 "ENGINE_CHILD mutex not acquired after OS kill (timeout); next IPC may respawn",
370 None,
371 );
372 }
373 });
374 Ok(())
375}
376
377fn clear_engine_slot_after_os_kill() -> bool {
380 const MAX_ITERS: u32 = 6000;
381 for _ in 0..MAX_ITERS {
382 if let Ok(mut g) = ENGINE_CHILD.try_lock() {
383 take_and_reap_engine_child(&mut *g);
384 return true;
385 }
386 thread::sleep(Duration::from_millis(5));
387 }
388 false
389}
390
391pub fn shutdown_audio_engine_child() -> Result<(), String> {
393 audio_engine_eof_watchdog_stop();
394 let pid = ENGINE_CHILD_PID.swap(0, Ordering::SeqCst);
395 if pid != 0 {
396 kill_pid_raw(pid);
397 }
398 let _ = clear_engine_slot_after_os_kill();
399 crate::write_app_log("audio-engine: AudioEngine terminated (app shutdown)".to_string());
400 Ok(())
401}
402
403pub fn audio_engine_eof_watchdog_start(app: AppHandle) {
408 if EOF_WATCHDOG_ACTIVE.swap(true, Ordering::SeqCst) {
409 return;
410 }
411 thread::spawn(move || {
412 let mut prev_eof = false;
413 while EOF_WATCHDOG_ACTIVE.load(Ordering::SeqCst) {
414 thread::sleep(Duration::from_millis(EOF_WATCHDOG_POLL_MS));
415 if !EOF_WATCHDOG_ACTIVE.load(Ordering::SeqCst) {
416 break;
417 }
418 let v = match spawn_audio_engine_request(&serde_json::json!({ "cmd": "playback_status" })) {
419 Ok(v) => v,
420 Err(_) => {
421 prev_eof = false;
422 continue;
423 }
424 };
425 let loaded = v.get("loaded").and_then(|v| v.as_bool()).unwrap_or(false);
426 let eof = v.get("eof").and_then(|v| v.as_bool()).unwrap_or(false);
427 let at_eof = loaded && eof;
428 if at_eof && !prev_eof {
429 let _ = app.emit("audio-engine-playback-eof", serde_json::Value::Null);
430 }
431 prev_eof = at_eof;
432 }
433 });
434}
435
436pub fn audio_engine_eof_watchdog_stop() {
438 EOF_WATCHDOG_ACTIVE.store(false, Ordering::SeqCst);
439}
440
441pub fn spawn_audio_engine_request(request: &serde_json::Value) -> Result<serde_json::Value, String> {
443 spawn_audio_engine_request_at(request)
444}
445
446pub(crate) fn normalize_ipc_request_payload(v: &serde_json::Value) -> serde_json::Value {
450 if let Some(obj) = v.as_object() {
451 if !obj.contains_key("cmd") {
452 if let Some(inner) = obj.get("request") {
453 return inner.clone();
454 }
455 }
456 }
457 v.clone()
458}
459
460fn read_engine_json_line<R: Read>(stdout: &mut BufReader<R>) -> Result<String, String> {
464 const MAX_LINE_READS: usize = 256;
465 let mut line = String::new();
466 for _ in 0..MAX_LINE_READS {
467 line.clear();
468 match stdout.read_line(&mut line) {
469 Ok(0) => return Err("audio-engine closed stdout".to_string()),
470 Ok(_) => {
471 let mut s = line.trim();
472 if s.starts_with('\u{feff}') {
473 s = s.trim_start_matches('\u{feff}').trim_start();
474 }
475 if s.is_empty() {
476 continue;
477 }
478 let first = s.as_bytes().first().copied();
479 if first == Some(b'{') || first == Some(b'[') {
480 return Ok(s.to_string());
481 }
482 continue;
483 }
484 Err(e) => return Err(format!("audio-engine stdout: {e}")),
485 }
486 }
487 Err("audio-engine stdout: no JSON line (exceeded line read limit)".to_string())
488}
489
490fn spawn_audio_engine_request_at(request: &serde_json::Value) -> Result<serde_json::Value, String> {
491 let payload = serde_json::to_string(request).map_err(|e| e.to_string())?;
492
493 for attempt in 0..2 {
494 let path = resolve_audio_engine_binary().map_err(|e| {
495 log_ipc_failure(format!("failed to resolve audio-engine binary: {e}"), None);
496 e
497 })?;
498 ensure_engine_child(&path)?;
499 let mut guard = ENGINE_CHILD
500 .lock()
501 .map_err(|_| "audio-engine child mutex poisoned".to_string())?;
502 let eng = guard.as_mut().ok_or_else(|| "audio-engine child missing".to_string())?;
503
504 if eng
505 .stdin
506 .write_all(payload.as_bytes())
507 .map_err(|e| e.to_string())
508 .and_then(|_| {
509 eng.stdin
510 .write_all(b"\n")
511 .map_err(|e| format!("audio-engine stdin: {e}"))
512 })
513 .and_then(|_| eng.stdin.flush().map_err(|e| format!("audio-engine stdin: {e}")))
514 .is_err()
515 {
516 let stderr_tail = Arc::clone(&eng.stderr_tail);
517 clear_engine_pid();
518 *guard = None;
519 if attempt == 1 {
520 log_ipc_failure("stdin write failed", Some(&stderr_tail));
521 return Err("audio-engine stdin write failed".to_string());
522 }
523 continue;
524 }
525
526 match read_engine_json_line(&mut eng.stdout) {
527 Ok(json_line) => {
528 let v: serde_json::Value = match serde_json::from_str(&json_line) {
529 Ok(v) => v,
530 Err(e) => {
531 let stderr_tail = Arc::clone(&eng.stderr_tail);
532 log_ipc_failure(
533 format!("invalid JSON on stdout: {e}; line={json_line:?}"),
534 Some(&stderr_tail),
535 );
536 return Err(format!("audio-engine JSON: {e}: {json_line}"));
537 }
538 };
539 if attempt == 0 {
544 if let Some(err) = v.get("error").and_then(|e| e.as_str()) {
545 if err.to_ascii_lowercase().contains("unknown cmd") {
546 clear_engine_pid();
547 *guard = None;
548 continue;
549 }
550 }
551 }
552 return Ok(v);
553 }
554 Err(e) => {
555 let stderr_tail = Arc::clone(&eng.stderr_tail);
556 let is_eof = e == "audio-engine closed stdout";
557 clear_engine_pid();
558 *guard = None;
559 if attempt == 1 {
560 if is_eof {
561 log_ipc_failure(
562 "AudioEngine closed stdout (process exited or crashed)",
563 Some(&stderr_tail),
564 );
565 } else {
566 log_ipc_failure(format!("stdout read: {e}"), Some(&stderr_tail));
567 }
568 return Err(e);
569 }
570 }
571 }
572 }
573 log_ipc_failure("request failed after retry", None);
574 Err("audio-engine request failed after retry".to_string())
575}
576
577#[cfg(test)]
578mod tests {
579 use std::io::{BufReader, Cursor};
580
581 use super::{normalize_ipc_request_payload, read_engine_json_line};
582 use serde_json::json;
583
584 #[test]
585 fn parse_engine_response_line() {
586 let s = r#"{"ok":true,"version":"1.0.0"}"#;
587 let v: serde_json::Value = serde_json::from_str(s).unwrap();
588 assert_eq!(v["ok"], true);
589 }
590
591 #[test]
592 fn audio_engine_stub_default_and_json_roundtrip() {
593 let s = super::AudioEngineStub::default();
594 assert_eq!(s.state, "not_started");
595 let v = serde_json::to_value(&s).unwrap();
596 let back: super::AudioEngineStub = serde_json::from_value(v).unwrap();
597 assert_eq!(back.state, "not_started");
598 }
599
600 #[test]
601 fn normalize_ipc_request_payload_passes_through_when_cmd_present() {
602 let v = json!({ "cmd": "ping", "request": { "cmd": "other" } });
603 let n = normalize_ipc_request_payload(&v);
604 assert_eq!(n, v);
605 }
606
607 #[test]
608 fn normalize_ipc_request_payload_unwraps_tauri_request_wrapper() {
609 let v = json!({ "request": { "cmd": "ping", "x": 1 } });
610 let n = normalize_ipc_request_payload(&v);
611 assert_eq!(n, json!({ "cmd": "ping", "x": 1 }));
612 }
613
614 #[test]
615 fn normalize_ipc_request_payload_unwraps_when_no_top_level_cmd() {
616 let v = json!({ "foo": true, "request": { "cmd": "engine_state" } });
617 let n = normalize_ipc_request_payload(&v);
618 assert_eq!(n, json!({ "cmd": "engine_state" }));
619 }
620
621 #[test]
622 fn normalize_ipc_request_payload_leaves_non_object_unchanged() {
623 let v = json!("literal");
624 assert_eq!(normalize_ipc_request_payload(&v), v);
625 }
626
627 #[test]
628 fn normalize_ipc_request_payload_empty_object_unchanged() {
629 let v = json!({});
630 assert_eq!(normalize_ipc_request_payload(&v), json!({}));
631 }
632
633 #[test]
634 fn normalize_ipc_request_payload_does_not_unwrap_when_cmd_key_is_empty_string() {
635 let v = json!({ "cmd": "", "request": { "cmd": "ping" } });
636 let n = normalize_ipc_request_payload(&v);
637 assert_eq!(n, v);
638 }
639
640 #[test]
641 fn normalize_ipc_request_payload_does_not_unwrap_when_cmd_is_null() {
642 let v = json!({ "cmd": null, "request": { "cmd": "ping" } });
643 let n = normalize_ipc_request_payload(&v);
644 assert_eq!(n, v);
645 }
646
647 #[test]
648 fn normalize_ipc_request_payload_unwraps_when_only_request_has_cmd() {
649 let v = json!({ "request": { "cmd": "playback_status" } });
650 let n = normalize_ipc_request_payload(&v);
651 assert_eq!(n, json!({ "cmd": "playback_status" }));
652 }
653
654 #[test]
655 fn read_engine_json_line_skips_leading_warning_on_stdout() {
656 let data = b"Warning: thread locking is not implemented\n{\"ok\":true,\"x\":1}\n";
657 let mut r = BufReader::new(Cursor::new(&data[..]));
658 let line = read_engine_json_line(&mut r).unwrap();
659 assert_eq!(line, r#"{"ok":true,"x":1}"#);
660 }
661
662 #[test]
663 fn read_engine_json_line_strips_bom() {
664 let data = "\u{feff}{\"ok\":false}\n".as_bytes();
665 let mut r = BufReader::new(Cursor::new(data));
666 let line = read_engine_json_line(&mut r).unwrap();
667 assert_eq!(line, r#"{"ok":false}"#);
668 }
669
670 #[test]
671 fn read_engine_json_line_accepts_json_array_line() {
672 let data = b"[1,2,3]\n";
673 let mut r = BufReader::new(Cursor::new(&data[..]));
674 let line = read_engine_json_line(&mut r).unwrap();
675 assert_eq!(line, "[1,2,3]");
676 }
677
678 #[test]
679 fn read_engine_json_line_trims_leading_whitespace_before_json() {
680 let data = b" {\"a\":1}\n";
681 let mut r = BufReader::new(Cursor::new(&data[..]));
682 let line = read_engine_json_line(&mut r).unwrap();
683 assert_eq!(line, r#"{"a":1}"#);
684 }
685
686 #[test]
687 fn read_engine_json_line_skips_empty_and_blank_lines() {
688 let data = b" \n\t\nnot json\n{\"ok\":true}\n";
689 let mut r = BufReader::new(Cursor::new(&data[..]));
690 let line = read_engine_json_line(&mut r).unwrap();
691 assert_eq!(line, r#"{"ok":true}"#);
692 }
693
694 #[test]
695 fn read_engine_json_line_eof_on_empty_stream() {
696 let data: &[u8] = b"";
697 let mut r = BufReader::new(Cursor::new(data));
698 let err = read_engine_json_line(&mut r).unwrap_err();
699 assert_eq!(err, "audio-engine closed stdout");
700 }
701
702 #[test]
703 fn read_engine_json_line_errors_after_max_non_json_lines() {
704 let mut s = String::with_capacity(256 * 6 + 32);
705 for _ in 0..256 {
706 s.push_str("noise\n");
707 }
708 s.push_str(r#"{"ok":true}"#);
709 s.push('\n');
710 let mut r = BufReader::new(Cursor::new(s.into_bytes()));
711 let err = read_engine_json_line(&mut r).unwrap_err();
712 assert!(
713 err.contains("exceeded line read limit"),
714 "unexpected err: {err}"
715 );
716 }
717
718 #[test]
719 fn read_engine_json_line_multiple_warnings_before_object() {
720 let data = b"Warning: a\nWarning: b\n{\"x\":0}\n";
721 let mut r = BufReader::new(Cursor::new(&data[..]));
722 let line = read_engine_json_line(&mut r).unwrap();
723 assert_eq!(line, r#"{"x":0}"#);
724 }
725}