From 13608269b3e080ef203f14928096a09112212805 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 13 Jul 2026 22:17:51 +0800 Subject: [PATCH] fix(windows): stop the daemon before install/uninstall so it can replace tty7.exe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent daemon (`tty7.exe --daemon`) is a detached background process that outlives the GUI and is the running image of tty7.exe, so Windows locks the file. An upgrade or uninstall then can't overwrite/remove the binary and fails ("file in use" / reboot required) — the Restart Manager doesn't reliably catch a no-window, DETACHED_PROCESS daemon in its own process group. - spawn: extract the "stop the running daemon" half of `restart()` into a reusable `stop()` (Shutdown -> await exit -> pid reap fallback -> clear endpoint); `restart()` is now `stop()` + `ensure_running()`. - main: add a `--stop-daemon` CLI entry that runs `stop()` and returns before any GUI init, so it never opens a window. - installer: in PrepareToInstall, extract the *new* tty7.exe to {tmp} and run `--stop-daemon` (the new binary understands the flag; an old installed one would launch the GUI instead), releasing the lock before file copy. Mirror it in [UninstallRun]. Keep CloseApplications as a backstop but RestartApplications=no (the GUI respawns the daemon on next start). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/windows-installer.iss | 34 +++++++++++++++++++++++++++ src/daemon/spawn.rs | 31 +++++++++++++++++------- src/main.rs | 10 ++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/.github/scripts/windows-installer.iss b/.github/scripts/windows-installer.iss index 18e505cd..d4099f31 100644 --- a/.github/scripts/windows-installer.iss +++ b/.github/scripts/windows-installer.iss @@ -41,6 +41,13 @@ OutputBaseFilename={#OutputName} Compression=lzma2 SolidCompression=yes WizardStyle=modern +; The persistent daemon (tty7.exe --daemon) is a detached background process +; that outlives the GUI and holds the running image of tty7.exe, so Windows +; locks the file and an upgrade can't replace it. We stop it explicitly in +; PrepareToInstall below; keep the Restart Manager as a backstop but don't let +; it relaunch anything (the GUI respawns the daemon itself on next start). +CloseApplications=yes +RestartApplications=no [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked @@ -57,3 +64,30 @@ Name: "{autodesktop}\tty7"; Filename: "{app}\tty7.exe"; Tasks: desktopicon [Run] Filename: "{app}\tty7.exe"; Description: "{cm:LaunchProgram,tty7}"; Flags: nowait postinstall skipifsilent + +[UninstallRun] +; Stop the daemon before the uninstaller deletes tty7.exe — the running daemon +; is the locked image of that file, so removing it fails otherwise. This runs at +; the start of uninstallation, before any files are removed. The installed binary +; is this version, which understands the flag; runhidden suppresses any flash and +; the call returns without opening a window. RunOnceId keys the entry so a repeated +; uninstall doesn't run it twice. +Filename: "{app}\tty7.exe"; Parameters: "--stop-daemon"; Flags: runhidden waituntilterminated; RunOnceId: "StopDaemon" + +[Code] +{ Gracefully stop the persistent daemon before we overwrite tty7.exe. We can't + run the *installed* binary here — on an upgrade from an older build it may not + understand --stop-daemon and would launch the GUI instead — so we extract the + *new* tty7.exe to {tmp} and run that. It connects to the running daemon, hangs + up every shell, waits for it to exit (releasing the file lock), then returns + without opening a window. Best effort: any failure falls through to the Restart + Manager backstop, and a fresh install simply has no daemon to stop. } +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; +begin + ExtractTemporaryFile('tty7.exe'); + Exec(ExpandConstant('{tmp}\tty7.exe'), '--stop-daemon', '', + SW_HIDE, ewWaitUntilTerminated, ResultCode); + Result := ''; +end; diff --git a/src/daemon/spawn.rs b/src/daemon/spawn.rs index 7fe513d1..4ac4a70e 100644 --- a/src/daemon/spawn.rs +++ b/src/daemon/spawn.rs @@ -99,11 +99,26 @@ pub fn ensure_running() -> anyhow::Result<()> { /// quitting/reopening the GUI alone doesn't touch the detached daemon. Safe with /// no daemon running — it just spawns a fresh one. pub fn restart() -> anyhow::Result<()> { + stop(); + ensure_running() +} + +/// Stop the running daemon and leave nothing running: ask it to shut down — +/// which hangs up every live shell — wait for it to exit, escalate to a +/// pid-based reap if it won't, and clear its endpoint marker. A no-op when no +/// daemon is running. Unlike [`restart`], this does not spawn a replacement. +/// +/// This backs both the GUI's restart (which calls it, then respawns) and the +/// `--stop-daemon` CLI entry point the Windows installer/uninstaller runs before +/// replacing or deleting `tty7.exe`: the detached daemon is the running image of +/// that same file, so Windows locks it until the daemon exits. Stopping it here +/// releases the lock so the install/uninstall can overwrite/remove the binary. +pub fn stop() { use crate::daemon::protocol::ClientMsg; use std::io::Write as _; // Ask a running daemon to stop. Best effort: a failed connect/write means - // nothing is listening, so we fall through to spawning a fresh one. + // nothing is listening, so we fall through to the reap/clear below. if let Ok(mut stream) = transport::connect() { if ClientMsg::Shutdown.encode(&mut stream).is_ok() { let _ = stream.flush(); @@ -117,19 +132,17 @@ pub fn restart() -> anyhow::Result<()> { } // If the old daemon is still alive here, `Shutdown` didn't stop it — a - // binary that predates the message, or a wedged teardown. Restarting - // *means* the old daemon must go: quietly claiming its endpoint while it - // lives is how sessions got stranded (unreachable daemon, panes and - // children still running — issue #42). Escalate by recorded pid. + // binary that predates the message, or a wedged teardown. Stopping *means* + // the old daemon must go: quietly claiming its endpoint while it lives is + // how sessions got stranded (unreachable daemon, panes and children still + // running — issue #42). Escalate by recorded pid. reap_recorded_daemon(); - // The daemon removes its own endpoint marker on shutdown, but clear defensively - // in case it was killed mid-teardown, then bring a fresh daemon up and wait for - // it to listen. `ensure_running` re-probes and spawns only if nothing answers. + // The daemon removes its own endpoint marker on shutdown, but clear + // defensively in case it was killed mid-teardown. if transport::endpoint_exists() { transport::remove_stale_endpoint(); } - ensure_running() } /// Reap the daemon recorded in the pidfile, if it is still alive: the caller diff --git a/src/main.rs b/src/main.rs index 96f13585..9e1a4ed5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -261,6 +261,16 @@ fn main() { return; } + // Stop mode: `--stop-daemon` shuts the persistent daemon down (hanging up + // every shell) and returns without ever opening a window. On Windows the + // detached daemon is the running image of `tty7.exe`, so it locks the file + // and blocks an upgrade/uninstall from replacing it; the installer runs this + // first to release the lock. Harmless when no daemon is running. + if std::env::args().any(|a| a == "--stop-daemon") { + crate::daemon::spawn::stop(); + return; + } + // GUI path: repair the starved Launch Services PATH before anything reads it // (completion scans it per keystroke; the daemon we spawn below inherits it). #[cfg(unix)]