Merge branch 'codex/consolidate-1809' into codex/pr-queue-integration

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Palash Debnath
2026-09-07 11:10:11 +05:30
5 changed files with 319 additions and 14 deletions
+1
View File
@@ -28,6 +28,7 @@ the frozen-backend fallback mirror it for their toolchains.
- Voice reference preparation reclaims allocator memory before one bounded retry, then reports persistent GPU out-of-memory failures (#1811)
- `bun run desktop` now opens on a fresh clone: the Vite alias for `@tauri-apps/plugin-dialog` no longer assumes a nested `frontend/node_modules`, which bun's workspace hoisting leaves empty (#1818) — thanks @flutterkage2k!
- Slow backend startups remain running with progress updates, and Retry can interrupt startup reliably (#1809)
## [0.5.2] — 2026-09-02
+158 -13
View File
@@ -265,6 +265,9 @@ pub fn respawn_backend<R: tauri::Runtime>(
stage: Arc<Mutex<BootstrapStage>>,
logs: Arc<Mutex<Vec<LogPayload>>>,
) {
// Before anything reaches for lifecycle ownership: a readiness wait may be
// holding it while a slow backend starts (#1791).
preempt_backend_wait();
if let Ok(mut guard) = stage.lock() {
*guard = BootstrapStage::Checking;
}
@@ -308,6 +311,7 @@ pub fn with_backend_stopped<R: tauri::Runtime, T>(
app: &tauri::AppHandle<R>,
action: impl FnOnce() -> T,
) -> Result<T, String> {
preempt_backend_wait();
let state = app.state::<BackendState>();
let _lifecycle = state.lifecycle.lock().unwrap_or_else(|e| e.into_inner());
if let Err(error) = stop_backend_locked(app) {
@@ -615,11 +619,18 @@ fn launch_backend_and_wait<R: tauri::Runtime>(
stage_handle: &Arc<Mutex<BootstrapStage>>,
first_run_gate: bool,
) {
// Preserve cancellation arriving while waiting for ownership or preparing launch.
let wait_generation = backend_wait_generation();
let outcome = {
let state = app.state::<BackendState>();
let _lifecycle = state.lifecycle.lock().unwrap_or_else(|e| e.into_inner());
if backend_stop_requested(app) {
log::info!("App is quitting — backend launch cancelled");
#[cfg(debug_assertions)]
wait_for_tracking_test_gate(
"OMNIVOICE_TEST_LAUNCH_LOCKED_ENTERED",
"OMNIVOICE_TEST_LAUNCH_LOCKED_RELEASE",
);
if backend_stop_requested(app) || backend_wait_generation() != wait_generation {
log::info!("Backend launch cancelled before preparation");
LaunchOutcome::Done
} else {
match prepare_backend_launch(app, stage_handle) {
@@ -638,10 +649,10 @@ fn launch_backend_and_wait<R: tauri::Runtime>(
set_stage(stage_handle, BootstrapStage::AwaitingSetup);
LaunchOutcome::Done
} else {
spawn_with_supervisor_owner(app, stage_handle)
spawn_with_supervisor_owner(app, stage_handle, wait_generation)
}
} else {
spawn_with_supervisor_owner(app, stage_handle)
spawn_with_supervisor_owner(app, stage_handle, wait_generation)
}
}
}
@@ -659,9 +670,10 @@ fn launch_backend_and_wait<R: tauri::Runtime>(
fn spawn_with_supervisor_owner<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
stage_handle: &Arc<Mutex<BootstrapStage>>,
wait_generation: u64,
) -> LaunchOutcome {
let supervisor_owner = SUPERVISOR_OWNER.fetch_add(1, Ordering::SeqCst) + 1;
if spawn_backend_until_ready(app, stage_handle) {
if spawn_backend_until_ready(app, stage_handle, wait_generation) {
LaunchOutcome::SupervisedReady {
owner: supervisor_owner,
}
@@ -680,25 +692,39 @@ fn spawn_with_supervisor_owner<R: tauri::Runtime>(
fn spawn_backend_until_ready<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
stage_handle: &Arc<Mutex<BootstrapStage>>,
wait_generation: u64,
) -> bool {
let mut venv_heal_attempted = false;
'bootstrap: loop {
if backend_stop_requested(app) {
if backend_stop_requested(app) || backend_wait_generation() != wait_generation {
return false;
}
spawn_and_track_backend(app, stage_handle);
let start = std::time::Instant::now();
// Latest `/startup/progress` status, or None while nothing answers.
let mut last_status: Option<String> = None;
// Early-bind narration: the backend answers /startup/progress within
// ~1s of spawn, long before it is Ready — surface each step change
// as a log line so the splash shows "Loading ML runtime (PyTorch)…"
// instead of a silent 300s wait. An old backend (no endpoint) yields
// None and the wait looks exactly as it did before.
let mut last_step = String::new();
while start.elapsed() < startup_budget() {
// #1791: the clock measures time since the last sign of life, not time
// since spawn — see `keep_waiting_for_backend`.
let mut last_progress = start;
while keep_waiting_for_backend(last_status.as_deref(), last_progress.elapsed(), startup_budget())
{
if backend_stop_requested(app) {
log::info!("App is quitting — backend startup poll cancelled");
return false;
}
if backend_wait_generation() != wait_generation {
log::info!(
"Retry/reset is taking the backend lifecycle — standing down from \
the startup wait so it can proceed"
);
return false;
}
if crate::backend::backend_ready(backend_port()) {
set_stage(stage_handle, BootstrapStage::Ready);
return true;
@@ -838,13 +864,20 @@ fn spawn_backend_until_ready<R: tauri::Runtime>(
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
return false;
}
if let Some((status, step, label)) =
crate::backend::startup_progress(backend_port())
{
if status == "starting" && !step.is_empty() && step != last_step {
last_step = step;
emit_log(app, "starting_backend", &format!("Startup: {label}"));
match crate::backend::startup_progress(backend_port()) {
Some((status, step, label)) => {
if status == "starting" {
// Alive, serving, and naming the step it is on — that is
// the evidence the wait is keyed on (#1791).
last_progress = std::time::Instant::now();
if !step.is_empty() && step != last_step {
last_step = step;
emit_log(app, "starting_backend", &format!("Startup: {label}"));
}
}
last_status = Some(status);
}
None => last_status = None,
}
std::thread::sleep(Duration::from_millis(500));
}
@@ -889,6 +922,30 @@ static SUPERVISOR_OWNER: AtomicU64 = AtomicU64::new(0);
/// moment a fresh child is spawned and tracked (`track_backend_child`).
static BACKEND_KILL_INTENDED: AtomicBool = AtomicBool::new(false);
/// Bumped by any flow about to take backend lifecycle ownership for a
/// deliberate replacement — Retry, Clean & Retry, reset, uninstall.
///
/// #1791: the readiness wait keeps waiting for as long as the backend answers
/// `/startup/progress`, and it holds `BackendState::lifecycle` the whole time
/// (`launch_backend_and_wait` takes it around the entire launch). Without a way
/// to interrupt that wait, the user's own escape hatch would deadlock behind
/// it: Retry and Clean & Retry both need the same lock, so pressing either on
/// a slow start would hang instead of restarting anything — trading a backend
/// killed too early for an app with no way out, which is worse. Every such
/// flow bumps this BEFORE reaching for the lock; the waiting loop sees the
/// change within one poll, returns, and releases it (Greptile, #1809).
static BACKEND_WAIT_GENERATION: AtomicU64 = AtomicU64::new(0);
/// Ask any in-flight readiness wait to stand down, so this caller can take
/// lifecycle ownership. Call before locking, never while holding the lock.
pub fn preempt_backend_wait() {
BACKEND_WAIT_GENERATION.fetch_add(1, Ordering::SeqCst);
}
fn backend_wait_generation() -> u64 {
BACKEND_WAIT_GENERATION.load(Ordering::SeqCst)
}
/// Bumped every time `track_backend_child` installs a new child. The
/// supervisor snapshots it when it observes a death; a change during its
/// backoff pause means ANOTHER flow (Retry / Clean & Retry) spawned and
@@ -1124,6 +1181,42 @@ fn startup_budget() -> Duration {
.unwrap_or(Duration::from_secs(300))
}
/// Should the readiness poll keep waiting for a backend that is not Ready yet?
///
/// `status` is the `status` field of the latest `/startup/progress` reply, or
/// `None` while nothing answers on the port.
///
/// #1791: this used to be a flat `elapsed < startup_budget()` from spawn, so a
/// host where the cold start genuinely takes longer than five minutes — a
/// project on a mapped network drive, a cold `import torch` off a spinning
/// disk, a first CUDA DLL load — had its backend killed *while it was still
/// importing*, reported as "the backend never reported ready". The respawn
/// then threw away the warm work and raced the same clock again, so the app
/// could never start even though launching the same backend by hand reached
/// ready in under a minute.
///
/// A backend answering `status: "starting"` is not a backend we have to guess
/// about: it bound its socket, it is serving HTTP, and it is telling us which
/// step it is on. Killing it cannot make the next attempt faster, and the
/// launcher has no information the user lacks — so keep waiting and keep
/// narrating. The user's escape hatch is deliberate rather than clock-driven:
/// the splash's own stall budget surfaces Retry and the logs, and its
/// `/health` recovery poll walks straight into the app if the slow start does
/// finish. The budget still governs *silence* — nothing answering, or a
/// `failed`/unknown status — because there we truly cannot tell a slow
/// backend from a wedged one, and the existing failure path (stderr tail +
/// Retry) is the right answer.
fn keep_waiting_for_backend(
status: Option<&str>,
since_progress: Duration,
budget: Duration,
) -> bool {
match status {
Some("starting") => true,
_ => since_progress < budget,
}
}
/// The supervisor's death-detection poll interval. 2s in production;
/// `OMNIVOICE_SUPERVISOR_POLL_MS` shrinks it for the harness only.
fn supervisor_poll() -> Duration {
@@ -3396,6 +3489,58 @@ mod tests {
std::env::remove_var("OMNIVOICE_SUPERVISOR_POLL_MS");
}
#[test]
fn a_backend_that_is_still_starting_is_never_timed_out() {
let budget = Duration::from_secs(300);
// #1791: the whole point — a slow cold start on a network drive or a
// cold disk keeps waiting no matter how long it has already taken,
// because it is demonstrably alive and naming its current step.
assert!(keep_waiting_for_backend(
Some("starting"),
Duration::from_secs(3_600),
budget
));
// Silence is the case we cannot read, so the budget still governs it:
// wait up to the budget, then fail with the stderr tail as before.
assert!(keep_waiting_for_backend(None, Duration::from_secs(299), budget));
assert!(!keep_waiting_for_backend(None, budget, budget));
// A backend that reports its own startup failure gets no extension —
// it is not making progress and never will.
assert!(!keep_waiting_for_backend(
Some("failed"),
Duration::from_secs(301),
budget
));
// An unrecognised status is treated as silence, not as liveness.
assert!(!keep_waiting_for_backend(
Some("wat"),
Duration::from_secs(301),
budget
));
}
#[test]
fn a_retry_preempts_an_in_flight_readiness_wait() {
// #1791 + Greptile on #1809: the wait holds lifecycle ownership, which
// Retry and Clean & Retry both need. A waiter that cannot be asked to
// stand down turns a slow start into an app with no way out — strictly
// worse than the early kill this fix removed. The waiter snapshots the
// generation before ownership is acquired; a later bump means someone else is
// taking over.
let snapshot = backend_wait_generation();
assert_eq!(backend_wait_generation(), snapshot, "nothing changed yet");
preempt_backend_wait();
assert_ne!(
backend_wait_generation(),
snapshot,
"Retry must be visible to the waiting loop"
);
// And the flow that preempted then takes its OWN snapshot, so it does
// not immediately cancel itself.
let theirs = backend_wait_generation();
assert_eq!(backend_wait_generation(), theirs);
}
#[test]
fn restart_backoff_escalates_but_first_respawn_is_immediate() {
// A one-off crash self-heals with zero added latency; repeat deaths
@@ -345,6 +345,8 @@ const SCENARIO_ENV: &[&str] = &[
"OMNIVOICE_TEST_BEFORE_TRACK_RELEASE",
"OMNIVOICE_TEST_AFTER_TRACK_ENTERED",
"OMNIVOICE_TEST_AFTER_TRACK_RELEASE",
"OMNIVOICE_TEST_LAUNCH_LOCKED_ENTERED",
"OMNIVOICE_TEST_LAUNCH_LOCKED_RELEASE",
"OMNIVOICE_BACKEND_CMD",
"OMNIVOICE_LOG_DIR",
"OMNIVOICE_PORT",
@@ -1760,3 +1762,29 @@ fn deferred_startup_failure_names_the_step() {
logs.iter().map(|l| &l.line).collect::<Vec<_>>()
);
}
#[test]
fn retry_preempts_launch_before_the_readiness_wait_starts() {
let t = TestApp::new(&Scenario { serve_ms: Some(0), ..Default::default() });
std::env::set_var("OMNIVOICE_SCENARIO_PROGRESS_ONLY", "1");
let entered = t._logdir.path().join("launch-locked");
let release = t._logdir.path().join("release-launch");
std::env::set_var("OMNIVOICE_TEST_LAUNCH_LOCKED_ENTERED", &entered);
std::env::set_var("OMNIVOICE_TEST_LAUNCH_LOCKED_RELEASE", &release);
let bootstrap = t.run_bootstrap();
assert!(wait_until(Duration::from_secs(5), || entered.exists()));
app_lib::bootstrap::preempt_backend_wait();
let handle = t.handle();
let retry = std::thread::spawn(move || {
let state = handle.state::<BackendState>();
let _ownership = state.lifecycle.lock().unwrap();
});
std::fs::write(&release, b"release").unwrap();
let acquired = wait_until(Duration::from_secs(3), || retry.is_finished());
t.quit();
t.kill_tracked_child();
join_with_timeout(bootstrap, Duration::from_secs(10), "preempted launch");
join_with_timeout(retry, Duration::from_secs(10), "retry ownership");
assert!(acquired, "old launch swallowed Retry's generation and held lifecycle");
}
+24 -1
View File
@@ -365,6 +365,27 @@ function IpcLostRecovery({ t }) {
}
/** Mono error block. */
/** When the backend last produced ANY line of bootstrap output.
*
* #1791: the stall watchdog in `useBootstrapStage` keys on `bootstrap_status`,
* which sits on a single stage for the whole of a slow start — a cold `import
* torch` off a mapped network drive can hold `starting_backend` for many
* minutes. The narration that proves it is alive ("Loading ML runtime
* (PyTorch)…") arrives on the separate `bootstrap-log` event stream instead,
* so the watchdog never saw it and called a live launch stuck. A splash that
* is still printing new lines is by definition not the info-less spinner the
* watchdog exists to break.
*
* Module-scoped rather than a ref because the two halves live in different
* components — `BootstrapSplash` owns the event subscription, `useBootstrapStage`
* owns the watchdog — and they are always mounted as a pair.
*/
let lastBootstrapLogTs = 0;
export function noteBootstrapLogActivity(ts = Date.now()) {
lastBootstrapLogTs = ts;
}
function ErrorBox({ children }) {
return (
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words rounded-md bg-danger/10 px-3 py-2 font-mono text-[0.66rem] leading-relaxed text-danger shadow-[inset_2px_0_0_var(--color-danger)]">
@@ -523,6 +544,7 @@ export function BootstrapSplash({ stage, message }) {
unlistenLog = await listen('bootstrap-log', (e) => {
const { stage: s, line } = e.payload || {};
if (!line) return;
noteBootstrapLogActivity();
setLogs((prev) => {
// Deduplicate against backfill by checking the last few lines.
const lastFew = prev.slice(-5);
@@ -982,7 +1004,8 @@ export function useBootstrapStage(pollMs = 1000) {
}
// Rust returns { stage: 'ready' } or { stage: 'failed', message: '…' } etc.
if (stage !== 'ready' && stage !== 'failed') {
if (Date.now() - lastChangeTs > stallBudgetMs(stage)) {
const lastActivity = Math.max(lastChangeTs, lastBootstrapLogTs);
if (Date.now() - lastActivity > stallBudgetMs(stage)) {
// Stuck — surface it as a failure so Retry/logs/hints appear.
setState({
stage: 'failed',
@@ -0,0 +1,108 @@
/**
* #1791 a backend that is still narrating its startup is not stuck.
*
* The reporter's project lived on a mapped network drive, where the cold
* `import torch` took far longer than the shell's five-minute readiness
* budget. The shell killed the still-importing backend and respawned it, the
* respawn raced the same clock, and the app reported "the backend never
* reported ready" while launching the very same backend by hand reached
* ready in under a minute once the file cache was warm.
*
* Rust now keeps waiting for as long as `/startup/progress` answers (see
* `keep_waiting_for_backend` in bootstrap.rs). This is the splash's half of
* that contract: the stall watchdog keys on `bootstrap_status`, which sits on
* `starting_backend` for the entire slow start, so on its own it would still
* declare a false failure at six minutes. The proof of life arrives on the
* `bootstrap-log` stream instead, and that has to count as activity.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useBootstrapStage, noteBootstrapLogActivity } from '../components/BootstrapSplash';
const invokeMock = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args) => invokeMock(...args),
}));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock('@tauri-apps/plugin-opener', () => ({
revealItemInDir: vi.fn(),
}));
let warnSpy;
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
invokeMock.mockReset();
vi.useFakeTimers();
window.__TAURI_INTERNALS__ = {};
vi.stubEnv('DEV', false);
// The backend has not bound its port yet, so nothing answers /health the
// same condition that keeps the #879 IPC watchdog out of the way.
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('ECONNREFUSED');
}),
);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
warnSpy.mockRestore();
delete window.__TAURI_INTERNALS__;
// Module-scoped signal clear it so a later test's silent backend really is
// silent.
noteBootstrapLogActivity(0);
});
describe('useBootstrapStage — a narrating backend is not stuck (#1791)', () => {
it('keeps waiting past the stall budget while startup steps still arrive', async () => {
invokeMock.mockImplementation(async (cmd) =>
cmd === 'bootstrap_status' ? { stage: 'starting_backend' } : undefined,
);
const { result } = renderHook(() => useBootstrapStage());
await act(async () => {});
expect(result.current.stage).toBe('starting_backend');
// Twenty minutes of a genuinely slow cold start, with the backend
// reporting a new step every four minutes well inside the six-minute
// budget each time, but far past it in total.
for (let i = 0; i < 5; i += 1) {
await act(async () => {
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
});
expect(result.current.stage).toBe('starting_backend');
expect(result.current.message ?? '').not.toMatch(/stuck/i);
act(() => {
noteBootstrapLogActivity();
});
}
expect(result.current.stage).toBe('starting_backend');
expect(result.current.message ?? '').not.toMatch(/stuck/i);
});
it('still calls a genuinely silent backend stuck', async () => {
// The other half: output is the evidence, and with none the watchdog has
// to keep breaking the info-less spinner it exists for (#879).
invokeMock.mockImplementation(async (cmd) =>
cmd === 'bootstrap_status' ? { stage: 'starting_backend' } : undefined,
);
const { result } = renderHook(() => useBootstrapStage());
await act(async () => {});
await act(async () => {
await vi.advanceTimersByTimeAsync(6 * 60 * 1000 + 2_000);
});
expect(result.current.stage).toBe('failed');
expect(result.current.message).toMatch(/stuck/i);
});
});