Merge remote-tracking branch 'origin/main' into fix/1933-port-holder

# Conflicts:
#	CHANGELOG.md
#	frontend/src-tauri/src/backend.rs
This commit is contained in:
Palash Debnath
2026-09-10 01:53:46 -07:00
3 changed files with 310 additions and 29 deletions
+1
View File
@@ -98,6 +98,7 @@ the frozen-backend fallback mirror it for their toolchains.
- Windows contributors can run the test suite without Developer Mode: tests that create a symlink now skip instead of failing with `WinError 1314` (#1990)
- The crash details dialog now says what the exit code means and what to try, instead of showing a raw number and a log (#1927)
- A crash report now carries the backend's actual last words: the log tail is captured after the dying process's final output lands, not the instant it exits (#1850)
- A port-3900 conflict now names who is actually holding it, and gives the command that ends an orphaned backend, instead of telling you to quit an app that has no window (#1933) — thanks @Chang-Jin-Lee!
- Windows desktop launches no longer freeze at "Loading ML runtime (PyTorch)": the parent-liveness watchdog polls the stdin pipe instead of leaving a read pending, which deadlocked numpy's OpenBLAS initializer (#1952)
+290 -25
View File
@@ -635,14 +635,53 @@ pub fn err_log_run_start() -> u64 {
/// This is the reader every death path must use: it cannot see another run's
/// output, so a crash marker carries the dying process's words or nothing.
pub fn read_error_log_tail_for_run(max_lines: usize) -> String {
read_error_log_tail_range(err_log_run_start(), None, max_lines)
}
/// Last N lines of one run's slice, `[start, end)`.
///
/// A death path pins `start` BEFORE it settles the drainers (#1850, Greptile):
/// settling can take up to two seconds, and a Retry arriving in that window
/// installs a new run and moves `ERR_LOG_RUN_START` past the dying run's
/// output. Reading "the current run" afterwards would hand the dead process's
/// crash marker the REPLACEMENT's healthy startup — the cross-run attribution
/// #1510 exists to prevent, reintroduced through the wait that fixed the tail.
///
/// `end` closes the other side (CodeRabbit). A start offset with an unbounded
/// end still does not identify ONE run: the replacement writes below the dying
/// run's lines, and a tail reads the last N of the file, so the newer run's
/// startup is exactly what a caller would get. `end` is where the next run's
/// slice begins, or `None` when no run started in the meantime.
pub fn read_error_log_tail_range(start: u64, end: Option<u64>, max_lines: usize) -> String {
let err_path = backend_log_path().with_file_name("backend_err.log");
read_error_log_tail_at(&err_path, err_log_run_start(), max_lines)
read_error_log_slice(&err_path, start, end, max_lines)
}
/// The run that has just died, read as a closed range.
///
/// Call AFTER settling: the end is taken from wherever the current run now
/// begins, which is either still `start` (nothing replaced it) or the
/// replacement's offset (which is exactly where this run's slice ends).
pub fn read_dead_run_tail(start: u64, max_lines: usize) -> String {
let now = err_log_run_start();
let end = if now > start { Some(now) } else { None };
read_error_log_tail_range(start, end, max_lines)
}
/// Tail of `path` starting at byte `start` (whole file when `start` is 0 or
/// no longer valid — an externally replaced/shrunk file must degrade to the
/// old whole-file behaviour, never to a silent empty capture).
fn read_error_log_tail_at(path: &Path, start: u64, max_lines: usize) -> String {
read_error_log_slice(path, start, None, max_lines)
}
/// `read_error_log_tail_at` with the far end closed too.
fn read_error_log_slice(
path: &Path,
start: u64,
end: Option<u64>,
max_lines: usize,
) -> String {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return String::new(),
@@ -653,36 +692,107 @@ fn read_error_log_tail_at(path: &Path, start: u64, max_lines: usize) -> String {
} else {
&content[..]
};
// An end that is not a usable boundary degrades to "the rest of the file",
// matching how an unusable start degrades to the whole file: evidence
// beats precision, and a silent empty capture is the one outcome that
// helps nobody.
let slice = match end.and_then(|e| usize::try_from(e).ok()) {
Some(e) if e >= start && e <= content.len() && content.is_char_boundary(e) => {
&content[..e - start]
}
_ => slice,
};
let lines: Vec<&str> = slice.lines().collect();
let from = lines.len().saturating_sub(max_lines);
lines[from..].join("\n")
}
/// The previous run's stderr-drainer thread. Joined (bounded) before a new
/// spawn records its offset, so a dying run's still-buffered stderr cannot be
/// appended AFTER the new run's start offset and get attributed to the new
/// run. (Full per-child offset binding isn't needed: spawns are serialized by
/// the #1223 spawn-once flow, so the only race left was this buffered tail.)
static ERR_LOG_DRAINER: Mutex<Option<std::thread::JoinHandle<()>>> = Mutex::new(None);
/// Stderr-drainer threads that have not finished writing yet.
///
/// A list, not a slot. Two callers wait on these — a respawn before it records
/// its start offset (#1510) and a crash marker before it captures the tail
/// (#1850) — and either can time out on a wedged drainer (a pipe held open by
/// an orphaned grandchild) while a NEW run installs its own. A single slot
/// loses the timed-out handle at that moment: dropping a JoinHandle detaches
/// the thread, so nothing can ever wait for that run's output again and both
/// guarantees quietly stop holding. Keeping every unfinished handle costs a
/// Vec that is empty in the normal case, since a finished drainer is dropped
/// on the next settle.
static ERR_LOG_DRAINERS: Mutex<Vec<std::thread::JoinHandle<()>>> = Mutex::new(Vec::new());
/// Wait briefly for the previous run's stderr drainer to flush. A wedged
/// drainer (pipe held open by an orphaned grandchild) must not block a
/// respawn forever — after the bound we proceed; the offset then simply
/// includes whatever the old run still manages to write, which degrades to
/// attributing too MUCH to the new run, never to destroying evidence.
fn join_previous_err_drainer(bound: Duration) {
let handle = ERR_LOG_DRAINER.lock().ok().and_then(|mut g| g.take());
if let Some(handle) = handle {
let deadline = std::time::Instant::now() + bound;
while !handle.is_finished() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
/// Register a run's stderr drainer so the settles below can wait for it.
fn track_err_drainer(handle: std::thread::JoinHandle<()>) {
if let Ok(mut guard) = ERR_LOG_DRAINERS.lock() {
guard.push(handle);
}
}
/// Serializes a whole settlement. `settle_err_log` takes the handles out of
/// the list and then waits without holding that lock, so two callers could
/// otherwise interleave: the second finds an empty list, concludes there is
/// nothing to wait for, and reads the log while the first is still waiting for
/// exactly the drainer it needs (CodeRabbit). One settlement at a time makes a
/// caller that returns a caller for whom the waiting is genuinely done.
static ERR_LOG_SETTLE_LOCK: Mutex<()> = Mutex::new(());
/// Wait briefly for every outstanding stderr drainer to finish writing.
///
/// Two callers, one guarantee: everything a run wrote is on disk before anyone
/// reads it.
///
/// - Before a respawn takes its start offset, so a dying run's buffered tail
/// cannot be appended past that offset and get attributed to the new run
/// (#1510).
/// - Before a crash marker captures `last_stderr` (#1850). `wait()` returns the
/// moment the child exits, but the drainer is a separate thread reading a
/// pipe: its last lines — the traceback naming the cause — can still be in
/// flight. Reading the file at that instant captures a tail that stops BEFORE
/// the death, which is how a crash report arrives with a log ending a minute
/// early and nothing to diagnose.
///
/// A wedged drainer must not block forever. After the bound we proceed, and
/// every handle still running is kept for the next caller to wait on. For a
/// respawn that degrades to attributing too MUCH to the new run; for a marker,
/// to a short tail. Never to destroyed evidence, and never to a detached
/// thread nobody can wait for again.
pub fn settle_err_log(bound: Duration) {
let _settling = ERR_LOG_SETTLE_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let pending: Vec<_> = match ERR_LOG_DRAINERS.lock() {
Ok(mut guard) => guard.drain(..).collect(),
Err(_) => return,
};
if pending.is_empty() {
return;
}
let deadline = std::time::Instant::now() + bound;
while std::time::Instant::now() < deadline && pending.iter().any(|h| !h.is_finished()) {
std::thread::sleep(Duration::from_millis(20));
}
let mut still_running = Vec::new();
for handle in pending {
if handle.is_finished() {
let _ = handle.join();
} else {
still_running.push(handle);
}
}
if !still_running.is_empty() {
if let Ok(mut guard) = ERR_LOG_DRAINERS.lock() {
// Put them back at the FRONT: they are older than anything a
// concurrent spawn pushed while this was waiting.
still_running.append(&mut guard);
*guard = still_running;
}
}
}
/// How long a death path waits for the dying run's final stderr. Bounded so a
/// wedged pipe cannot stall crash recording, generous enough to cover a
/// traceback already sitting in the drainer's buffer.
pub const ERR_LOG_SETTLE: Duration = Duration::from_secs(2);
/// Open backend_err.log for a new run: append-only (a respawn must not
/// destroy the previous run's evidence), rotated when oversized, with the
/// run's start offset returned for `ERR_LOG_RUN_START`.
@@ -866,7 +976,7 @@ pub(crate) fn spawn_backend<R: tauri::Runtime>(
// Append + per-run offset, never truncate: the previous run's stderr is
// crash evidence until someone reads it (#1510). Flush the previous
// drainer first so old buffered lines land BEFORE this run's offset.
join_previous_err_drainer(Duration::from_secs(2));
settle_err_log(Duration::from_secs(2));
let (err_log_file, err_log_start) = open_err_log_for_run(&err_path);
ERR_LOG_RUN_START.store(err_log_start, std::sync::atomic::Ordering::SeqCst);
if let Some(ref f) = err_log_file {
@@ -1050,9 +1160,7 @@ pub(crate) fn spawn_backend<R: tauri::Runtime>(
}
}
});
if let Ok(mut guard) = ERR_LOG_DRAINER.lock() {
*guard = Some(drainer);
}
track_err_drainer(drainer);
}
Some(contained)
@@ -1074,6 +1182,11 @@ mod tests {
/// other's toes (cargo runs tests in threads by default).
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// `ERR_LOG_DRAINERS` is one process-global list, and several tests push
/// a handle onto it. Without this they race: one test's settle drains and
/// joins another's thread, and both assert on state they no longer own.
static DRAINER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn a_baked_token_reaches_the_spawned_backend() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
@@ -1600,6 +1713,7 @@ mod tests {
#[test]
fn a_dying_runs_buffered_stderr_flushes_before_the_next_offset() {
let _g = DRAINER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
@@ -1612,10 +1726,10 @@ mod tests {
let mut f = fs::OpenOptions::new().append(true).open(&p).unwrap();
writeln!(f, "run1: buffered last words").unwrap();
});
*ERR_LOG_DRAINER.lock().unwrap() = Some(late);
track_err_drainer(late);
// …must land BEFORE the next run records where its output begins.
join_previous_err_drainer(Duration::from_secs(2));
settle_err_log(Duration::from_secs(2));
let (_file, start) = open_err_log_for_run(&path);
let run2 = read_error_log_tail_at(&path, start, 10);
assert!(
@@ -1812,4 +1926,155 @@ Content-Length: {}
assert!(msg.contains("another application"), "{msg}");
assert!(msg.ends_with("so the backend can't restart."), "{msg}");
}
#[test]
fn a_crash_tail_waits_for_the_dying_runs_last_words() {
let _g = DRAINER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// #1850: `wait()` returns the moment the child exits, but the stderr
// drainer is a separate thread still reading the pipe. Capturing
// `last_stderr` at that instant produced a crash report whose log
// stopped a minute before the death — the traceback that named the
// cause never made it into the file in time.
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
fs::write(&path, "steady state\n").unwrap();
let p = path.clone();
let dying = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(120));
let mut f = fs::OpenOptions::new().append(true).open(&p).unwrap();
writeln!(f, "Traceback (most recent call last):").unwrap();
writeln!(f, "RuntimeError: the actual cause").unwrap();
});
track_err_drainer(dying);
// Without the settle this reads "steady state" and nothing else.
settle_err_log(ERR_LOG_SETTLE);
let tail = read_error_log_tail_at(&path, 0, 30);
assert!(
tail.contains("RuntimeError: the actual cause"),
"the crash marker captured a tail that predates the death: {tail:?}"
);
}
#[test]
fn a_pinned_offset_survives_a_respawn_during_the_settle() {
// Greptile on #1994: settling can take up to two seconds, and a Retry
// in that window installs a new run and moves ERR_LOG_RUN_START past
// the dying run's output. Reading "the current run" after the wait
// would hand the dead process's crash marker the REPLACEMENT's healthy
// startup — the cross-run attribution #1510 exists to prevent,
// reintroduced through the wait that fixes the tail. Death paths pin
// the offset first, so the slice is the dying run's either way.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
fs::write(&path, "run1: RuntimeError: the actual cause
").unwrap();
let pinned = 0u64;
// A respawn lands mid-settle and the new run starts after that line.
let moved_on = fs::metadata(&path).unwrap().len();
fs::write(
&path,
"run1: RuntimeError: the actual cause
run2: healthy startup
",
)
.unwrap();
assert!(
read_error_log_tail_at(&path, pinned, 30).contains("the actual cause"),
"the pinned offset must still name the dying run's slice"
);
assert!(
!read_error_log_tail_at(&path, moved_on, 30).contains("the actual cause"),
"sanity: reading from the new run's offset really does lose it"
);
}
#[test]
fn a_dead_runs_slice_stops_where_the_replacement_begins() {
// CodeRabbit: a start offset with an unbounded end does not identify
// ONE run. The replacement writes BELOW the dying run's lines, and a
// tail reads the last N of the file — so the newer run's healthy
// startup is exactly what the dead run's crash marker would get.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
let run1 = "run1: RuntimeError: the actual cause
";
fs::write(&path, run1).unwrap();
let boundary = run1.len() as u64;
fs::write(&path, format!("{run1}run2: healthy startup
run2: listening
")).unwrap();
let closed = read_error_log_slice(&path, 0, Some(boundary), 30);
assert!(closed.contains("the actual cause"), "{closed}");
assert!(!closed.contains("run2"), "the replacement's output leaked in: {closed}");
// Unbounded, the tail is the replacement — the bug this closes.
let open = read_error_log_slice(&path, 0, None, 2);
assert!(open.contains("run2"), "sanity: an open end really does read the newer run");
}
#[test]
fn an_unusable_end_degrades_to_the_rest_of_the_file() {
// Same principle as an unusable start: evidence beats precision, and a
// silent empty capture is the one outcome that helps nobody.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
fs::write(&path, "only line
").unwrap();
// Past EOF, and an end that sits before the start — both mean the
// caller cannot pin the far side, so the slice runs to the end of the
// file. The start is honoured independently either way.
assert_eq!(read_error_log_slice(&path, 0, Some(10_000), 10), "only line");
assert_eq!(read_error_log_slice(&path, 5, Some(1), 10), "line");
}
#[test]
fn a_wedged_drainer_is_handed_back_rather_than_detached() {
let _g = DRAINER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// The bound exists so an orphaned grandchild holding the pipe cannot
// stall crash recording. But giving up must not drop the handle:
// every later caller would then have nothing to wait on, and the
// respawn-offset guarantee (#1510) would quietly stop holding.
let (tx, rx) = std::sync::mpsc::channel::<()>();
let wedged = std::thread::spawn(move || {
let _ = rx.recv();
});
track_err_drainer(wedged);
settle_err_log(Duration::from_millis(60));
assert!(
!ERR_LOG_DRAINERS.lock().unwrap().is_empty(),
"a drainer that outlived the bound was detached instead of retained"
);
// CodeRabbit: a NEW run installing its own drainer while the wait was
// timing out must not evict the old one. A single slot dropped it here,
// which detaches the thread — nothing could wait for that run's output
// again, and both the #1510 and #1850 guarantees silently stopped
// holding.
let (tx2, rx2) = std::sync::mpsc::channel::<()>();
track_err_drainer(std::thread::spawn(move || {
let _ = rx2.recv();
}));
settle_err_log(Duration::from_millis(60));
assert_eq!(
ERR_LOG_DRAINERS.lock().unwrap().len(),
2,
"an unfinished drainer was dropped when another run installed one"
);
let _ = tx.send(());
let _ = tx2.send(());
for handle in ERR_LOG_DRAINERS.lock().unwrap().drain(..) {
let _ = handle.join();
}
}
}
+19 -4
View File
@@ -828,7 +828,14 @@ fn spawn_backend_until_ready<R: tauri::Runtime>(
}
};
if let Some((exit_info, real_exit)) = process_dead {
let err_tail = crate::backend::read_error_log_tail_for_run(30);
// Pin the dying run's slice BEFORE waiting for its drainer: a
// Retry arriving during the settle installs a new run and moves
// the current-run offset past this output (#1850, Greptile).
let run_start = crate::backend::err_log_run_start();
// The child is gone; its last stderr may still be in the
// drainer. Let it land before anything reads the tail (#1850).
crate::backend::settle_err_log(crate::backend::ERR_LOG_SETTLE);
let err_tail = crate::backend::read_dead_run_tail(run_start, 30);
// #941: persist the forensics for every true process death —
// startup crashes included — unless the app is shutting down
// or a retry flow deliberately killed the child.
@@ -840,7 +847,10 @@ fn spawn_backend_until_ready<R: tauri::Runtime>(
crate::crash::record_crash(crate::crash::marker_now(
exit,
backend_uptime_s(app),
crate::backend::read_error_log_tail_for_run(CRASH_STDERR_TAIL_LINES),
crate::backend::read_dead_run_tail(
run_start,
CRASH_STDERR_TAIL_LINES,
),
));
}
}
@@ -1442,6 +1452,11 @@ fn supervise_backend<R: tauri::Runtime>(
attachment_lifecycle = Some(lifecycle);
}
let exit_info = exit.description.clone();
// Same as the startup path: pin this run's slice, then wait for its
// drainer. `wait()` beat the drainer to the punch, and a Retry during
// the wait would otherwise move the offset onto the replacement run.
let run_start = crate::backend::err_log_run_start();
crate::backend::settle_err_log(crate::backend::ERR_LOG_SETTLE);
// #941: make the death self-documenting BEFORE any restart attempt —
// the marker (exit code/signal + stderr tail + uptime) is what turns
// the next "Can't reach the backend" report into a diagnosable one.
@@ -1456,7 +1471,7 @@ fn supervise_backend<R: tauri::Runtime>(
crate::crash::record_crash(crate::crash::marker_now(
&exit,
uptime_s,
crate::backend::read_error_log_tail_for_run(CRASH_STDERR_TAIL_LINES),
crate::backend::read_dead_run_tail(run_start, CRASH_STDERR_TAIL_LINES),
));
} else {
log::info!(
@@ -1464,7 +1479,7 @@ fn supervise_backend<R: tauri::Runtime>(
);
}
if restart_budget_exhausted(&mut restart_times, Instant::now()) {
let tail = crate::backend::read_error_log_tail_for_run(30);
let tail = crate::backend::read_dead_run_tail(run_start, 30);
let msg = format!(
"The backend kept {} ({} times in {} min; last stop: {}) and couldn't \
be kept running. Use Clean & Retry, or check Settings → Logs → Backend.{}",