mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
fix(windows): make the install directory actually replaceable before updating (#403)
* 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>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -447,6 +447,18 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> 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);
|
||||
}
|
||||
|
||||
@@ -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<PathBuf> = 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<String> = 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() {}
|
||||
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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::<u32>().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<SystemTime>, written: Option<SystemTime>) -> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<std::path::PathBuf> {
|
||||
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<std::time::SystemTime> {
|
||||
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<u32> {
|
||||
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];
|
||||
|
||||
+110
-4
@@ -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();
|
||||
|
||||
+128
-2
@@ -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<PathBuf> = 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::<Config>().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<PathBuf> {
|
||||
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::<Vec<_>>()
|
||||
.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<PathBuf>, Vec<PathBuf>) {
|
||||
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<String> {
|
||||
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![]));
|
||||
|
||||
+37
@@ -143,6 +143,21 @@ fn open_path_from(
|
||||
None
|
||||
}
|
||||
|
||||
/// The directory an installer is about to replace, from
|
||||
/// `--stop-daemon --update-install-dir <dir>`. Meaningful only next to
|
||||
/// `--stop-daemon`; the caller checks that flag first.
|
||||
#[cfg(windows)]
|
||||
fn update_install_dir_from(
|
||||
mut args: impl Iterator<Item = std::ffi::OsString>,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
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<bool> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => "以后再说",
|
||||
|
||||
Reference in New Issue
Block a user