fix(splash): IPC-independent watchdog + recovery panel for dead Tauri IPC (#879) (#892)

After an unclean shutdown (Windows BSOD), the WebView2 profile cache
(%LOCALAPPDATA%\com.debpalash.omnivoice-studio\EBWebView) can corrupt:
Tauri's IPC custom protocol fails AND the postMessage fallback breaks,
so invoke() hangs forever. useBootstrapStage's poll loop rode entirely
on that IPC — a hung bootstrap_status call silently killed the loop and
the splash sat at "preparing" forever, even with a fully healthy
backend answering over plain HTTP.

Class fix, three parts:

- splashWatchdog.js: IPC-independent escape hatch. If no IPC signal
  arrives within 10s, poll GET /health over plain HTTP; healthy →
  proceed to the app as if 'ready' was received (console.warn
  breadcrumb so diagnostic bundles carry it). Any successful IPC
  response disarms it for good.
- Recovery panel (stage 'ipc_lost'): if neither IPC nor HTTP succeed
  within 45s, show an actionable panel instead of the infinite
  spinner — "Open logs" (with an inline path fallback when IPC is
  dead) and, Windows-only and only in this error state, "Repair and
  restart". Health polling continues behind the panel so a slow
  first-run install with broken IPC still reaches the app.
- clear_webview_cache_and_relaunch (Rust): writes a marker and
  relaunches; the fresh process deletes EBWebView at the top of run()
  before any webview exists (WebView2 holds locks while running),
  with a bounded retry while the old instance exits. Runtime cfg!
  guards keep the whole path compiling on every platform.

Tauri 2 exposes no reliable flag for the postMessage-fallback mode
(closure-local in its injected ipc.js), so the logged detector is the
observable combination: zero IPC signals + working plain HTTP.

Fail-before/pass-after regression tests: hung invoke + healthy HTTP →
ready; hung invoke + dead backend → recovery panel, then auto-continue;
working IPC → normal path untouched, zero HTTP polling. Plus watchdog
state-machine unit tests and recovery-panel render/interaction tests
(6/7 fail on the pre-fix component). Troubleshooting doc gains the
matching section (docs-sync).

Fixes #879

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-02 18:52:44 +05:30
committed by GitHub
co-authored by mergetest Claude Fable 5
parent 6e600c48cb
commit 86f5213055
9 changed files with 805 additions and 3 deletions
+33
View File
@@ -355,6 +355,39 @@ capacity is restored automatically (no app restart needed). Tune the bounds with
**Raise** them for very long single files/generations, **lower** them to fail
faster on a small machine.
## 15. Stuck at "preparing" forever after a crash / BSOD (Windows)
**Symptom:** after an unclean shutdown (Windows BSOD, forced power-off), every
launch sits on the "preparing" splash indefinitely — even though the backend is
actually healthy (its log shows models loaded, and
`http://127.0.0.1:3900/health` answers `{"status":"ok"}` in a browser). The
WebView log contains:
```
IPC custom protocol failed, Tauri will now use the postMessage interface instead
TypeError: Failed to fetch
```
**Cause:** the crash corrupted the WebView2 profile cache at
`%LOCALAPPDATA%\com.debpalash.omnivoice-studio\EBWebView`. Both the IPC custom
protocol *and* its postMessage fallback break, so the splash never hears the
"ready" signal from the app shell (issue #879).
**Fix:** current builds handle this automatically — if the splash gets no IPC
signal within ~10 s it checks the backend over plain HTTP and proceeds on its
own; if the backend isn't up either, after ~45 s a recovery panel appears with
**Repair and restart** (Windows), which clears the WebView cache and relaunches.
Your voices, projects, and settings are not touched — only browser display data
is cleared.
On older builds (≤ 0.3.8), or if the automatic repair fails, do it manually:
quit OmniVoice Studio, delete the folder below, then start the app again.
<!-- validate: skip -->
```powershell
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\com.debpalash.omnivoice-studio\EBWebView"
```
## Dub: "translation engine needs the optional … package"
**Symptom:** in the Dub tab, translating fails with e.g. *"The 'google'
+100
View File
@@ -528,6 +528,106 @@ pub fn save_text_file(path: String, contents: String) -> Result<(), String> {
std::fs::write(p, contents).map_err(|e| format!("write: {e}"))
}
// ── WebView cache repair (issue #879) ─────────────────────────────────────
//
// After an unclean shutdown (e.g. a Windows BSOD), WebView2's profile cache
// (%LOCALAPPDATA%\<identifier>\EBWebView) can corrupt. Tauri's IPC custom
// protocol then fails ("IPC custom protocol failed, Tauri will now use the
// postMessage interface instead") and the postMessage fallback can break too,
// so the splash never hears bootstrap events even with a healthy backend.
// The splash's recovery panel (Windows-only affordance, error-state only)
// calls `clear_webview_cache_and_relaunch` to fix it in one click.
//
// Deleting EBWebView from inside a running app fails — the WebView2 browser
// processes hold locks on the profile — so this is a two-step dance:
// 1. the command writes a marker file next to the cache and relaunches;
// 2. the fresh process calls `clear_webview_cache_if_marked()` at the very
// top of `run()`, before any webview exists, and deletes the cache
// there — retrying briefly while the old instance's WebView2 children
// finish exiting.
//
// Everything below compiles on every platform (runtime `cfg!` guards, not
// `#[cfg]`) so a macOS/Linux `cargo check` validates the whole path; the
// behavior itself is Windows-only and the frontend never renders the button
// elsewhere.
const CLEAR_WEBVIEW_MARKER: &str = ".clear-webview-cache";
const WEBVIEW_CACHE_DIR: &str = "EBWebView";
/// (marker file, cache dir) under the pre-app local data dir. Mirrors
/// `config::config_path_pre_app()` — `%LOCALAPPDATA%\<identifier>` on
/// Windows — because step 2 runs before an `AppHandle` exists.
fn webview_cache_paths() -> Option<(PathBuf, PathBuf)> {
let base = dirs_next::data_local_dir()?.join(crate::config::BUNDLE_IDENTIFIER);
Some((base.join(CLEAR_WEBVIEW_MARKER), base.join(WEBVIEW_CACHE_DIR)))
}
#[tauri::command]
pub fn clear_webview_cache_and_relaunch(app: tauri::AppHandle) -> Result<(), String> {
if !cfg!(target_os = "windows") {
return Err("WebView cache repair is only available on Windows (WebView2)".into());
}
let (marker, cache) = webview_cache_paths()
.ok_or_else(|| "could not resolve the local app data directory".to_string())?;
if let Some(parent) = marker.parent() {
let _ = fs::create_dir_all(parent);
}
fs::write(&marker, b"requested by the splash recovery panel (issue #879)\n")
.map_err(|e| format!("write {}: {e}", marker.display()))?;
log::warn!(
"WebView cache repair requested (#879) — relaunching to clear {}",
cache.display()
);
app.restart()
}
/// Startup half of the repair: if the previous run left the marker, delete
/// the WebView2 profile cache before any webview is created. Called at the
/// top of `run()`. One-shot by design — the marker is removed first so a
/// failing repair can never loop across launches.
pub fn clear_webview_cache_if_marked() {
if !cfg!(target_os = "windows") {
return;
}
let Some((marker, cache)) = webview_cache_paths() else {
return;
};
if !marker.exists() {
return;
}
let _ = fs::remove_file(&marker);
if !cache.exists() {
return;
}
// `app.restart()` spawns the new process before the old one has fully
// exited, so its WebView2 children may still hold locks — retry briefly.
const ATTEMPTS: u32 = 20;
for attempt in 1..=ATTEMPTS {
match fs::remove_dir_all(&cache) {
Ok(()) => {
log::warn!(
"cleared WebView2 profile cache at {} (attempt {attempt}) — issue #879 repair",
cache.display()
);
return;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) if attempt < ATTEMPTS => {
log::debug!("WebView2 cache still locked ({e}) — retrying");
std::thread::sleep(Duration::from_millis(500));
}
Err(e) => {
// Never brick startup over a failed repair: WebView2 rebuilds
// whatever subset survived, and the user can retry.
log::error!(
"could not fully clear WebView2 cache at {}: {e} — continuing startup",
cache.display()
);
}
}
}
}
#[cfg(test)]
mod paste_error_tests {
use super::{kind_err, CLIPBOARD_RESTORE_DELAY};
+3 -1
View File
@@ -154,7 +154,9 @@ pub fn load_config_pre_app() -> AppConfig {
.unwrap_or_default()
}
const BUNDLE_IDENTIFIER: &str = "com.debpalash.omnivoice-studio";
/// Also used by `commands::webview_cache_paths` (#879) to locate the WebView2
/// profile cache before an `AppHandle` exists.
pub const BUNDLE_IDENTIFIER: &str = "com.debpalash.omnivoice-studio";
fn config_path_pre_app() -> Option<PathBuf> {
portable_config_file()
+6
View File
@@ -205,6 +205,11 @@ mod media_permission_tests {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// #879: if the previous run requested a WebView cache repair (splash
// recovery panel → clear_webview_cache_and_relaunch), perform it now —
// before any webview exists, so WebView2 holds no locks on the profile.
commands::clear_webview_cache_if_marked();
// ── Detect pill mode from CLI args OR persisted config ────────────────
// CLI flag takes precedence. If not passed, fall back to the
// `launch_as_widget` config field (set via tray "Switch to Pill Mode" or
@@ -265,6 +270,7 @@ pub fn run() {
commands::set_dictation_shortcut,
commands::get_launch_as_widget,
commands::set_launch_as_widget,
commands::clear_webview_cache_and_relaunch,
])
.setup(move |app| {
app.handle().plugin(tauri_plugin_dialog::init())?;
+169 -2
View File
@@ -13,12 +13,24 @@
* firstrun.css so setup → install → model wizard reads as one experience.
*/
import { Suspense, lazy, useEffect, useMemo, useRef, useState } from 'react';
import { Brush, Check, ChevronDown, ChevronRight, Clipboard, Globe, Lightbulb } from 'lucide-react';
import {
Brush,
Check,
ChevronDown,
ChevronRight,
Clipboard,
FolderOpen,
Globe,
Lightbulb,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { copyText } from '../utils/copyText';
import { useTranslation } from 'react-i18next';
import i18n, { LANGUAGES } from '../i18n';
import { useAppStore } from '../store';
import { getApiBase } from '../utils/apiBase';
import { startSplashWatchdog } from '../utils/splashWatchdog';
import { Button, Progress, Select } from '../ui';
// First-run only: keep the setup screen out of the main bundle so every
@@ -64,8 +76,39 @@ const STAGE_LABEL = {
starting_backend: 'Starting backend…',
ready: 'Ready',
failed: 'Setup failed',
ipc_lost: 'Startup issue detected',
};
/** Race a promise against a timeout. Used for IPC calls made from the
* recovery panel (#879): the whole point of that state is that IPC may be
* hung, so every invoke gets a bounded wait + a manual fallback. */
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('ipc timeout')), ms)),
]);
}
/** Platform-default log directory, computed client-side (no IPC available in
* the recovery state). Mirrors src-tauri/src/backend.rs `backend_log_path()`.
* The Windows form uses %LOCALAPPDATA% literally — Explorer expands it. */
function defaultLogDirForPlatform() {
const ua = typeof navigator !== 'undefined' ? navigator.userAgent || '' : '';
if (ua.includes('Windows')) return '%LOCALAPPDATA%\\OmniVoice\\Logs';
if (ua.includes('Mac')) return '~/Library/Logs/OmniVoice';
return '~/.local/state/OmniVoice';
}
/** WebView2 profile cache path shown in the manual-repair fallback (#879). */
const WEBVIEW_CACHE_PATH_WIN = '%LOCALAPPDATA%\\com.debpalash.omnivoice-studio\\EBWebView';
/** True on Windows. Deliberately reads the user agent, NOT a Tauri plugin —
* in the recovery state IPC is presumed dead, so OS detection must not
* round-trip through it. */
function isWindowsUA() {
return typeof navigator !== 'undefined' && (navigator.userAgent || '').includes('Windows');
}
const STEPS = [
'checking',
'downloading_uv',
@@ -198,6 +241,93 @@ function JourneyRail({ t }) {
);
}
/**
* Recovery panel for the stuck-startup state (#879): the Tauri IPC layer is
* silent AND the backend never answered /health within the recovery window.
* Explains what happened and offers actionable exits instead of an infinite
* spinner. The "Repair and restart" affordance is Windows-only (it clears the
* WebView2 `EBWebView` profile cache — a Windows-specific artifact) and only
* exists inside this error-recovery state, never as default-mode UI.
*/
function IpcLostRecovery({ t }) {
const [showLogHint, setShowLogHint] = useState(false);
const [repairing, setRepairing] = useState(false);
const [repairFailed, setRepairFailed] = useState(false);
const handleOpenLogs = async () => {
try {
// Best effort over IPC (it may be partially alive); bounded so a hung
// invoke can't make the button feel dead.
const { invoke } = await import('@tauri-apps/api/core');
const tail = await withTimeout(invoke('read_log_tail', { source: 'backend' }), 3000);
if (!tail?.path) throw new Error('no log path');
const { revealItemInDir } = await import('@tauri-apps/plugin-opener');
await withTimeout(revealItemInDir(tail.path), 3000);
} catch {
// IPC is dead (the expected case here) — show where the logs live.
setShowLogHint(true);
}
};
const handleRepairRestart = async () => {
if (repairing) return;
if (!confirm(t('bootstrap.ipc_lost_repair_confirm'))) return;
setRepairing(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
// On success the process relaunches and this promise never settles;
// the timeout only fires when the IPC layer is too broken even for
// this one call — then we fall back to manual instructions.
await withTimeout(invoke('clear_webview_cache_and_relaunch'), 8000);
} catch (e) {
if (e?.message !== 'ipc timeout') console.error('repair failed', e);
setRepairFailed(true);
setRepairing(false);
}
};
return (
<section className="fr-rise flex flex-col gap-2.5" style={{ '--rise': 1 }}>
<h2 className="m-0 font-mono text-[0.62rem] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{t('bootstrap.ipc_lost_title', "The app can't finish starting")}
</h2>
<p className="m-0 text-sm leading-relaxed text-fg-muted">{t('bootstrap.ipc_lost_body')}</p>
{showLogHint && (
<ErrorBox>
{t('bootstrap.ipc_lost_log_hint', { path: defaultLogDirForPlatform() })}
</ErrorBox>
)}
{repairFailed && (
<ErrorBox>
{t('bootstrap.ipc_lost_repair_failed', { path: WEBVIEW_CACHE_PATH_WIN })}
</ErrorBox>
)}
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleOpenLogs}
leading={<FolderOpen size={12} />}
>
{t('bootstrap.ipc_lost_open_logs', 'Open logs')}
</Button>
{isWindowsUA() && (
<Button
variant="primary"
onClick={handleRepairRestart}
disabled={repairing}
leading={<Wrench size={12} />}
>
{repairing
? t('bootstrap.ipc_lost_repairing', 'Repairing…')
: t('bootstrap.ipc_lost_repair', 'Repair and restart')}
</Button>
)}
</div>
</section>
);
}
/** Mono error block. */
function ErrorBox({ children }) {
return (
@@ -493,7 +623,9 @@ export function BootstrapSplash({ stage, message }) {
</div>
)}
{isFailed ? (
{stage === 'ipc_lost' ? (
<IpcLostRecovery t={t} />
) : isFailed ? (
<section className="fr-rise flex flex-col gap-2.5" style={{ '--rise': 1 }}>
<h2 className="m-0 font-mono text-[0.62rem] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{t('bootstrap.failed', 'Setup failed')}
@@ -669,6 +801,29 @@ export function useBootstrapStage(pollMs = 1000) {
let cancelled = false;
let timer = null;
let misses = 0;
// IPC watchdog (#879): the poll loop below rides entirely on Tauri IPC.
// After an unclean shutdown, a corrupted WebView cache can break BOTH the
// IPC custom protocol and its postMessage fallback — `invoke()` then hangs
// without ever resolving OR rejecting, so neither the stall watchdog
// (#474) nor the miss counter below can fire, and the splash would spin
// forever even with a healthy backend. This watchdog is IPC-independent:
// if no `bootstrap_status` response arrives at all, it polls /health over
// plain HTTP and either proceeds to the app ('ready') or flips to the
// 'ipc_lost' recovery panel. Started synchronously, before the dynamic
// import — in a corrupted-webview world even that import may stall.
let httpForcedReady = false;
const watchdog = startSplashWatchdog({
healthUrl: `${getApiBase()}/health`,
onReadyViaHttp: () => {
if (cancelled) return;
httpForcedReady = true;
setState({ stage: 'ready', message: null });
},
onStuck: () => {
if (cancelled || httpForcedReady) return;
setState({ stage: 'ipc_lost', message: null });
},
});
// Stall watchdog (#474): if the backend hangs in a non-terminal stage and
// never reports `ready` (e.g. a failed Python-backend spawn on a from-source
// build), the poll loop would otherwise spin forever and trap the user on a
@@ -691,6 +846,7 @@ export function useBootstrapStage(pollMs = 1000) {
(async () => {
const tauriInvoke = await invoke();
if (!tauriInvoke) {
watchdog.cancel();
setState({ stage: 'ready', message: null });
return;
}
@@ -699,6 +855,12 @@ export function useBootstrapStage(pollMs = 1000) {
try {
const res = await tauriInvoke('bootstrap_status');
if (cancelled) return;
// IPC answered — the normal path owns the transition; disarm the
// HTTP watchdog for good (#879). But if the watchdog already
// force-transitioned to the app via HTTP health, a late-thawing
// IPC response must not yank the user back to the splash.
watchdog.markIpcAlive();
if (httpForcedReady) return;
misses = 0;
const stage = res.stage || 'ready';
const message = res.message || null;
@@ -737,6 +899,10 @@ export function useBootstrapStage(pollMs = 1000) {
if (misses < 5) {
timer = setTimeout(tick, pollMs);
} else {
// Conceding 'ready' after repeated fast rejections — stop the
// HTTP watchdog too, so it can't flip to 'ipc_lost' underneath
// the already-mounted main UI (#879).
watchdog.cancel();
setState({ stage: 'ready', message: null });
}
}
@@ -745,6 +911,7 @@ export function useBootstrapStage(pollMs = 1000) {
})();
return () => {
cancelled = true;
watchdog.cancel();
if (timer) clearTimeout(timer);
};
}, [pollMs]);
+9
View File
@@ -1416,6 +1416,15 @@
"hint_intel_mac": "This Mac has an Intel processor, and PyTorch no longer ships Intel-Mac builds — the local AI backend can't be installed, and retrying won't change that. You can still point the app at a remote backend running on another machine (Settings → Sharing → Remote backend), or run OmniVoice on an Apple Silicon Mac, Windows, or Linux. Details: docs/install/macos.md.",
"hint_stuck": "The backend didn't finish starting. From source, run `uv sync` first and make sure `uv` and Python are on your PATH, then Retry. Check the log below (or Settings → Logs → Backend) for the exact stall point.",
"hint_default": "Try \"Retry\" first. If it fails again, \"Clean & Retry\" will rebuild the environment from scratch.",
"ipc_lost": "Startup issue detected",
"ipc_lost_title": "The app can't finish starting",
"ipc_lost_body": "The window lost its connection to the app's core (Tauri IPC isn't responding) and the local backend hasn't answered yet. This usually happens when the WebView cache was corrupted by a crash or forced shutdown. The app keeps checking in the background and will continue automatically if the backend comes up.",
"ipc_lost_open_logs": "Open logs",
"ipc_lost_log_hint": "Couldn't open the folder automatically. Logs are at: {{path}}",
"ipc_lost_repair": "Repair and restart",
"ipc_lost_repairing": "Repairing…",
"ipc_lost_repair_confirm": "This clears the app's WebView cache (display data only — your voices, projects, and settings are untouched) and restarts OmniVoice Studio. Continue?",
"ipc_lost_repair_failed": "Automatic repair didn't work. Quit OmniVoice Studio, delete this folder, then start the app again: {{path}}",
"lines": "{{count}} lines"
},
"direction": {
@@ -0,0 +1,202 @@
/**
* Regression tests for issue #879 — splash stuck at "preparing" forever when
* the Tauri IPC layer is dead (corrupted WebView2 cache after a BSOD).
*
* fail-before/pass-after: with a hung `invoke()` (never resolves, never
* rejects — the reported failure mode), the old useBootstrapStage poll loop
* silently died and the stage never left 'checking'. The #879 watchdog now
* escapes over plain HTTP or renders the recovery panel.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act, renderHook, waitFor } from '@testing-library/react';
import { BootstrapSplash, useBootstrapStage } from '../components/BootstrapSplash';
const invokeMock = vi.fn();
const revealMock = 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: (...args) => revealMock(...args),
}));
/** A promise that never settles — the exact #879 IPC failure mode. */
const hangForever = () => new Promise(() => {});
let warnSpy;
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
invokeMock.mockReset();
revealMock.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
warnSpy.mockRestore();
delete window.__TAURI_INTERNALS__;
});
describe('useBootstrapStage — #879 IPC-dead watchdog', () => {
beforeEach(() => {
vi.useFakeTimers();
window.__TAURI_INTERNALS__ = {};
// The hook early-returns 'ready' in dev builds; force the packaged path.
vi.stubEnv('DEV', false);
});
it('hung invoke + healthy backend over HTTP → proceeds to ready (was: stuck forever)', async () => {
invokeMock.mockImplementation(hangForever);
const fetchMock = vi.fn(async () => ({ ok: true }));
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useBootstrapStage());
await act(async () => {}); // flush dynamic imports + first (hanging) tick
expect(result.current.stage).toBe('checking');
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(result.current.stage).toBe('ready');
expect(fetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:3900/health',
expect.objectContaining({ cache: 'no-store' }),
);
// The IPC-fallback breadcrumb for diagnostic bundles / auto bug reports.
const warned = warnSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(warned).toMatch(/no Tauri IPC signal/i);
expect(warned).toMatch(/issue #879/);
});
it('hung invoke + dead backend → ipc_lost recovery stage after the 45s window, then auto-continues when the backend appears', async () => {
invokeMock.mockImplementation(hangForever);
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('Failed to fetch');
}),
);
const { result } = renderHook(() => useBootstrapStage());
await act(async () => {
await vi.advanceTimersByTimeAsync(44_000);
});
expect(result.current.stage).toBe('checking'); // not yet — still inside the window
await act(async () => {
await vi.advanceTimersByTimeAsync(1_500);
});
expect(result.current.stage).toBe('ipc_lost');
// Backend eventually comes up (e.g. slow first-run install with broken
// IPC): the splash must still hand over to the app.
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true })),
);
await act(async () => {
await vi.advanceTimersByTimeAsync(4_000);
});
expect(result.current.stage).toBe('ready');
});
it('working IPC → normal path unchanged, watchdog disarmed, zero HTTP polling', async () => {
invokeMock.mockImplementation(async (cmd) =>
cmd === 'bootstrap_status' ? { stage: 'installing_deps', message: null } : null,
);
const fetchMock = vi.fn(async () => ({ ok: true }));
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useBootstrapStage());
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000);
});
expect(result.current.stage).toBe('installing_deps');
// Way past both watchdog windows: stage is IPC-driven, health never polled.
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(result.current.stage).toBe('installing_deps');
expect(fetchMock).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
});
});
function setUserAgent(value) {
const original = Object.getOwnPropertyDescriptor(window.navigator, 'userAgent');
Object.defineProperty(window.navigator, 'userAgent', {
value,
configurable: true,
});
return () => {
if (original) Object.defineProperty(window.navigator, 'userAgent', original);
else delete window.navigator.userAgent;
};
}
describe('BootstrapSplash — ipc_lost recovery panel', () => {
it('renders the recovery panel instead of the progress list; no repair button on non-Windows', () => {
const restore = setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)');
try {
render(<BootstrapSplash stage="ipc_lost" message={null} />);
expect(screen.getByText("The app can't finish starting")).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Open logs/ })).toBeInTheDocument();
// Windows-only affordance must not leak to other platforms.
expect(screen.queryByRole('button', { name: /Repair and restart/ })).toBeNull();
// The infinite-spinner progress list is gone.
expect(screen.queryByText('Checking environment…')).toBeNull();
} finally {
restore();
}
});
it('Windows: repair button invokes clear_webview_cache_and_relaunch after confirm', async () => {
const restore = setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
const confirmMock = vi.fn(() => true);
vi.stubGlobal('confirm', confirmMock);
invokeMock.mockResolvedValue(undefined);
try {
render(<BootstrapSplash stage="ipc_lost" message={null} />);
fireEvent.click(screen.getByRole('button', { name: /Repair and restart/ }));
await waitFor(() =>
expect(invokeMock).toHaveBeenCalledWith('clear_webview_cache_and_relaunch'),
);
expect(confirmMock).toHaveBeenCalledTimes(1);
} finally {
restore();
}
});
it('Windows: declining the confirm does not invoke the repair command', async () => {
const restore = setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
vi.stubGlobal(
'confirm',
vi.fn(() => false),
);
try {
render(<BootstrapSplash stage="ipc_lost" message={null} />);
fireEvent.click(screen.getByRole('button', { name: /Repair and restart/ }));
await act(async () => {});
expect(invokeMock).not.toHaveBeenCalledWith('clear_webview_cache_and_relaunch');
} finally {
restore();
}
});
it('Open logs falls back to an inline log-path hint when IPC is dead', async () => {
invokeMock.mockRejectedValue(new Error('ipc dead'));
render(<BootstrapSplash stage="ipc_lost" message={null} />);
fireEvent.click(screen.getByRole('button', { name: /Open logs/ }));
expect(await screen.findByText(/Couldn't open the folder automatically/)).toBeInTheDocument();
expect(revealMock).not.toHaveBeenCalled();
});
});
+140
View File
@@ -0,0 +1,140 @@
/**
* Unit tests for the IPC-independent splash watchdog (issue #879).
*
* The class under test is the escape hatch for a dead Tauri IPC layer
* (corrupted WebView2 cache after a BSOD): no bootstrap events, `invoke()`
* hanging forever, backend perfectly healthy over plain HTTP.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
startSplashWatchdog,
IPC_SILENCE_MS,
RECOVERY_AFTER_MS,
HEALTH_POLL_MS,
} from '../utils/splashWatchdog';
const HEALTH_URL = 'http://127.0.0.1:3900/health';
let warnSpy;
beforeEach(() => {
vi.useFakeTimers();
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
warnSpy.mockRestore();
});
function make(fetchImpl, overrides = {}) {
const onReadyViaHttp = vi.fn();
const onStuck = vi.fn();
const fetchFn = vi.fn(fetchImpl);
const wd = startSplashWatchdog({
healthUrl: HEALTH_URL,
onReadyViaHttp,
onStuck,
fetchFn,
...overrides,
});
return { wd, onReadyViaHttp, onStuck, fetchFn };
}
describe('startSplashWatchdog (#879)', () => {
it('no IPC signal + healthy backend → proceeds via HTTP with a console.warn breadcrumb', async () => {
const { onReadyViaHttp, onStuck, fetchFn } = make(async () => ({ ok: true }));
// Before the silence window: nothing happens.
await vi.advanceTimersByTimeAsync(IPC_SILENCE_MS - 1);
expect(fetchFn).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(fetchFn).toHaveBeenCalledWith(HEALTH_URL, expect.anything());
expect(onReadyViaHttp).toHaveBeenCalledTimes(1);
expect(onStuck).not.toHaveBeenCalled();
// Breadcrumbs for auto bug reports: the fallback + the detected
// "IPC silent but HTTP works" combination.
const warned = warnSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(warned).toMatch(/No Tauri IPC signal within 10s/);
expect(warned).toMatch(/healthy over plain HTTP but no Tauri IPC signal/);
expect(warned).toMatch(/issue #879/);
// Fully disarmed afterwards: no more polls, no stuck panel.
await vi.advanceTimersByTimeAsync(RECOVERY_AFTER_MS * 2);
expect(fetchFn).toHaveBeenCalledTimes(1);
expect(onStuck).not.toHaveBeenCalled();
expect(onReadyViaHttp).toHaveBeenCalledTimes(1);
});
it('no IPC + dead backend → onStuck at the recovery window, then still proceeds when the backend comes up', async () => {
let healthy = false;
const { onReadyViaHttp, onStuck, fetchFn } = make(async () => {
if (!healthy) throw new TypeError('Failed to fetch');
return { ok: true };
});
await vi.advanceTimersByTimeAsync(RECOVERY_AFTER_MS - 1);
expect(onStuck).not.toHaveBeenCalled();
expect(fetchFn).toHaveBeenCalled(); // polling started at the silence mark
await vi.advanceTimersByTimeAsync(1);
expect(onStuck).toHaveBeenCalledTimes(1);
expect(onReadyViaHttp).not.toHaveBeenCalled();
// Recovery panel showing is NOT terminal: a slow first-run install with
// broken IPC must still reach the app once the backend answers.
healthy = true;
await vi.advanceTimersByTimeAsync(HEALTH_POLL_MS);
expect(onReadyViaHttp).toHaveBeenCalledTimes(1);
expect(onStuck).toHaveBeenCalledTimes(1); // never re-fired
});
it('non-2xx /health responses count as unhealthy', async () => {
const { onReadyViaHttp, onStuck } = make(async () => ({ ok: false, status: 503 }));
await vi.advanceTimersByTimeAsync(RECOVERY_AFTER_MS);
expect(onReadyViaHttp).not.toHaveBeenCalled();
expect(onStuck).toHaveBeenCalledTimes(1);
});
it('markIpcAlive before the silence window → never polls, never warns, never fires', async () => {
const { wd, onReadyViaHttp, onStuck, fetchFn } = make(async () => ({ ok: true }));
await vi.advanceTimersByTimeAsync(3_000);
wd.markIpcAlive();
await vi.advanceTimersByTimeAsync(RECOVERY_AFTER_MS * 3);
expect(fetchFn).not.toHaveBeenCalled();
expect(onReadyViaHttp).not.toHaveBeenCalled();
expect(onStuck).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
});
it('a late IPC signal after HTTP polling started still disarms everything', async () => {
const { wd, onReadyViaHttp, onStuck, fetchFn } = make(async () => {
throw new TypeError('Failed to fetch');
});
await vi.advanceTimersByTimeAsync(IPC_SILENCE_MS + HEALTH_POLL_MS);
const callsSoFar = fetchFn.mock.calls.length;
expect(callsSoFar).toBeGreaterThan(0);
wd.markIpcAlive(); // IPC thawed — normal path owns the transition now
await vi.advanceTimersByTimeAsync(RECOVERY_AFTER_MS * 2);
expect(fetchFn).toHaveBeenCalledTimes(callsSoFar);
expect(onStuck).not.toHaveBeenCalled();
expect(onReadyViaHttp).not.toHaveBeenCalled();
});
it('cancel() stops timers and polling (unmount path)', async () => {
const { wd, onReadyViaHttp, onStuck, fetchFn } = make(async () => ({ ok: true }));
wd.cancel();
await vi.advanceTimersByTimeAsync(RECOVERY_AFTER_MS * 2);
expect(fetchFn).not.toHaveBeenCalled();
expect(onReadyViaHttp).not.toHaveBeenCalled();
expect(onStuck).not.toHaveBeenCalled();
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* IPC-independent splash watchdog (issue #879).
*
* After an unclean shutdown (Windows BSOD), the WebView2 profile cache
* (%LOCALAPPDATA%\<identifier>\EBWebView) can corrupt. Tauri's IPC custom
* protocol then fails ("IPC custom protocol failed, Tauri will now use the
* postMessage interface instead — TypeError: Failed to fetch") and the
* postMessage fallback can be broken too: `invoke()` hangs forever and no
* bootstrap events are ever delivered. The splash used to gate the
* splash → app transition solely on that IPC, so it sat at "preparing"
* forever even with a fully healthy backend answering over plain HTTP.
*
* This watchdog is the escape hatch — it only trusts plain HTTP:
*
* t + ipcSilenceMs no IPC signal yet → start polling GET /health
* backend healthy proceed to the app as if 'ready' was received
* t + recoveryAfterMs neither IPC nor healthy HTTP → onStuck() renders the
* recovery panel. Health polling keeps running after
* that, so a slow first-run install with broken IPC
* still reaches the app once the backend comes up.
*
* Any successful IPC signal (a `bootstrap_status` response) at any point
* disarms the watchdog permanently via `markIpcAlive()` — the normal
* IPC-driven path owns the transition from then on.
*
* Detection note (part 3 of the #879 fix): Tauri 2 does NOT expose the
* "custom protocol failed → postMessage fallback" state to page JS (it's a
* closure-local in its injected ipc.js and only surfaces as a console.warn
* from the init script). The reliable detector for a broken IPC layer is
* therefore exactly the combination this watchdog observes — zero IPC
* signals plus a working plain-HTTP backend — and we console.warn that
* combination so diagnostic bundles / auto bug reports carry the breadcrumb.
*/
/** How long to wait for the first IPC signal before falling back to HTTP. */
export const IPC_SILENCE_MS = 10_000;
/** Total window before declaring the startup stuck (recovery panel). */
export const RECOVERY_AFTER_MS = 45_000;
/** Interval between /health polls once the HTTP fallback is active. */
export const HEALTH_POLL_MS = 2_000;
/**
* Start the watchdog. Returns { markIpcAlive, cancel }.
*
* @param {object} opts
* @param {string} opts.healthUrl - absolute URL of the backend /health endpoint
* @param {() => void} opts.onReadyViaHttp - backend healthy but IPC silent → proceed
* @param {() => void} opts.onStuck - neither IPC nor HTTP after recoveryAfterMs
* @param {typeof fetch} [opts.fetchFn] - injectable for tests
*/
export function startSplashWatchdog({
healthUrl,
onReadyViaHttp,
onStuck,
fetchFn,
ipcSilenceMs = IPC_SILENCE_MS,
recoveryAfterMs = RECOVERY_AFTER_MS,
healthPollMs = HEALTH_POLL_MS,
}) {
const doFetch = fetchFn || ((...args) => fetch(...args));
let done = false; // IPC alive, cancelled, or already proceeded via HTTP
let stuckFired = false;
let silenceTimer = null;
let stuckTimer = null;
let pollTimer = null;
const clearTimers = () => {
if (silenceTimer) clearTimeout(silenceTimer);
if (stuckTimer) clearTimeout(stuckTimer);
if (pollTimer) clearTimeout(pollTimer);
silenceTimer = stuckTimer = pollTimer = null;
};
const finish = () => {
done = true;
clearTimers();
};
const poll = async () => {
if (done) return;
let healthy = false;
try {
// AbortSignal.timeout keeps a hung request from stalling the chain;
// the next poll is only scheduled after this one settles.
const res = await doFetch(healthUrl, {
cache: 'no-store',
signal:
typeof AbortSignal !== 'undefined' && AbortSignal.timeout
? AbortSignal.timeout(healthPollMs)
: undefined,
});
healthy = !!res && res.ok;
} catch {
healthy = false;
}
if (done) return;
if (healthy) {
// The #879 breadcrumb: backend reachable over HTTP, IPC totally silent.
console.warn(
'[splash-watchdog] Backend is healthy over plain HTTP but no Tauri IPC signal ever ' +
'arrived — the WebView IPC layer (custom protocol AND its postMessage fallback) ' +
'appears broken. Proceeding to the app via HTTP health. A corrupted WebView cache ' +
'after an unclean shutdown is the usual cause (issue #879).',
);
finish();
onReadyViaHttp();
return;
}
pollTimer = setTimeout(poll, healthPollMs);
};
silenceTimer = setTimeout(() => {
if (done) return;
console.warn(
`[splash-watchdog] No Tauri IPC signal within ${Math.round(ipcSilenceMs / 1000)}s — ` +
`WebView IPC may be broken (issue #879). Falling back to plain-HTTP health polling ` +
`at ${healthUrl}.`,
);
poll();
}, ipcSilenceMs);
stuckTimer = setTimeout(() => {
if (done || stuckFired) return;
stuckFired = true;
console.warn(
'[splash-watchdog] Startup stuck: no Tauri IPC signal AND the backend never answered ' +
'/health within the recovery window — showing the recovery panel (issue #879).',
);
onStuck();
// Deliberately NOT finish(): keep polling /health so a slow first-run
// install with broken IPC still transitions to the app when it comes up.
}, recoveryAfterMs);
return {
/** Call on any successful IPC response — disarms the watchdog for good. */
markIpcAlive() {
finish();
},
cancel() {
finish();
},
};
}