From 741c863c2ce1d1d85e5fc39f6f075ef51710ee4a Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sat, 8 Aug 2026 11:04:15 +0800 Subject: [PATCH] fix(windows): make the install directory actually replaceable before updating (#403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): make the install directory actually replaceable before updating The updater stopped the daemon and started the Inno installer the moment the daemon's endpoint disappeared — but the endpoint going away is not the same event as the images being released. The ConPTY hosts (OpenConsole.exe) are the daemon's children, not the shells', so the per-pane kill never reached them, and the daemon's exit(0) skipped every destructor that would have closed them; they kept the installed OpenConsole.exe open for seconds after --stop-daemon returned. Silent Setup then hit the lock, took the suppressed dialog's default (Abort), and the updater's recovery relaunched the old build — "updated, restarted, still the old version". A daemon that died without cleaning up made it permanent: its orphaned hosts survive indefinitely, which is the DeleteFile-code-5 users hit even after "closing everything". Reproduced both shapes in isolation before fixing: with a pane open, --stop-daemon returned ~1s in while OpenConsole.exe stayed locked for another ~1.4s; after taskkill on the daemon, the orphaned host held the lock forever. The shutdown now finishes what it starts, at every layer that can be the last one standing: * The daemon reaps its remaining descendants and waits for them before exiting, while the endpoint — the signal stop() watches — is still up. * stop() reads the pidfile before asking, and waits for that process to actually exit after the endpoint goes, not just stop listening. * The recorded-daemon reap waits for the images to be released instead of returning on the async TerminateProcess. * stop_for_update(dir) — reached via --stop-daemon --update-install-dir, which PrepareToInstall and the portable updater now pass — also terminates anything still running from the installation directory (the orphan case no pidfile can name) and only returns once the .exe/.dll images there open for writing, naming the holdouts in the error if they never do. * The updater runs that clearing itself before invoking Setup, so a directory that cannot be cleared fails with a cause in update.log and relaunches the previous build, instead of Inno's bare "DeleteFile failed; code 5". The update dialog on Windows also told a macOS truth — "the background service keeps running, so whatever is open in your panes survives". Windows cannot replace a running daemon's image, so its install path stops the service; the dialog now says so. * fix(windows): tighten the install-dir clearing per review - An image that fails to canonicalize stays in the lock check instead of being silently skipped; only a positive match against the caller's own running image is excluded. - reap_recorded_daemon shares one deadline across the whole tree via a new winproc::terminate_and_wait_all, which stop_for_update and reap_descendants_of now use too — one implementation of "terminate, then wait, bounded overall" instead of three. - [UninstallRun] passes --update-install-dir "{app}" like PrepareToInstall, so uninstalling after a daemon crash gets the same orphaned-ConPTY-host cleanup as upgrading. * fix(update): close three gaps the update audit found - macOS updater: wait for the parent by watching getppid() reparent to launchd instead of polling kill(pid, 0), which a recycled pid could satisfy forever. The kill loop remains only for a hand-run updater. - Windows: a new update guard (config-dir update.lock, held by the updater from daemon stop to relaunch) makes ensure_running refuse to spawn a daemon mid-install, so a tty7 CLI call or manual launch can no longer relock the images the installer is replacing. Stale guards — dead writer or past the TTL — are shed on sight. - Windows portable: the update backup now carries an incomplete marker from before the first file moves until the replacement lands. At launch the app reports a backup still carrying it as an interrupted update (the installation may mix two versions; the old files are preserved), and silently removes marker-less backups a finished update failed to delete past an antivirus hold. * fix(update): verify the guard's writer by start time, and guard manual Setup runs Review round three, both findings and all three minors: - The guard no longer expires a live, verified holder: a pid is believed to be the writer only if the process behind it started before the guard was written (winproc::creation_time via GetProcessTimes), which is what tells a genuine holder from a recycled pid. The TTL now bounds only the unverifiable case, so an install slowed past ten minutes by an antivirus sweep keeps its protection. - Manual Setup runs get the guard too: the --stop-daemon --update-install-dir helper holds it in its parent's name — the Setup or uninstaller that keeps replacing files after the helper returns — and it goes stale when that parent exits. ensure_running gained five seconds of patience so the post-install "Launch tty7" click, racing Setup's own exit, gets its daemon instead of an error. - processes_running_from also matches images against the canonicalized install-dir spelling (junction, subst, 8.3 given form). - reconcile_portable_backups reports every interrupted backup, not the first. - The unix signal-and-wait loop now reuses wait_for_recorded_exit. * style: rustfmt --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- .github/scripts/windows-installer.iss | 28 ++- crates/tty7-core/src/daemon/mod.rs | 6 + crates/tty7-core/src/daemon/server.rs | 12 ++ crates/tty7-core/src/daemon/spawn.rs | 170 ++++++++++++++-- crates/tty7-core/src/daemon/update_guard.rs | 204 ++++++++++++++++++++ crates/tty7-core/src/daemon/winproc.rs | 194 +++++++++++++++++++ src/bin/tty7-updater.rs | 114 ++++++++++- src/core/update.rs | 130 ++++++++++++- src/main.rs | 37 ++++ src/ui/i18n/en.rs | 3 + src/ui/i18n/ja.rs | 3 + src/ui/i18n/mod.rs | 2 + src/ui/i18n/zh.rs | 3 + 13 files changed, 879 insertions(+), 27 deletions(-) create mode 100644 crates/tty7-core/src/daemon/update_guard.rs diff --git a/.github/scripts/windows-installer.iss b/.github/scripts/windows-installer.iss index e1d19dd9..5aad047a 100644 --- a/.github/scripts/windows-installer.iss +++ b/.github/scripts/windows-installer.iss @@ -130,12 +130,16 @@ Filename: "{app}\tty7-app.exe"; Description: "{cm:LaunchProgram,tty7}"; Flags: n [UninstallRun] ; Stop the daemon before the uninstaller deletes tty7-app.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-app.exe"; Parameters: "--stop-daemon"; Flags: runhidden waituntilterminated; RunOnceId: "StopDaemon" +; is the locked image of that file, so removing it fails otherwise. Naming {app} +; widens the stop the same way PrepareToInstall's does: ConPTY hosts orphaned by +; a daemon that never got to shut down keep the installed images open, and their +; DeleteFile fails an uninstall exactly as it fails an upgrade. (The stop excludes +; the calling process itself, so the binary running this step is safe.) This runs +; at the start of uninstallation, before any files are removed. The installed +; binary is this version, which understands the flags; 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-app.exe"; Parameters: "--stop-daemon --update-install-dir ""{app}"""; Flags: runhidden waituntilterminated; RunOnceId: "StopDaemon" ; Unconditional, and deliberately not gated on the task: an install that had the ; menu registered and was later upgraded without the box ticked still holds the ; keys, and verbs pointing at a deleted exe are worse than a no-op. Removing keys @@ -148,14 +152,20 @@ Filename: "{app}\tty7-app.exe"; Parameters: "--unregister-explorer-menu"; Flags: understand --stop-daemon and would launch the GUI instead — so we extract the *new* tty7-app.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. *) + without opening a window. Naming {app} widens the stop into "make this directory + replaceable": ConPTY hosts (OpenConsole.exe) orphaned by a daemon that never got + to shut down keep the installed images open — invisible to the daemon stop, fatal + to the DeleteFile below — so anything still running from {app} is terminated and + the call waits until the images there actually open for writing. Best effort: any + failure falls through to the Restart Manager backstop, and a fresh install simply + has nothing to stop. *) function PrepareToInstall(var NeedsRestart: Boolean): String; var ResultCode: Integer; begin ExtractTemporaryFile('tty7-app.exe'); - Exec(ExpandConstant('{tmp}\tty7-app.exe'), '--stop-daemon', '', + Exec(ExpandConstant('{tmp}\tty7-app.exe'), + '--stop-daemon --update-install-dir "' + ExpandConstant('{app}') + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); Result := ''; end; diff --git a/crates/tty7-core/src/daemon/mod.rs b/crates/tty7-core/src/daemon/mod.rs index b603ac9b..b89d5a39 100644 --- a/crates/tty7-core/src/daemon/mod.rs +++ b/crates/tty7-core/src/daemon/mod.rs @@ -20,6 +20,12 @@ pub(crate) mod shell_integration; #[cfg(windows)] pub(crate) mod winproc; +/// Windows-only like the updater flow that holds it: the macOS updater swaps +/// the bundle by rename and never stops the daemon, so it has no window in +/// which a fresh daemon could relock anything. +#[cfg(windows)] +pub mod update_guard; + /// The Windows environment refresh new panes get (#333). Only the registry /// reader and the spawn wiring are Windows-only; the merge itself is a pure /// function, so `cfg(test)` keeps the module compiling everywhere and its diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 24310e74..6f930461 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -447,6 +447,18 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { ClientMsg::Shutdown => { log::info!("daemon shutting down on client request"); registry.drain_and_kill(); + // The ConPTY hosts (OpenConsole.exe) are this process's children, + // not the shells', so the per-pane kill never reaches them — and + // exiting right away would leave them holding the installed + // OpenConsole.exe image open while an updater tries to replace it. + // Reap everything still below us and wait for the images to be + // released before the endpoint disappears, because the endpoint + // going away is what tells `spawn::stop` the shutdown is complete. + #[cfg(windows)] + crate::daemon::winproc::reap_descendants_of( + std::process::id(), + std::time::Duration::from_secs(3), + ); on_shutdown(); std::process::exit(0); } diff --git a/crates/tty7-core/src/daemon/spawn.rs b/crates/tty7-core/src/daemon/spawn.rs index 01be294f..13b46388 100644 --- a/crates/tty7-core/src/daemon/spawn.rs +++ b/crates/tty7-core/src/daemon/spawn.rs @@ -15,6 +15,10 @@ const STARTUP_TIMEOUT: Duration = Duration::from_secs(3); const POLL_INTERVAL: Duration = Duration::from_millis(50); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2); const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(6); +/// How long after the endpoint disappears the daemon process itself gets to +/// finish exiting. Under a graceful shutdown this is milliseconds; the margin +/// covers the Windows descendant reap that runs before `exit`. +const PROCESS_EXIT_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(any(target_os = "macos", target_os = "linux"))] const REAP_TERM_TIMEOUT: Duration = Duration::from_secs(6); #[cfg(any(target_os = "macos", target_os = "linux"))] @@ -194,6 +198,28 @@ pub fn ensure_running() -> anyhow::Result<()> { } } + // While an installer is replacing the installation, spawning a daemon + // would relock the very images it is clearing — the update would fail + // with "files in use" caused by us. Connecting to a live daemon above is + // fine; only creating a new one waits. The short patience first is for + // the guard's holder being a Setup that is exiting right now — the + // post-install "Launch tty7" click — where the launch deserves its + // daemon, not an error. + #[cfg(windows)] + { + const UPDATE_GUARD_PATIENCE: Duration = Duration::from_secs(5); + let deadline = Instant::now() + UPDATE_GUARD_PATIENCE; + while crate::daemon::update_guard::held() { + if Instant::now() >= deadline { + anyhow::bail!( + "a tty7 update is being installed right now; the daemon will return \ + when the installer relaunches the app" + ); + } + std::thread::sleep(POLL_INTERVAL); + } + } + spawn_detached()?; let deadline = Instant::now() + STARTUP_TIMEOUT; @@ -296,6 +322,12 @@ pub fn restart() -> anyhow::Result<()> { pub fn stop() { use std::io::Write as _; + // Read the pid before asking the daemon to die: a clean shutdown removes + // the pidfile, and the endpoint disappearing is not the same event as the + // process releasing its image — the gap between them is exactly where an + // installer starts replacing files that are still locked. + let recorded = pidfile::read().filter(|&pid| pid > 4 && pid != std::process::id()); + if let Ok(mut stream) = transport::connect() { if ClientMsg::Shutdown.encode(&mut stream).is_ok() { let _ = stream.flush(); @@ -306,6 +338,12 @@ pub fn stop() { } } + if let Some(pid) = recorded + && !wait_for_recorded_exit(pid, PROCESS_EXIT_TIMEOUT) + { + log::warn!("daemon pid {pid} released its endpoint but has not exited yet"); + } + reap_recorded_daemon(); if transport::endpoint_exists() { @@ -313,6 +351,31 @@ pub fn stop() { } } +/// Whether the recorded daemon process actually exited within `timeout`. The +/// endpoint file only says the daemon stopped listening; this is what says its +/// executable image is no longer mapped. +#[cfg(windows)] +fn wait_for_recorded_exit(pid: u32, timeout: Duration) -> bool { + crate::daemon::winproc::wait_for_exit(pid, timeout) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn wait_for_recorded_exit(pid: u32, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while process_alive(pid as libc::pid_t) { + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(POLL_INTERVAL); + } + true +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +fn wait_for_recorded_exit(_pid: u32, _timeout: Duration) -> bool { + true +} + #[cfg(any(target_os = "macos", target_os = "linux"))] fn reap_recorded_daemon() { let Some(pid) = pidfile::read() else { return }; @@ -347,14 +410,7 @@ fn reap_process(pid: libc::pid_t) { #[cfg(any(target_os = "macos", target_os = "linux"))] fn signal_and_await_exit(pid: libc::pid_t, sig: libc::c_int, timeout: Duration) -> bool { unsafe { libc::kill(pid, sig) }; - let deadline = Instant::now() + timeout; - while process_alive(pid) { - if Instant::now() >= deadline { - return false; - } - std::thread::sleep(POLL_INTERVAL); - } - true + wait_for_recorded_exit(pid as u32, timeout) } #[cfg(any(target_os = "macos", target_os = "linux"))] @@ -378,14 +434,104 @@ fn reap_recorded_daemon() { .is_some_and(|entry| is_reapable_daemon_name(&entry.name)); if matches { log::warn!("reaping unreachable daemon (pid {pid}); its sessions will be hung up"); - for descendant in winproc::descendants(&procs, pid) { - winproc::terminate(descendant); - } - winproc::terminate(pid); + // One deadline across the whole tree: this runs synchronously before + // the first window exists, and a crash that left many hosts behind + // must not multiply the wait by their count. + let mut targets = winproc::descendants(&procs, pid); + targets.push(pid); + winproc::terminate_and_wait_all(&targets, Instant::now() + REAP_WAIT_TIMEOUT); } pidfile::remove(); } +#[cfg(windows)] +const REAP_WAIT_TIMEOUT: Duration = Duration::from_secs(2); + +/// Stops the daemon and then makes the installation directory actually +/// replaceable, which is more than `stop` alone can promise: a daemon that +/// died without cleaning up leaves its ConPTY hosts (OpenConsole.exe) running, +/// orphaned, each holding the installed image open — invisible to the pidfile +/// and fatal to any installer's `DeleteFile`. +/// +/// Terminates every process whose executable lives under `install_dir` +/// (except the caller), then waits until the replaceable images there can be +/// opened for writing. An error names what is still locked, so the update log +/// finally says *why* an upgrade could not replace its files. +#[cfg(windows)] +pub fn stop_for_update(install_dir: &Path) -> Result<(), String> { + use crate::daemon::winproc; + + stop(); + + let deadline = Instant::now() + UPDATE_CLEAR_TIMEOUT; + let holdouts = winproc::processes_running_from(install_dir); + for &pid in &holdouts { + log::warn!( + "terminating pid {pid} still running from {}", + install_dir.display() + ); + } + winproc::terminate_and_wait_all(&holdouts, deadline); + + wait_until_images_unlocked(install_dir, deadline) +} + +#[cfg(windows)] +const UPDATE_CLEAR_TIMEOUT: Duration = Duration::from_secs(10); + +/// Waits until every .exe and .dll directly in `dir` can be opened for +/// writing — the same access an installer needs to replace it. Only the top +/// level: that is where the locked images (tty7-app.exe, OpenConsole.exe, +/// conpty.dll) live, and a recursive sweep would stall on unrelated content. +#[cfg(windows)] +fn wait_until_images_unlocked(dir: &Path, deadline: Instant) -> Result<(), String> { + // Never probe our own image: the legitimate callers run from a staged + // copy outside `dir`, but if someone invokes the *installed* binary with + // this flag, its own image can never open for writing and the wait would + // only ever time out. + let own = std::env::current_exe().and_then(std::fs::canonicalize).ok(); + let images: Vec = std::fs::read_dir(dir) + .map_err(|error| format!("reading {}: {error}", dir.display()))? + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + ext.eq_ignore_ascii_case("exe") || ext.eq_ignore_ascii_case("dll") + }) + }) + .filter(|path| { + // Excluding takes a positive identification: a candidate that + // cannot be canonicalized (delete-pending, held by a scanner) is + // a lock to wait out, not ours to skip. + !own.as_deref() + .is_some_and(|own| std::fs::canonicalize(path).is_ok_and(|path| path == own)) + }) + .collect(); + + let mut locked: Vec<&PathBuf> = images.iter().collect(); + loop { + locked.retain(|path| std::fs::OpenOptions::new().write(true).open(path).is_err()); + if locked.is_empty() { + return Ok(()); + } + if Instant::now() >= deadline { + let names: Vec = locked + .iter() + .filter_map(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .collect(); + return Err(format!( + "these files in {} are still in use by another process: {}", + dir.display(), + names.join(", ") + )); + } + std::thread::sleep(POLL_INTERVAL); + } +} + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] fn reap_recorded_daemon() {} diff --git a/crates/tty7-core/src/daemon/update_guard.rs b/crates/tty7-core/src/daemon/update_guard.rs new file mode 100644 index 00000000..2ced4128 --- /dev/null +++ b/crates/tty7-core/src/daemon/update_guard.rs @@ -0,0 +1,204 @@ +//! A file that says "an installer is replacing this installation right now". +//! +//! Between `spawn::stop_for_update` clearing the installed images and the +//! installer finishing, nothing used to stop a `tty7` CLI call — or a +//! manually launched GUI — from spawning a fresh daemon that relocks the very +//! files being replaced. The guard closes that window: whoever drives the +//! installation holds it, and `spawn::ensure_running` refuses to spawn a +//! daemon while it is held. Only spawning is deferred; connecting to a daemon +//! that is already running stays untouched. +//! +//! The guard names its holder by pid, and a pid is only believed to be the +//! holder while the process behind it *could* be: it must be alive, and it +//! must have started before the guard was written — a process born later +//! merely inherited the number. That check is what lets a holder keep the +//! guard for as long as its installation genuinely runs (an install slowed +//! past any fixed budget by an antivirus sweep stays protected), while a +//! crashed holder's guard goes stale the moment its pid dies or is recycled. +//! Only when the start time cannot be read at all does a TTL bound the doubt. +//! +//! Two kinds of holder: +//! - the auto-updater (`tty7-updater.exe install`/`install-portable`) holds +//! for itself, from stopping the daemon until it relaunches the app; +//! - the `--stop-daemon --update-install-dir` helper that Inno's +//! `PrepareToInstall`/`[UninstallRun]` runs holds for its *parent* — the +//! Setup or uninstaller that keeps replacing files long after the helper +//! returns. Nobody clears that one; it goes stale when Setup exits. + +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use crate::core::config; +use crate::daemon::winproc; + +/// Bounds how long a writer whose start time cannot be read may hold spawns +/// back. Never reached by a verifiable writer — see [`writer_holds`]. +const GUARD_TTL: Duration = Duration::from_secs(10 * 60); + +/// Clock-versus-filesystem slack when comparing a process's start against the +/// guard's mtime. Generous: the two are the same machine's clock, but FAT +/// timestamps are coarse. +const START_SLACK: Duration = Duration::from_secs(10); + +fn path() -> Option { + config::config_path("update.lock") +} + +/// Claims the guard for the calling process. +pub fn hold() { + hold_for(std::process::id()); +} + +/// Claims the guard for the calling process's parent. For the helper Inno +/// runs: the helper exits as soon as the daemon stop returns, but its parent +/// — Setup — lives exactly as long as the files are being replaced, which is +/// the lifetime the guard has to match. Falls back to the caller itself when +/// the parent cannot be named; that guard goes stale at the caller's exit, +/// which is no worse than not holding one. +pub fn hold_for_parent() { + let own = std::process::id(); + let parent = winproc::snapshot() + .iter() + .find(|process| process.pid == own) + .map(|process| process.parent); + match parent { + Some(pid) if pid > 4 => hold_for(pid), + _ => hold(), + } +} + +fn hold_for(pid: u32) { + let Some(path) = path() else { return }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(error) = std::fs::write(&path, pid.to_string()) { + log::warn!( + "could not write the update guard {}: {error}", + path.display() + ); + } +} + +/// Releases the guard. Harmless when it is not held. +pub fn clear() { + if let Some(path) = path() { + let _ = std::fs::remove_file(path); + } +} + +/// Whether an installer is replacing the installation right now. A stale +/// guard — dead or recycled writer, unreadable garbage — is removed on sight, +/// so one crashed holder never costs more than one look. +pub(crate) fn held() -> bool { + let Some(path) = path() else { return false }; + let Ok(contents) = std::fs::read_to_string(&path) else { + return false; + }; + let written = std::fs::metadata(&path) + .and_then(|meta| meta.modified()) + .ok(); + let holds = + contents.trim().parse::().ok().is_some_and(|pid| { + writer_holds(process_alive(pid), winproc::creation_time(pid), written) + }); + if !holds { + log::info!("removing a stale update guard at {}", path.display()); + let _ = std::fs::remove_file(&path); + } + holds +} + +/// The staleness policy, pure so every case is testable: `started` is when +/// the process wearing the recorded pid began, `written` the guard's mtime. +fn writer_holds(alive: bool, started: Option, written: Option) -> bool { + if !alive { + return false; + } + match (started, written) { + // The writer wrote the guard after it started; a "writer" born later + // is a recycled pid wearing its number. A verified writer holds for + // as long as it lives — an install slowed past any fixed budget is + // still an install. + (Some(started), Some(written)) => started <= written + START_SLACK, + // Alive but unverifiable: the TTL bounds how long a pid that cannot + // be told from a recycled one may hold spawns back. + (None, Some(written)) => written.elapsed().is_ok_and(|age| age <= GUARD_TTL), + // No readable mtime to reason from at all. + _ => false, + } +} + +fn process_alive(pid: u32) -> bool { + !winproc::wait_for_exit(pid, Duration::ZERO) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pin_config_dir() { + let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok(); + config::set_config_dir(dir); + } + + fn exited_pid() -> u32 { + let mut child = std::process::Command::new("cmd") + .args(["/C", "exit"]) + .spawn() + .unwrap(); + let pid = child.id(); + child.wait().unwrap(); + pid + } + + #[test] + fn staleness_policy_trusts_only_a_live_writer_born_before_the_guard() { + let now = SystemTime::now(); + let before = now - Duration::from_secs(60); + let later = now + Duration::from_secs(60); + + assert!(writer_holds(true, Some(before), Some(now))); + assert!( + writer_holds(true, Some(now - GUARD_TTL * 3), Some(now - GUARD_TTL * 2)), + "a verified writer is never expired by the TTL, however long it runs" + ); + assert!( + !writer_holds(true, Some(later), Some(now)), + "a process born after the guard is a recycled pid" + ); + assert!(!writer_holds(false, Some(before), Some(now))); + + // Liveness without identity gets exactly the TTL. + assert!(writer_holds(true, None, Some(now))); + assert!(!writer_holds(true, None, Some(now - GUARD_TTL * 2))); + assert!(!writer_holds(true, None, None)); + } + + // One test for the file lifecycle, like the pidfile's: the guard file is + // process-global state, and two tests sharing it would race each other. + #[test] + fn guard_lifecycle_holds_for_a_live_writer_and_sheds_stale_files() { + pin_config_dir(); + clear(); + assert!(!held(), "no guard file, no guard"); + + hold(); + assert!(held(), "this process is alive and older than its guard"); + clear(); + assert!(!held()); + + std::fs::write(path().unwrap(), exited_pid().to_string()).unwrap(); + assert!(!held(), "a dead writer cannot be installing anything"); + assert!( + !path().unwrap().exists(), + "the stale guard is gone after one look" + ); + + // Garbage is stale by the same rule. + std::fs::write(path().unwrap(), "not-a-pid").unwrap(); + assert!(!held()); + assert!(!path().unwrap().exists()); + } +} diff --git a/crates/tty7-core/src/daemon/winproc.rs b/crates/tty7-core/src/daemon/winproc.rs index f9442ba5..0f4c05e6 100644 --- a/crates/tty7-core/src/daemon/winproc.rs +++ b/crates/tty7-core/src/daemon/winproc.rs @@ -86,6 +86,170 @@ fn exe_name(raw: &[u16]) -> String { String::from_utf16_lossy(&raw[..len]) } +/// The full image path of a running process, or `None` for one this user +/// cannot query (system processes, another session's). +pub(crate) fn image_path(pid: u32) -> Option { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, + QueryFullProcessImageNameW, + }; + + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle.is_null() { + return None; + } + let mut buf = [0u16; 1024]; + let mut len = buf.len() as u32; + let ok = QueryFullProcessImageNameW(handle, PROCESS_NAME_WIN32, buf.as_mut_ptr(), &mut len); + CloseHandle(handle); + if ok == 0 { + return None; + } + Some(std::path::PathBuf::from(String::from_utf16_lossy( + &buf[..len as usize], + ))) + } +} + +/// When the process started, or `None` for one this user cannot query. What +/// makes a pid an identity: a process claiming to be the writer of a file +/// must have started *before* that file was written, or it merely inherited +/// the writer's number. +pub(crate) fn creation_time(pid: u32) -> Option { + use windows_sys::Win32::Foundation::{CloseHandle, FILETIME}; + use windows_sys::Win32::System::Threading::{ + GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle.is_null() { + return None; + } + let mut creation: FILETIME = std::mem::zeroed(); + let mut exit: FILETIME = std::mem::zeroed(); + let mut kernel: FILETIME = std::mem::zeroed(); + let mut user: FILETIME = std::mem::zeroed(); + let ok = GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user); + CloseHandle(handle); + if ok == 0 { + return None; + } + let ticks = (u64::from(creation.dwHighDateTime) << 32) | u64::from(creation.dwLowDateTime); + // FILETIME counts 100 ns ticks from 1601-01-01; Unix time starts + // 11 644 473 600 seconds later. + let unix_ticks = ticks.checked_sub(11_644_473_600 * 10_000_000)?; + Some( + std::time::UNIX_EPOCH + + std::time::Duration::new( + unix_ticks / 10_000_000, + (unix_ticks % 10_000_000) as u32 * 100, + ), + ) + } +} + +/// Waits until the process is gone, up to `timeout`. Returns whether it exited +/// in time. A pid that cannot be opened is reported as exited: the handle is +/// what names the process, and no handle means there is nothing left to wait +/// on that this user could ever observe. +pub(crate) fn wait_for_exit(pid: u32, timeout: std::time::Duration) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, WAIT_OBJECT_0}; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + }; + + unsafe { + let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); + if handle.is_null() { + return true; + } + let millis = timeout.as_millis().min(u128::from(u32::MAX - 1)) as u32; + let result = WaitForSingleObject(handle, millis); + CloseHandle(handle); + result == WAIT_OBJECT_0 + } +} + +/// Terminates every pid in order and waits for each to release its image, +/// all bounded by the one `deadline` — never a fresh timeout per process. +/// Callers pass parents after children when a parent could respawn one. +pub(crate) fn terminate_and_wait_all(pids: &[u32], deadline: std::time::Instant) { + for &pid in pids { + terminate(pid); + } + for &pid in pids { + let left = deadline.saturating_duration_since(std::time::Instant::now()); + if !wait_for_exit(pid, left) { + log::warn!("process {pid} did not exit in time"); + } + } +} + +/// Terminates every descendant of `root` and waits for them to release their +/// images, bounded by `timeout` overall. Deepest-first, like the per-pane +/// kill, so a parent never respawns a child we already visited. +pub(crate) fn reap_descendants_of(root: u32, timeout: std::time::Duration) { + terminate_and_wait_all( + &descendants(&snapshot(), root), + std::time::Instant::now() + timeout, + ); +} + +/// Pids (other than the caller's own) whose executable image lives under +/// `dir`. These are the processes that keep Windows from replacing the files +/// there: a stale daemon, or ConPTY hosts orphaned by a daemon that never got +/// to shut down. +pub(crate) fn processes_running_from(dir: &std::path::Path) -> Vec { + let own = std::process::id(); + // Image paths come back in long, resolved form, so a `dir` spelled + // through a junction, a subst drive, or an 8.3 short name would never + // prefix-match them. Trying the canonical spelling as well closes that — + // one syscall for the directory, not one per process. (A miss here still + // cannot let an installer proceed over a lock: `wait_until_images_unlocked` + // probes the files themselves and its callers abort on timeout.) + let canonical = std::fs::canonicalize(dir).ok().and_then(|real| { + let text = real.to_str()?; + Some(std::path::PathBuf::from( + text.strip_prefix(r"\\?\").unwrap_or(text), + )) + }); + snapshot() + .iter() + .filter(|p| p.pid != own && p.pid > 4) + .filter(|p| { + image_path(p.pid).is_some_and(|image| { + path_is_under(&image, dir) + || canonical + .as_deref() + .is_some_and(|canonical| path_is_under(&image, canonical)) + }) + }) + .map(|p| p.pid) + .collect() +} + +/// Whether `path` names a file inside `dir`, the way the filesystem sees it: +/// case-insensitive, and only at a component boundary so `...\tty7-two` is not +/// inside `...\tty7`. +pub(crate) fn path_is_under(path: &std::path::Path, dir: &std::path::Path) -> bool { + let (Some(path), Some(dir)) = (path.to_str(), dir.to_str()) else { + return false; + }; + let path = path.to_lowercase().replace('/', "\\"); + let mut dir = dir + .trim_end_matches(['\\', '/']) + .to_lowercase() + .replace('/', "\\"); + if dir.is_empty() { + return false; + } + dir.push('\\'); + path.starts_with(&dir) +} + #[cfg(test)] mod tests { use super::*; @@ -159,6 +323,36 @@ mod tests { assert_eq!(foreground_name(&procs, 100).as_deref(), Some("b")); } + #[test] + fn path_is_under_respects_component_boundaries_and_case() { + use std::path::Path; + let dir = Path::new(r"C:\Users\me\Apps\tty7"); + assert!(path_is_under( + Path::new(r"C:\Users\me\Apps\tty7\OpenConsole.exe"), + dir + )); + assert!(path_is_under( + Path::new(r"c:\users\me\apps\TTY7\server\tty7-server"), + dir + )); + assert!(!path_is_under( + Path::new(r"C:\Users\me\Apps\tty7-two\a.exe"), + dir + )); + assert!(!path_is_under(Path::new(r"C:\Users\me\Apps\tty7"), dir)); + assert!(path_is_under( + Path::new(r"C:\Users\me\Apps\tty7\x.exe"), + Path::new(r"C:\Users\me\Apps\tty7\"), + )); + assert!(!path_is_under(Path::new(r"C:\anything"), Path::new(""))); + } + + #[test] + fn creation_time_of_this_process_is_in_the_past() { + let started = creation_time(std::process::id()).expect("own process is queryable"); + assert!(started <= std::time::SystemTime::now()); + } + #[test] fn exe_name_reads_up_to_the_nul() { let mut raw = [0u16; 260]; diff --git a/src/bin/tty7-updater.rs b/src/bin/tty7-updater.rs index e15aec45..f2a54b94 100644 --- a/src/bin/tty7-updater.rs +++ b/src/bin/tty7-updater.rs @@ -283,13 +283,28 @@ mod macos { } fn wait_for_exit(pid: u32) { + // The updater is spawned directly by the app it waits for, so while + // that app lives it *is* this process's parent, and the kernel + // reparents us to launchd the moment it exits. Watching getppid() is + // therefore immune to pid reuse, which `kill(pid, 0)` is not: a + // recycled pid keeps answering 0 forever. (Windows solves the same + // race by holding a process handle — see the windows module.) + let pid = pid as libc::pid_t; + if unsafe { libc::getppid() } == pid { + while unsafe { libc::getppid() } == pid { + thread::sleep(PARENT_POLL); + } + return; + } + // Not our parent — a hand-run updater. The polling fallback keeps + // that invocation working, pid-reuse caveat and all. while process_alive(pid) { thread::sleep(PARENT_POLL); } } - fn process_alive(pid: u32) -> bool { - unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } + fn process_alive(pid: libc::pid_t) -> bool { + unsafe { libc::kill(pid, 0) == 0 } } fn remove_path(path: &Path) -> Result<(), String> { @@ -486,6 +501,14 @@ mod windows { const PORTABLE_PAYLOAD_DIR: &str = "portable-payload"; const PORTABLE_MARKER: &str = ".tty7-portable"; const PORTABLE_MARKER_CONTENT: &[u8] = b"portable-v1"; + /// Lives inside a portable-update backup from before the first installed + /// file moves until the replacement is fully in place. A backup found + /// later still carrying it names a replacement that was cut short — + /// power loss, a kill — and an installation that may mix two versions; + /// one without it is a finished update whose backup deletion lost to an + /// antivirus scan. The app reads it at launch: duplicated in + /// src/core/update.rs, like the portable marker above. + const PORTABLE_BACKUP_INCOMPLETE: &str = ".tty7-replace-incomplete"; const MAX_PORTABLE_ENTRIES: usize = 4096; const MAX_PORTABLE_EXPANDED_BYTES: u64 = 1024 * 1024 * 1024; // Everything the release package owns: an entry outside this list is @@ -668,6 +691,22 @@ mod windows { return recover_from_failed_update(&plan, error); } + // Setup's own PrepareToInstall repeats this, but doing it here first + // means a directory that cannot be cleared fails with a named cause in + // this log instead of Inno's bare "DeleteFile failed; code 5" — and the + // previous app is relaunched instead of being left half-replaced. + log_line( + &plan.log, + "stopping the tty7 daemon and clearing installed-file locks", + ); + // From here until the relaunch, a `tty7` CLI call or a manual launch + // must not spawn a daemon that relocks the files Setup is replacing. + // launch_app releases the guard on every path out of this function. + tty7_core::daemon::update_guard::hold(); + if let Err(error) = tty7_core::daemon::spawn::stop_for_update(&plan.install_dir) { + return recover_from_failed_update(&plan, error); + } + log_line(&plan.log, "running the tty7 Windows installer"); let status = match run_installer(&plan.installer, &plan.log) { Ok(status) => status, @@ -726,7 +765,11 @@ mod windows { &plan.log, "stopping the tty7 daemon before replacing portable files", ); - if let Err(error) = stop_daemon_from_payload(&payload) { + // Held for the whole replacement, exactly as in `install`; the pid it + // records must be this process's — the payload child below exits + // immediately, and a guard naming a dead writer holds nothing. + tty7_core::daemon::update_guard::hold(); + if let Err(error) = stop_daemon_from_payload(&payload, &plan.install_dir) { return recover_without_replacement(&plan.log, &plan.install_dir, &plan.stage, error); } @@ -1097,10 +1140,12 @@ mod windows { .map_err(|error| format!("starting {}: {error}", installer.display())) } - fn stop_daemon_from_payload(payload: &Path) -> Result<(), String> { + fn stop_daemon_from_payload(payload: &Path, install_dir: &Path) -> Result<(), String> { let executable = payload.join("tty7-app.exe"); let status = Command::new(&executable) .arg("--stop-daemon") + .arg("--update-install-dir") + .arg(install_dir) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -1144,6 +1189,15 @@ mod windows { return Err(with_relaunch_failure(cause, relaunch_previous(install_dir))); } }; + // Marked incomplete before any installed file moves. Nothing here + // removes the marker on rollback: a rollback that succeeds removes + // the whole backup, and one that fails leaves an installation whose + // state really is suspect. + if let Err(error) = fs::write(backup.join(PORTABLE_BACKUP_INCOMPLETE), b"") { + let cause = format!("marking the update backup {}: {error}", backup.display()); + let _ = remove_path(&backup); + return Err(with_relaunch_failure(cause, relaunch_previous(install_dir))); + } let mut moved = Vec::new(); for root in PORTABLE_MANAGED_ROOTS { @@ -1183,6 +1237,9 @@ mod windows { // The replacement survived its launch grace period. Old managed files // are no longer needed; an antivirus-held backup is harmless and can be // removed manually rather than turning a successful update into rollback. + // The marker leaves first: a backup that outlives this process without + // it is finished business the next launch may discard on its own. + let _ = fs::remove_file(backup.join(PORTABLE_BACKUP_INCOMPLETE)); let _ = remove_path(&backup); Ok(()) } @@ -1324,6 +1381,10 @@ mod windows { } fn launch_app(install_dir: &Path) -> Result<(), String> { + // Every relaunch — success, failure recovery, rollback — is a point + // where the installation is no longer being replaced, so the daemon + // spawn guard ends here, before the app comes up and asks for one. + tty7_core::daemon::update_guard::clear(); let executable = install_dir.join("tty7-app.exe"); let mut child = Command::new(&executable) .stdin(Stdio::null()) @@ -1847,6 +1908,51 @@ mod windows { ); } + #[test] + fn a_backup_carries_the_incomplete_marker_exactly_while_files_move() { + let install = tempfile::tempdir().unwrap(); + let payload = tempfile::tempdir().unwrap(); + fs::write(install.path().join("tty7-app.exe"), b"old app").unwrap(); + fs::write(payload.path().join("tty7-app.exe"), b"new app").unwrap(); + let install_dir = install.path().to_path_buf(); + + replace_portable_and_relaunch( + install.path(), + payload.path(), + move |_| { + let backups: Vec<_> = fs::read_dir(&install_dir) + .unwrap() + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(".tty7-update-backup-")) + }) + .collect(); + assert_eq!(backups.len(), 1, "one backup during the replacement"); + assert!( + backups[0].join(PORTABLE_BACKUP_INCOMPLETE).is_file(), + "the marker is present while files are moving" + ); + Ok(()) + }, + |_| panic!("the previous version must not relaunch after success"), + ) + .unwrap(); + + let leftovers: Vec<_> = fs::read_dir(install.path()) + .unwrap() + .flatten() + .map(|entry| entry.file_name()) + .collect(); + assert_eq!( + leftovers, + vec![std::ffi::OsString::from("tty7-app.exe")], + "no backup and no marker survive a completed replacement" + ); + } + #[test] fn portable_replacement_rolls_back_when_the_new_app_does_not_start() { let install = tempfile::tempdir().unwrap(); diff --git a/src/core/update.rs b/src/core/update.rs index 4b321578..60ab8f5a 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -89,6 +89,14 @@ const WINDOWS_INNO_INSTALL_MARKER: &str = ".tty7-inno-install"; const WINDOWS_PORTABLE_MARKER: &str = ".tty7-portable"; #[cfg(target_os = "windows")] const WINDOWS_PORTABLE_MARKER_CONTENT: &[u8] = b"portable-v1"; +/// What the updater names the directory it moves the old portable files into. +#[cfg(target_os = "windows")] +const WINDOWS_PORTABLE_BACKUP_PREFIX: &str = ".tty7-update-backup-"; +/// Present inside that backup from before the first installed file moves +/// until the replacement is complete — see `PORTABLE_BACKUP_INCOMPLETE` in +/// src/bin/tty7-updater.rs, which this duplicates like the markers above. +#[cfg(target_os = "windows")] +const WINDOWS_PORTABLE_BACKUP_INCOMPLETE: &str = ".tty7-replace-incomplete"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct AvailableUpdate { @@ -188,6 +196,15 @@ pub struct UpdateStatus { impl Global for UpdateStatus {} pub fn spawn_check(cx: &mut App) { + // Before hydration and before the config gate: an interrupted portable + // replacement has to surface whether or not checking is on, and it is + // recorded as a failure so `hydrate_from_disk` below carries it into the + // UI. The scan itself is one `read_dir`; the deletions it hands back ride + // the background sweep. + #[cfg(target_os = "windows")] + let finished_backups = reconcile_portable_backups(); + #[cfg(not(target_os = "windows"))] + let finished_backups: Vec = Vec::new(); // Before the config gate: a package staged by an earlier run, or a failure // from one, has to reach Settings whether or not checking is still on. hydrate_from_disk(cx); @@ -195,7 +212,16 @@ pub fn spawn_check(cx: &mut App) { // waits on the result. let keep = UpdateState::load().pending.map(|pending| pending.stage); cx.background_executor() - .spawn(async move { sweep_orphaned_stages(keep) }) + .spawn(async move { + for backup in finished_backups { + log::info!( + "removing a completed update's leftover backup at {}", + backup.display() + ); + let _ = std::fs::remove_dir_all(&backup); + } + sweep_orphaned_stages(keep) + }) .detach(); if !cx.global::().check_for_updates { return; @@ -406,8 +432,16 @@ fn prompt_update(update: &AvailableUpdate, window: &mut Window, cx: &mut App) { .as_ref() .map(localized_update_install_hint); let detail = if update.installable { + // Windows cannot replace a running daemon's image, so its install path + // stops the background service — the promise that panes survive is + // only true where the daemon really does keep running (macOS). + let detail_key = if cfg!(target_os = "windows") { + L10nKey::UpdateDialogDetailWindows + } else { + L10nKey::UpdateDialogDetail + }; let base = t_fmt( - L10nKey::UpdateDialogDetail, + detail_key, &[ ("version", update.version.as_str()), ("current", current_version()), @@ -963,6 +997,80 @@ fn is_stage_name(name: &str) -> bool { name.starts_with(".tty7-update-") || name.starts_with("tty7-update-") } +/// Deals with `.tty7-update-backup-*` directories a previous portable update +/// left in the installation. +/// +/// One still carrying the incomplete marker means the replacement was cut +/// short — power loss, a kill — and the installed files may mix two versions. +/// That is recorded as an update failure so Settings shows it (and shows it +/// again at every launch until the user restores or deletes the backup: the +/// warning describes a condition, not an event). The backup itself is kept — +/// it holds the only copy of the previous files. +/// +/// One without the marker is a completed update whose backup deletion lost to +/// an antivirus scan; those are returned for the background sweep to remove. +#[cfg(target_os = "windows")] +fn reconcile_portable_backups() -> Vec { + let Some(WindowsUpdateLayout::Portable(dir)) = current_windows_update_layout() else { + return Vec::new(); + }; + let (interrupted, finished) = scan_portable_backups(&dir); + if !interrupted.is_empty() { + // All of them, not the first: two interrupted attempts in a row leave + // two backups, and one the user never hears about is one they delete + // blind or keep forever. + let preserved = interrupted + .iter() + .map(|backup| backup.display().to_string()) + .collect::>() + .join(", "); + let detail = format!( + "a previous update was interrupted while replacing the installed files, so {} may \ + mix two versions; the files from before are preserved at {preserved} — restore \ + them or reinstall, then delete the backup", + dir.display() + ); + log::warn!("{detail}"); + let mut state = UpdateState::load(); + if state.last_failure.as_ref().map(|failure| &failure.detail) != Some(&detail) { + state.last_failure = Some(FailureRecord { + version: current_version().to_string(), + detail, + }); + state.save(); + } + } + finished +} + +/// Splits the backups under `dir` into (interrupted, finished) by the +/// incomplete marker. Pure directory inspection, so the policy above stays +/// testable without a real installation. +#[cfg(target_os = "windows")] +fn scan_portable_backups(dir: &Path) -> (Vec, Vec) { + let mut interrupted = Vec::new(); + let mut finished = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return (interrupted, finished); + }; + for entry in entries.flatten() { + let path = entry.path(); + let named = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(WINDOWS_PORTABLE_BACKUP_PREFIX)); + if !named || !path.is_dir() { + continue; + } + if path.join(WINDOWS_PORTABLE_BACKUP_INCOMPLETE).is_file() { + interrupted.push(path); + } else { + finished.push(path); + } + } + (interrupted, finished) +} + pub(crate) fn localized_update_phase(phase: &UpdatePhase) -> Option { match phase { UpdatePhase::Idle => None, @@ -2031,6 +2139,24 @@ mod tests { ); } + #[cfg(target_os = "windows")] + #[test] + fn portable_backup_scan_separates_interrupted_from_finished() { + let root = tempfile::tempdir().unwrap(); + let interrupted = root.path().join(".tty7-update-backup-cut"); + std::fs::create_dir(&interrupted).unwrap(); + std::fs::write(interrupted.join(WINDOWS_PORTABLE_BACKUP_INCOMPLETE), b"").unwrap(); + let finished = root.path().join(".tty7-update-backup-done"); + std::fs::create_dir(&finished).unwrap(); + // Neither a user's directory nor a stray file may be touched. + std::fs::create_dir(root.path().join("completions")).unwrap(); + std::fs::write(root.path().join(".tty7-update-backup-not-a-dir"), b"file").unwrap(); + + let (got_interrupted, got_finished) = scan_portable_backups(root.path()); + assert_eq!(got_interrupted, vec![interrupted]); + assert_eq!(got_finished, vec![finished]); + } + #[test] fn parses_versions_with_and_without_prefix() { let release = |major, minor, patch| Some((major, minor, patch, true, vec![])); diff --git a/src/main.rs b/src/main.rs index 894d4343..b298ddce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -143,6 +143,21 @@ fn open_path_from( None } +/// The directory an installer is about to replace, from +/// `--stop-daemon --update-install-dir `. Meaningful only next to +/// `--stop-daemon`; the caller checks that flag first. +#[cfg(windows)] +fn update_install_dir_from( + mut args: impl Iterator, +) -> Option { + while let Some(arg) = args.next() { + if arg == std::ffi::OsStr::new("--update-install-dir") { + return args.next().map(Into::into); + } + } + None +} + /// `Some(true)` to register the Explorer verbs, `Some(false)` to remove them. fn explorer_menu_action_from(args: &[std::ffi::OsString]) -> Option { args.iter().find_map(|arg| match arg.as_os_str() { @@ -343,6 +358,28 @@ fn main() { .iter() .any(|arg| arg == std::ffi::OsStr::new("--stop-daemon")) { + // An installer about to replace `dir` says so, and gets more than a + // stop: orphaned ConPTY hosts and anything else still running from + // that directory are terminated, and the call does not return until + // the images there are actually replaceable (or says why they are + // not). Invoked by the Inno PrepareToInstall step and the updater. + #[cfg(windows)] + if let Some(dir) = update_install_dir_from(args.iter().cloned()) { + // Held in the *parent's* name: this helper returns in seconds, + // but the Setup (or uninstaller) that invoked it keeps replacing + // files in `dir` until it exits — and a daemon spawned in that + // window would relock them. The guard needs no clearing; it goes + // stale the moment that parent is gone. + tty7_core::daemon::update_guard::hold_for_parent(); + if let Err(error) = crate::daemon::spawn::stop_for_update(&dir) { + log::error!( + "preparing {} for replacement failed: {error}", + dir.display() + ); + std::process::exit(1); + } + return; + } crate::daemon::spawn::stop(); return; } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 4a064580..c1694e22 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -499,6 +499,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::UpdateDialogDetail => { "tty7 {version} is available — you're on {current}. Installing restarts the app; the background service keeps running, so whatever is open in your panes survives." } + L10nKey::UpdateDialogDetailWindows => { + "tty7 {version} is available — you're on {current}. Installing restarts the app and the background service: processes running in your panes are ended, and your tabs and layout come back with fresh shells." + } L10nKey::UpdateDialogDetailManual => { "tty7 {version} is available — you're on {current}. {hint}" } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index ba37a9d7..de7107d9 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -499,6 +499,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::UpdateDialogDetail => { "tty7 {version} が利用できます(現在 {current})。インストールするとアプリが再起動します。バックグラウンドサービスは動いたままなので、ペインで開いているものはそのまま残ります。" } + L10nKey::UpdateDialogDetailWindows => { + "tty7 {version} が利用できます(現在 {current})。インストールするとアプリとバックグラウンドサービスが再起動します。ペインで実行中のプロセスは終了し、タブとレイアウトは新しいシェルで復元されます。" + } L10nKey::UpdateDialogDetailManual => { "tty7 {version} が利用できます(現在 {current})。{hint}" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index ac1a5e3e..51cb731c 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -423,6 +423,7 @@ pub enum L10nKey { SettingsDaemonStaleRestart, UpdateDialogTitle, UpdateDialogDetail, + UpdateDialogDetailWindows, UpdateDialogDetailManual, UpdateDialogCannotSelfUpdate, UpdateDialogLater, @@ -1442,6 +1443,7 @@ mod tests { L10nKey::SettingsDaemonStaleRestart, L10nKey::UpdateDialogTitle, L10nKey::UpdateDialogDetail, + L10nKey::UpdateDialogDetailWindows, L10nKey::UpdateDialogDetailManual, L10nKey::UpdateDialogCannotSelfUpdate, L10nKey::UpdateDialogLater, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index fdd9f91c..f41f50ff 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -442,6 +442,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::UpdateDialogDetail => { "tty7 {version} 已发布,你现在是 {current}。安装会重启应用;后台服务不动,pane 里开着的东西都还在。" } + L10nKey::UpdateDialogDetailWindows => { + "tty7 {version} 已发布,你现在是 {current}。安装会重启应用和后台服务:pane 里正在运行的进程会被结束,标签页和布局会以全新的 shell 恢复。" + } L10nKey::UpdateDialogDetailManual => "tty7 {version} 已发布,你现在是 {current}。{hint}", L10nKey::UpdateDialogCannotSelfUpdate => "这份安装无法自行更新。", L10nKey::UpdateDialogLater => "以后再说",