fix(ipc): read stdin on a blocking thread — EAGAIN no longer kills the backend

The user's crash, captured in the app's own console (Tools/keynctr-debug/el.log): 'Could not read from stdin: Resource temporarily unavailable (os error 11)'. tokio::io::stdin flips the Electron pipe non-blocking; under load a read surfaced EAGAIN despite pending data and the serve loop treated it as fatal, exiting code 1 and failing every in-flight request (the 'Rust backend exited unexpectedly' card). Now a dedicated blocking-thread reader feeds an mpsc channel: blocking reads cannot spuriously fail, EOF ends the loop cleanly, transient errors log and continue. Verified live: 200-line request stream -> 200 responses, exit 0; 219 unit + 6 e2e green; clippy 0; release rebuilt 09:54.
This commit is contained in:
Avi 2026-09-28 09:56:22 -05:00
commit 9770f46d4a

View file

@ -259,7 +259,6 @@ pub struct ReplyEnvelope {
/// feed reads) run without holding that lock so they cannot delay interactive /// feed reads) run without holding that lock so they cannot delay interactive
/// ones such as selecting a profile. /// ones such as selecting a profile.
pub async fn serve() -> Result<(), AppError> { pub async fn serve() -> Result<(), AppError> {
use tokio::io::AsyncBufReadExt;
use tokio::task::JoinSet; use tokio::task::JoinSet;
// Shared state, so the NIP-46 signer's background task and the request loop // Shared state, so the NIP-46 signer's background task and the request loop
@ -292,15 +291,45 @@ pub async fn serve() -> Result<(), AppError> {
let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout())); let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
let stdin = tokio::io::stdin(); // Read stdin on a dedicated blocking thread, not tokio::io::stdin().
let mut lines = tokio::io::BufReader::new(stdin).lines(); // Tokio flips the pipe non-blocking and relies on readiness events; when
// Electron's pipe is busy (large npm/cargo output flowing while the GUI
// is active) a read can surface EAGAIN ("Resource temporarily
// unavailable", os error 11) despite data being pending, which used to
// kill the serve loop and every in-flight request with it. A plain
// blocking read cannot fail that way: it just waits.
let (line_tx, mut line_rx) = tokio::sync::mpsc::channel::<String>(64);
std::thread::Builder::new()
.name("stdin-reader".to_string())
.spawn(move || {
use std::io::BufRead;
let stdin = std::io::stdin();
let mut reader = std::io::BufReader::new(stdin.lock());
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break, // EOF: the GUI closed the pipe.
Ok(_) => {
if line_tx.blocking_send(line).is_err() {
break; // Serve loop gone; nobody to answer.
}
}
// Transient (EINTR/EAGAIN on a raw fd is impossible with
// blocking reads, but never take the whole loop down for
// a hiccup): keep reading.
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => {
eprintln!("[ipc] stdin read error, stopping: {e}");
break;
}
}
}
})
.map_err(|e| AppError::io("Could not start the stdin reader", e))?;
let mut tasks = JoinSet::new(); let mut tasks = JoinSet::new();
while let Some(line) = lines while let Some(line) = line_rx.recv().await {
.next_line()
.await
.map_err(|e| AppError::io("Could not read from stdin", e))?
{
if line.trim().is_empty() { if line.trim().is_empty() {
continue; continue;
} }