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:
co-authored by
mergetest
Claude Fable 5
parent
6e600c48cb
commit
86f5213055
@@ -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'
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user