Skip to content

Commit 5f2ac84

Browse files
committed
fix(manager): drain aw-notify stderr so it can't hang
aw-notify is spawned with both stdout and stderr piped, but stderr was only read by wait_with_output() after stdout closed. aw-notify logs to stderr, so once ~64 KiB of logs built up the child blocked on its next write and stopped sending notifications. Read stderr on its own thread while stdout is consumed, keeping only the last 64 KiB for the crash log and the --output-only fallback check.
1 parent d04f7e6 commit 5f2ac84

1 file changed

Lines changed: 56 additions & 2 deletions

File tree

‎src-tauri/src/manager.rs‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, SubmenuBuil
4343
use tauri::{AppHandle, Wry};
4444

4545
use crate::{get_app_handle, get_config, get_tray_id, module_alert_ui, HANDLE_CONDVAR};
46-
use std::io::{BufRead, BufReader};
46+
use std::io::{BufRead, BufReader, Read};
4747
use tauri_plugin_notification::NotificationExt;
4848

4949
#[derive(Debug)]
@@ -883,6 +883,11 @@ fn start_notify_module_thread(
883883
})
884884
.expect("Failed to send module started message");
885885

886+
// Drain stderr concurrently: aw-notify logs there, and if nothing reads the pipe it fills
887+
// up (~64 KiB) and the child blocks on its next log write while we wait on stdout.
888+
let stderr = child.stderr.take().expect("Failed to get stderr");
889+
let stderr_reader = thread::spawn(move || read_tail(stderr, STDERR_TAIL_BYTES));
890+
886891
let stdout = child.stdout.take().expect("Failed to get stdout");
887892
let reader = BufReader::new(stdout);
888893

@@ -924,7 +929,13 @@ fn start_notify_module_thread(
924929
}
925930

926931
// Wait for the child to exit
927-
let output = child.wait_with_output().expect("Failed to wait on child");
932+
let status = child.wait().expect("Failed to wait on child");
933+
let output = std::process::Output {
934+
status,
935+
// stdout was consumed line by line above
936+
stdout: Vec::new(),
937+
stderr: stderr_reader.join().unwrap_or_default(),
938+
};
928939

929940
// Check if the process failed due to unsupported --output-only flag
930941
// Exit code 2 is commonly used by clap/click for argument errors
@@ -963,6 +974,33 @@ fn start_notify_module_thread(
963974
});
964975
}
965976

977+
/// How much of a module's stderr to keep for crash diagnostics.
978+
const STDERR_TAIL_BYTES: usize = 64 * 1024;
979+
980+
/// Reads `reader` to EOF, keeping only the last `max` bytes so a chatty module can't grow memory
981+
/// without bound over a long run.
982+
fn read_tail(mut reader: impl Read, max: usize) -> Vec<u8> {
983+
let mut tail = Vec::new();
984+
let mut buf = [0u8; 8192];
985+
loop {
986+
match reader.read(&mut buf) {
987+
Ok(0) => break,
988+
Ok(n) => {
989+
tail.extend_from_slice(&buf[..n]);
990+
if tail.len() > max {
991+
tail.drain(..tail.len() - max);
992+
}
993+
}
994+
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
995+
Err(e) => {
996+
warn!("Failed to read module stderr: {e}");
997+
break;
998+
}
999+
}
1000+
}
1001+
tail
1002+
}
1003+
9661004
/// Route a notification: send via ManagerEvent channel (mini mode) or Tauri (GUI mode).
9671005
fn route_notification(event_tx: &Option<Sender<ManagerEvent>>, title: &str, message: &str) {
9681006
if let Some(tx) = event_tx {
@@ -1241,3 +1279,19 @@ fn discover_modules() -> BTreeMap<String, PathBuf> {
12411279

12421280
found_modules
12431281
}
1282+
1283+
#[cfg(test)]
1284+
mod tests {
1285+
use super::read_tail;
1286+
1287+
#[test]
1288+
fn read_tail_keeps_everything_under_the_limit() {
1289+
assert_eq!(read_tail(&b"hello"[..], 64), b"hello");
1290+
}
1291+
1292+
#[test]
1293+
fn read_tail_keeps_only_the_last_bytes() {
1294+
let input: Vec<u8> = (0..20_000u32).map(|i| (i % 251) as u8).collect();
1295+
assert_eq!(read_tail(&input[..], 1000), &input[input.len() - 1000..]);
1296+
}
1297+
}

0 commit comments

Comments
 (0)