app_lib/
audio_engine.rs

1//! **AudioEngine** subprocess: the main app spawns the **`audio-engine`** JUCE binary (`audio-engine/` CMake target),
2//! sends JSON lines on stdin, reads one JSON line per request. Keeps **one** child process alive
3//! (stdin loop in the AudioEngine) so stream state and IPC stay cheap.
4//! On app quit, [`shutdown_audio_engine_child`] runs from Tauri `RunEvent::Exit` / `ExitRequested` and from `libc::atexit`
5//! so the AudioEngine is always terminated with the host. **`AUDIO_HAXOR_PARENT_PID`** is set at spawn so the AudioEngine can
6//! exit if the host disappears without cleanup (e.g. macOS force quit / SIGKILL).
7//!
8//! **Shutdown must not take [`ENGINE_CHILD`] before killing the OS process.** Another thread can
9//! hold that mutex while blocked on `stdout.read_line()`; waiting on the mutex first deadlocks
10//! quit (AudioEngine never receives `kill`). We keep the child PID in [`ENGINE_CHILD_PID`] and send
11//! `SIGKILL` / `taskkill /F` first, then clear the slot.
12
13use 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/// Placeholder struct kept for serde stability / future prefs sync.
24#[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    /// Recent stderr from the AudioEngine (crash/assert output) for `app.log` when IPC fails.
42    stderr_tail: Arc<Mutex<String>>,
43    /// Which binary we spawned; must respawn if [`resolve_audio_engine_binary`] starts returning a different path.
44    binary_path: PathBuf,
45    /// `metadata().modified()` + `len()` when spawned — same path can be overwritten when the AudioEngine is rebuilt.
46    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
66/// Log host-side IPC failure to `app.log`, appending recent AudioEngine stderr when available.
67fn 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);
74/// OS PID of the current AudioEngine (`Child::id`), or `0` if none. Used for kill-on-exit without
75/// waiting on [`ENGINE_CHILD`] (see module comment).
76static ENGINE_CHILD_PID: AtomicU32 = AtomicU32::new(0);
77
78/// Host-side `playback_status` poll for library playback EOF when the WebView defers its **`setInterval`**
79/// poll (**`isUiIdleHeavyCpu`** — hidden, unfocused, minimized; see `syncEnginePlaybackEofWatchdog` in
80/// `audio-engine.js`). Throttled background timers can miss EOF for autoplay-next; this thread emits
81/// `audio-engine-playback-eof` on the same `loaded && eof` rising edge as the UI poll.
82static EOF_WATCHDOG_ACTIVE: AtomicBool = AtomicBool::new(false);
83
84/// Only runs while the WebView poll is deferred — ~1 Hz keeps idle CPU low; foreground-focused playback
85/// does not run this thread.
86const 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/// OS PID of the current AudioEngine subprocess (`Child::id`), or `0` if none has been spawned yet.
99#[inline]
100pub fn audio_engine_child_pid() -> u32 {
101    ENGINE_CHILD_PID.load(Ordering::SeqCst)
102}
103
104/// Hard-kill by PID so quit works even when no [`Child`] handle is available.
105fn 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
124/// Drop the in-memory slot after the OS process is dead or being replaced. Caller must have
125/// cleared or updated [`ENGINE_CHILD_PID`] appropriately.
126fn 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
142/// Resolve path to the `audio-engine` executable.
143///
144/// Prefer `audio-engine-artifacts/debug|release` (or legacy `target/debug|release`) found by walking **up** from [`std::env::current_exe`].
145/// That covers `pnpm dev` when the app runs inside a macOS **bundle** (`…/target/debug/bundle/…/Contents/MacOS/audio-haxor`)
146/// where the sibling `audio-engine` can be stale, while the real AudioEngine from `beforeDevCommand` lives
147/// at the workspace `audio-engine-artifacts/<profile>/audio-engine`. Also works when `CARGO_TARGET_DIR` is non-default
148/// (compile-time `CARGO_MANIFEST_DIR` alone is insufficient).
149///
150/// Override for debugging: set `AUDIO_HAXOR_AUDIO_ENGINE` to an absolute path to the AudioEngine binary.
151/// Release installs use the sibling next to the main executable when no workspace `target/` is found.
152///
153/// Tauri [`bundle.externalBin`](https://v2.tauri.app/develop/sidecar/) places **`audio-engine-<host-triple>`**
154/// next to the main executable (see `scripts/prepare-audio-engine-audioengine.mjs`). We spawn via
155/// [`std::process::Command`], not Tauri’s sidecar API, so we must resolve that suffixed name when
156/// plain `audio-engine` is absent (typical in a shipped `.app` under `/Applications`).
157pub 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    // macOS release-bundle layout: nested helper .app at
172    // `<bundle>/Contents/MacOS/AudioHaxorEngineHelper.app/Contents/MacOS/audio-engine`
173    // (sibling of the main `audio-haxor` binary). The audio-engine sidecar is wrapped in
174    // its own minimal .app so [NSBundle mainBundle] resolves to the helper bundle (its
175    // own bundle ID + Info.plist) instead of the parent AUDIO_HAXOR.app — required for
176    // `audiocomponentd` to authorize the host process for out-of-process AU plugin loading
177    // and XPC view-controller delivery (otherwise plugin editor windows render as a
178    // permanent 1×1 stub from `_RemoteAUv2ViewFactory`).
179    //
180    // The helper .app lives in Contents/MacOS/, NOT Contents/Frameworks/. We tried
181    // Frameworks/ first; LaunchServices treats `.app` bundles inside Contents/Frameworks/
182    // as embedded frameworks rather than registrable apps and audiocomponentd still
183    // refuses XPC view delivery. Real DAWs (Bitwig's `Bitwig Plug-in Host ARM64-NEON.app`,
184    // Reaper's helpers, etc.) put their helpers in Contents/MacOS/ and that's what works.
185    // See `scripts/postbundle-audio-engine-helper.sh` for the bundling pipeline.
186    //
187    // This check runs first so any release `.app` always uses the helper even if a stale
188    // sibling `audio-engine` binary is also present from a previous build.
189    #[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
227/// Walk parents of `exe` until `audio-engine-artifacts/…` or legacy `target/…/<binary>` exists (workspace root).
228fn 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
351/// Drop the long-lived `audio-engine` child so the next IPC spawns a fresh process.
352/// Use after a crash or when the AudioEngine stops responding.
353///
354/// Returns immediately after sending SIGKILL so the UI / toast is not blocked by mutex cleanup
355/// (which can take many seconds if an IPC thread still holds [`ENGINE_CHILD`]). Reaping runs on
356/// a background thread; the next `spawn_audio_engine_request` will spawn a fresh child if needed.
357pub 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
377/// Reap `Child` handles after the OS process is gone. Never uses blocking `Mutex::lock()`.
378/// Returns `true` if the slot was cleared; `false` if we gave up waiting (~30s).
379fn 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
391/// Kill the JUCE AudioEngine when the host app exits. Idempotent (safe if no child was spawned).
392pub 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
403/// Start a background thread that polls `playback_status` every [`EOF_WATCHDOG_POLL_MS`] and emits
404/// `audio-engine-playback-eof` when `loaded && eof` transitions to true (same edge `audio-engine.js`
405/// uses for autoplay next).
406/// Idempotent — if already running, returns immediately.
407pub 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
436/// Stop the host EOF poll (e.g. when library playback polling stops or the engine restarts).
437pub fn audio_engine_eof_watchdog_stop() {
438    EOF_WATCHDOG_ACTIVE.store(false, Ordering::SeqCst);
439}
440
441/// Run one request against the audio-engine subprocess (stdin / stdout JSON lines).
442pub fn spawn_audio_engine_request(request: &serde_json::Value) -> Result<serde_json::Value, String> {
443    spawn_audio_engine_request_at(request)
444}
445
446/// Tauri may pass `{ "request": { "cmd": ... } }` from `invoke(..., { request: payload })`; unwrap so
447/// stdin matches the audio-engine line protocol. If the top-level object already has `cmd`, it is
448/// left unchanged (even when `request` is also present).
449pub(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
460/// One response is one JSON object/array per line. A linked library may print warnings to **stdout**
461/// before the JSON line (e.g. `Warning: thread locking is not implemented`), which would break
462/// `serde_json::from_str` on the first `read_line`. Skip lines until one starts with `{` or `[`.
463fn 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                // Long-lived child can outlive a fresh `node scripts/build-audio-engine.mjs`; the old process may
540                // return `unknown cmd` for verbs added in a newer AudioEngine. Respawn once (see also
541                // [`ensure_engine_child`] binary identity). Retry even if `ok` is missing — some
542                // older builds only set `error`.
543                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}