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
12 changes: 9 additions & 3 deletions docs/specs/auto-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ app launch
└─ install fails → overwrite with failure marker → exit normally
```

The `Update` object returned by `check()` is held in memory as an available update. Clicking the approval action calls `download()` and promotes it to a pending update only after the download succeeds. The close handler intercepts the window close event only when there is an approved, downloaded update, writes a success marker to `localStorage` *before* calling `install()` (because on Windows, NSIS force-kills the process), then calls `install()`. In Vite dev mode (`pnpm dev:standalone`), the close handler skips `install()` without preventing the close. Dev mode is useful for testing check/download/banner behavior, but install must be tested from a packaged app because the updater resolves its replacement target from the current executable path.
The `Update` object returned by `check()` is held in memory as an available update. Clicking the approval action calls `download()` and promotes it to a pending update only after the download succeeds. The close handler intercepts the window close event only when there is an approved, downloaded update, writes a success marker to `localStorage` *before* calling `install()` (because on Windows, NSIS force-kills the process), then — on Windows only — kills the sidecar and waits for it to fully exit (see *Sidecar teardown on Windows* below) before calling `install()`. In Vite dev mode (`pnpm dev:standalone`), the close handler skips `install()` without preventing the close. Dev mode is useful for testing check/download/banner behavior, but install must be tested from a packaged app because the updater resolves its replacement target from the current executable path.

## Sidecar teardown on Windows

The NSIS installer overwrites files inside the bundled sidecar — including node-pty's native `conpty.node`. Windows refuses to overwrite a native module that a live process still has loaded, so if the Node sidecar is running when NSIS reaches `node_modules`, the install fails with *"Error opening file for writing: …\_up_\sidecar\node_modules\node-pty\prebuilds\win32-x64\conpty.node"*. The Rust `RunEvent::Exit` kill is too late and asynchronous — NSIS starts copying files immediately after `install()` force-kills the app, racing the sidecar's shutdown.

So on Windows the close handler `invoke`s `kill_sidecar_now` and awaits it before `install()`. That command is synchronous on the Rust side: it sends the kill, then polls `try_wait` (capped at ~5s) until the process has actually exited and released its file handles. `try_wait` is used instead of the job-object `wait()` because `wait()` consumes a completion-port message the reaper thread relies on and could block forever if the sidecar had already exited. macOS and Linux can replace open files in place, so they skip this and rely on the existing `RunEvent::Exit` cleanup.

## Update notice in the Baseboard

Expand Down Expand Up @@ -63,7 +69,7 @@ The Baseboard is in `lib/` but the updater is standalone-only. The notice is thr

| Platform | What `install()` does | App exit |
|----------|----------------------|----------|
| Windows | Launches NSIS installer in passive mode (progress bar, no user interaction). Force-kills the app. | Automatic (NSIS) |
| Windows | Kills the sidecar and waits for it to exit (so NSIS can overwrite its loaded native modules), then launches NSIS installer in passive mode (progress bar, no user interaction). Force-kills the app. | Automatic (NSIS) |
| macOS | Replaces the `.app` bundle in place | `getCurrentWindow().close()` after `install()` returns |
| Linux | Replaces the AppImage in place | `getCurrentWindow().close()` after `install()` returns |
| Vite dev mode | Skips `install()` to avoid replacing the dev executable directory | Native close proceeds normally |
Expand Down Expand Up @@ -125,6 +131,6 @@ The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().bu

**Why write the success marker before `install()`?** On Windows, the NSIS installer force-kills the process — code after `install()` may never run. Writing optimistically and overwriting on failure handles both platforms correctly.

**Why no `on_before_exit` Rust hook?** The JS close handler (`onCloseRequested`) runs before `install()` and handles marker writes. On Windows, NSIS handles process termination after `install()`. Sidecar cleanup is not currently handled at update-time — the sidecar process is orphaned and will exit when its stdin closes.
**Why no `on_before_exit` Rust hook?** The JS close handler (`onCloseRequested`) runs before `install()` and handles marker writes and (on Windows) the synchronous sidecar kill. On Windows, NSIS handles process termination after `install()`. On macOS/Linux the sidecar is orphaned and exits when its stdin closes — harmless there because open files can be replaced in place.

**Why `localStorage` instead of Tauri's store plugin?** `localStorage` persists across launches in Tauri's webview, requires no additional dependencies, and is automatically scoped to the app. If the user resets app data, markers are cleaned up naturally.
41 changes: 40 additions & 1 deletion standalone/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ fn read_update_log() -> Result<String, String> {

#[tauri::command]
fn kill_sidecar_now(state: tauri::State<'_, SidecarState>) {
kill_sidecar(&state.child);
kill_sidecar_and_wait(&state.child);
}

// Job Object on Windows / process group on Unix — kill propagates to the
Expand All @@ -342,6 +342,45 @@ fn kill_sidecar(child: &SharedChild) {
}
}

// Like `kill_sidecar`, but blocks until the process has actually exited. The
// updater calls this before launching the Windows NSIS installer: NSIS
// overwrites files inside the bundled sidecar (e.g. node-pty's `conpty.node`),
// and Windows refuses to overwrite a native module the live sidecar still has
// loaded — surfacing as "Error opening file for writing". Releasing those
// handles first requires the node process to be gone, not merely signalled.
//
// We poll `try_wait` rather than block on `wait()`: `try_wait` is idempotent
// and can't hang, whereas the job-object `wait()` consumes a completion-port
// message the reaper thread may already have drained (e.g. if the sidecar had
// crashed earlier), which would block forever. The ~5s cap means a wedged
// sidecar can't stall quit indefinitely.
fn kill_sidecar_and_wait(child: &SharedChild) {
// Poll for exit at this cadence, up to ~5s total (MAX_POLLS × POLL_INTERVAL).
const POLL_INTERVAL: Duration = Duration::from_millis(20);
const MAX_POLLS: u32 = 250;

let Ok(mut guard) = child.lock() else { return };
append_log(format!(
"[sidecar] killing and waiting for exit (pid={})",
guard.id()
));
let _ = guard.start_kill();
for _ in 0..MAX_POLLS {
match guard.try_wait() {
Ok(Some(status)) => {
append_log(format!("[sidecar] confirmed exit during kill (status: {status})"));
return;
}
Ok(None) => std::thread::sleep(POLL_INTERVAL),
Err(err) => {
append_log(format!("[sidecar] wait error during kill: {err}"));
return;
}
}
}
append_log("[sidecar] kill wait timed out (~5s); proceeding anyway");
}

#[derive(Serialize, Deserialize, Clone)]
struct ShellInfo {
name: String,
Expand Down
35 changes: 35 additions & 0 deletions standalone/src/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ vi.mock('@tauri-apps/api/core', () => ({
invoke: mocks.invoke,
}));

// Force the Windows code path so the sidecar-kill-before-install branch is
// exercised. updater.ts only consumes PLATFORM_STRING from this module.
vi.mock('dormouse-lib/lib/platform', () => ({
PLATFORM_STRING: 'Windows',
IS_WINDOWS: true,
}));

// --- Helpers ---

const STORAGE_KEY = 'dormouse:update-result';
Expand Down Expand Up @@ -207,6 +214,34 @@ describe('updater', () => {
expect(mocks.windowClose).toHaveBeenCalled();
});

it('kills the sidecar and waits for it before installing on Windows', async () => {
const update = makeUpdate('0.5.0');
mocks.check.mockResolvedValue(update);

startUpdateCheck();
await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(0);
approveUpdate();
await vi.advanceTimersByTimeAsync(0);

const order: string[] = [];
mocks.invoke.mockImplementation(async (cmd: string) => {
if (cmd === 'kill_sidecar_now') order.push('kill');
return '';
});
update.install.mockImplementation(async () => {
order.push('install');
});

const closeHandler = mocks.onCloseRequested.mock.calls[0][0];
await closeHandler({ preventDefault: vi.fn() });

expect(mocks.invoke).toHaveBeenCalledWith('kill_sidecar_now');
// The kill must complete before NSIS runs, or it can't overwrite the
// sidecar's still-loaded native modules.
expect(order).toEqual(['kill', 'install']);
});

it('writes failure marker when install throws', async () => {
const update = makeUpdate('0.5.0');
update.install.mockRejectedValue(new Error('install failed'));
Expand Down
11 changes: 10 additions & 1 deletion standalone/src/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { getVersion } from '@tauri-apps/api/app';
import { open } from '@tauri-apps/plugin-shell';
import { invoke } from '@tauri-apps/api/core';
import { PLATFORM_STRING } from 'dormouse-lib/lib/platform';
import { IS_WINDOWS, PLATFORM_STRING } from 'dormouse-lib/lib/platform';
import type { UpdateBannerState } from './UpdateBanner';

const GITHUB_REPO_URL = 'https://github.com/diffplug/dormouse';
Expand Down Expand Up @@ -231,6 +231,15 @@ function registerCloseHandler(): void {
from: currentVersion,
to: update.version,
}));
// On Windows the NSIS installer overwrites files inside the bundled
// sidecar (e.g. node-pty's conpty.node). Windows refuses to overwrite a
// native module the running sidecar still has loaded, which surfaces as
// "Error opening file for writing". Kill the sidecar and wait for it to
// fully exit before launching the installer. (On macOS/Linux open files
// can be replaced in place, so this is Windows-only.)
if (IS_WINDOWS) {
await invoke('kill_sidecar_now');
}
await update.install();
} catch (e) {
// Overwrite with failure marker
Expand Down
Loading