Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src-tauri/release-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,12 @@ test("Windows packaged sidecar starts without a console window", async () => {

test("Windows first launch paints progress and supports recovery while the sidecar starts", async () => {
const [launcher, startupPage] = await Promise.all([
readNativeHost("tauri_setup.rs", "sidecar_startup.rs"),
readNativeHost(
"tauri_setup.rs",
"sidecar_startup.rs",
"sidecar_supervisor.rs",
"sidecar_lifecycle.rs",
),
readFile(new URL("./frontend-stub/startup.html", import.meta.url), "utf8"),
access(new URL("./frontend-stub/cave-icon.png", import.meta.url)),
]);
Expand All @@ -940,6 +945,36 @@ test("Windows first launch paints progress and supports recovery while the sidec
/window\.location\.replace\(/,
"readiness must replace startup.html in session history so history.back() cannot return to the splash screen",
);
assert.match(
launcher,
/spawn_sidecar_startup\(app\.handle\(\)\.clone\(\), startup_control\)\?;\s*spawn_sidecar_supervisor\(app\.handle\(\)\.clone\(\)\)/,
"Windows must start post-ready supervision beside the startup owner",
);
assert.match(
launcher,
/spawn_sidecar_startup\(app\.clone\(\), Arc::clone\(control\.inner\(\)\)\)/,
"automatic Windows recovery must reuse SidecarStartupControl instead of racing it",
);
assert.match(
launcher,
/recovery_observation\(\s*recovery_pending,\s*sidecar_liveness\(&app\),\s*startup_in_progress\(&app\)/,
"the supervisor must wait for an owned startup and observe the resulting child",
);
assert.match(
launcher,
/stop_after_startup_attempt\(\)/,
"failed Windows startup workers must wait for the liveness probe and release their process job",
);
assert.match(
launcher,
/refreshed_sidecar_window_url[\s\S]*QUICK_CHAT_WINDOW_LABEL,\s*NOTCH_WINDOW_LABEL/,
"sidecar recovery must rotate auth for already-open auxiliary windows",
);
assert.match(
launcher,
/supervisor\.request_stop\(\)[\s\S]*control\.request_shutdown\(\)/,
"Windows shutdown must stop supervision before cancelling startup and the process job",
);
assert.match(
startupPage,
/role="progressbar"[\s\S]*aria-live="polite"/,
Expand Down
27 changes: 27 additions & 0 deletions src-tauri/src/sidecar_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ impl SidecarStartupControl {
self.running.store(false, Ordering::Release);
}

pub(super) fn is_running(&self) -> bool {
self.running.load(Ordering::Acquire)
}

pub(super) fn request_cancel(&self) -> Result<(), String> {
if !self.running.load(Ordering::Acquire) {
return Err("sidecar startup is not running".to_string());
Expand All @@ -212,6 +216,10 @@ impl SidecarStartupControl {
self.cancel_requested.store(true, Ordering::Release);
}

pub(super) fn is_shutdown_requested(&self) -> bool {
self.shutdown_requested.load(Ordering::Acquire)
}

pub(super) fn status(&self) -> Result<SidecarStartupStatus, String> {
self.status
.lock()
Expand Down Expand Up @@ -278,6 +286,22 @@ impl SidecarState {
drop(guard);
stop_sidecar_child(child)
}

#[cfg(target_os = "windows")]
pub(super) fn stop_after_startup_attempt(&self) -> Result<(), String> {
// Startup failure runs on the worker thread, not the UI/exit path.
// Wait for the supervisor's brief liveness probe so cleanup cannot
// leave a failed child holding the selected port.
let mut guard = match self.0.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let Some(child) = guard.take() else {
return Ok(());
};
drop(guard);
stop_sidecar_child(child)
}
}

#[cfg(desktop)]
Expand Down Expand Up @@ -349,6 +373,9 @@ pub(super) fn stop_sidecar_child(mut process: SidecarProcess) -> Result<(), Stri

#[cfg(all(desktop, target_os = "windows"))]
pub(super) fn shutdown_owned_processes(app: &tauri::AppHandle) {
if let Some(supervisor) = app.try_state::<Arc<SidecarSupervisor>>() {
supervisor.request_stop();
}
if let Some(control) = app.try_state::<Arc<SidecarStartupControl>>() {
control.request_shutdown();
}
Expand Down
61 changes: 52 additions & 9 deletions src-tauri/src/sidecar_startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,15 +202,58 @@ pub(super) fn node_arg_path(path: &Path) -> PathBuf {
/// history. Both first startup and later supervisor revivals use this exact
/// path so URL escaping and the native-navigation fallback cannot drift.
#[cfg(desktop)]
pub(super) fn replace_main_window_url(app: &tauri::AppHandle, url: Url) -> Result<(), String> {
let window = app
.get_webview_window("main")
.ok_or_else(|| "main window is unavailable".to_string())?;
fn navigate_sidecar_window(window: &tauri::WebviewWindow, url: Url) -> Result<(), String> {
let escaped = url.to_string().replace('"', "%22");
window
.eval(format!("window.location.replace(\"{escaped}\");"))
.or_else(|_| window.navigate(url))
.map_err(|error| format!("could not navigate the main window: {error}"))
.map_err(|error| format!("could not navigate the {} window: {error}", window.label()))
}

#[cfg(desktop)]
pub(super) fn refreshed_sidecar_window_url(startup_url: &Url, current_url: &Url) -> Url {
let presentation_query: Vec<_> = current_url
.query_pairs()
.filter(|(key, _)| key != "covenCaveToken" && key != "coven_access_token")
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
let mut refreshed = startup_url.clone();
refreshed.set_path(current_url.path());
refreshed.set_fragment(current_url.fragment());
for (key, value) in presentation_query {
refreshed.query_pairs_mut().append_pair(&key, &value);
}
refreshed
}

#[cfg(desktop)]
pub(super) fn replace_main_window_url(app: &tauri::AppHandle, url: Url) -> Result<(), String> {
let main_window = app
.get_webview_window("main")
.ok_or_else(|| "main window is unavailable".to_string())?;
navigate_sidecar_window(&main_window, url.clone())?;

for label in [QUICK_CHAT_WINDOW_LABEL, NOTCH_WINDOW_LABEL] {
let Some(window) = app.get_webview_window(label) else {
continue;
};
let target = match window.url() {
Ok(current) => refreshed_sidecar_window_url(&url, &current),
Err(error) => {
log::warn!(
"[cave] could not inspect the {label} window during sidecar recovery: {error}; closing the stale auxiliary window"
);
let _ = window.close();
continue;
}
};
if let Err(error) = navigate_sidecar_window(&window, target) {
log::warn!("[cave] {error}; closing the stale auxiliary window");
let _ = window.close();
}
}

Ok(())
}

#[cfg(desktop)]
Expand Down Expand Up @@ -570,7 +613,7 @@ pub(super) fn spawn_sidecar_startup(
let final_status = match result {
Ok(_url) if thread_control.is_cancelled() => {
if let Some(sidecar) = app.try_state::<SidecarState>() {
if let Err(error) = sidecar.stop() {
if let Err(error) = sidecar.stop_after_startup_attempt() {
log::warn!("[cave] could not stop cancelled sidecar: {error}");
}
}
Expand All @@ -587,7 +630,7 @@ pub(super) fn spawn_sidecar_startup(
Ok(()) => SidecarStartupStatus::ready(),
Err(error) => {
if let Some(sidecar) = app.try_state::<SidecarState>() {
if let Err(stop_error) = sidecar.stop() {
if let Err(stop_error) = sidecar.stop_after_startup_attempt() {
log::warn!(
"[cave] could not stop sidecar after navigation failure: {stop_error}"
);
Expand All @@ -599,15 +642,15 @@ pub(super) fn spawn_sidecar_startup(
}
Err(SidecarStartError::Cancelled) => {
if let Some(sidecar) = app.try_state::<SidecarState>() {
if let Err(error) = sidecar.stop() {
if let Err(error) = sidecar.stop_after_startup_attempt() {
log::warn!("[cave] could not stop cancelled sidecar: {error}");
}
}
SidecarStartupStatus::cancelled()
}
Err(SidecarStartError::Failed(error)) => {
if let Some(sidecar) = app.try_state::<SidecarState>() {
if let Err(stop_error) = sidecar.stop() {
if let Err(stop_error) = sidecar.stop_after_startup_attempt() {
log::warn!(
"[cave] could not stop sidecar after startup failure: {stop_error}"
);
Expand Down
Loading
Loading