Both the in-app restart and the auto-update install previously shelled out to cmd.exe so the new instance could wait for the old one to release the singleton mutex and the locked exe file. On some Windows configurations the `start ""` inside `cmd /c ...` can flash a console window despite CREATE_NO_WINDOW + DETACHED_PROCESS flags. The replacement spawns the child binary directly via CreateProcessW; since the main exe is built with windows_subsystem = "windows", no console is ever allocated. - New `src/update/handoff.rs` exposes `spawn_detached`, `wait_for_parent_exit`, and `cleanup_stale_old_exes`. - New CLI flags `--wait-pid <pid>` and `--updated-to <version>` parsed early in `main`; the child waits up to 5s on the parent PID via OpenProcess+WaitForSingleObject before falling through to a 3s mutex-acquisition retry. - `restart_app` and `install::begin` both spawn detached children using the new helper. - Update install now uses MoveFileExW twice (rename running exe sideways, then move staged exe into place with MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED so portable installs on non-system drives still work). Rollback restores the backup if either the swap OR the post-swap detached spawn fails, and a MessageBoxW modal surfaces the backup path if the rollback itself fails. - First launch after an auto-update shows a blue-info tray balloon "Updated to vX.Y.Z" via a new `tray::notify_info` (the existing `tray::notify` is split into `notify_warning` + `notify_info` sharing a `notify_inner`). - Startup sweeps stale `<exe>.old.<pid>` siblings left by past in-place updates. - Three new `LocaleStrings` fields translated across all 8 supported locales (en/nl/es/fr/de/ja/ko/zh-TW).
6.3 KiB
phase, title, status, priority, effort, dependencies
| phase | title | status | priority | effort | dependencies |
|---|---|---|---|---|---|
| 1 | Foundation: CLI flags + native spawn helper + mutex retry | complete | P1 | 3h |
Phase 1: Foundation: CLI flags + native spawn helper + mutex retry
Overview
Build the primitives that phases 2-4 reuse: a CLI argument parser for --wait-pid <pid> and --updated-to <version>, a spawn_detached_self helper that calls CreateProcessW directly (no cmd.exe), and mutex-acquisition retry logic that activates only when --wait-pid was passed.
Requirements
Functional
- Parse
--wait-pid <u32>and--updated-to <version-string>fromstd::env::argswithout breaking existing flags (--diagnose,--apply-update). - Expose
spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()>that usesCreateProcessWwithCREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP. - Expose
wait_for_parent_exit(pid: u32, timeout_ms: u32)that usesOpenProcess(SYNCHRONIZE)+WaitForSingleObject. Returns silently on timeout (best-effort). - Mutex acquisition in
app::runretriesCreateMutexWfor ~3 seconds (200ms backoff) ONLY when--wait-pidwas present; preserves today's immediate-fail behavior for normal startup.
Non-functional
- No new external dependencies. All Win32 calls via the existing
windows = "0.58"crate features. - Helper module ≤ ~120 lines total.
Architecture
src/
├── update/
│ └── handoff.rs ← NEW: spawn_detached, wait_for_parent_exit, cleanup_stale_old_exes
├── main.rs ← parse --wait-pid early, call wait_for_parent_exit BEFORE app::run
└── app.rs ← run() reads a static "wait_pid_was_passed" flag, retries mutex if set
Rationale for src/update/handoff.rs: keeps low-level Win32 process/file ops alongside the update module that uses them most. app.rs and main.rs import it for restart + post-update bootstrap.
Related Code Files
- Create:
src/update/handoff.rs - Modify:
src/main.rs(early arg parse + wait + flag handoff to app) - Modify:
src/update/mod.rs(declarepub mod handoff) - Modify:
src/app.rs(mutex retry loop, gated on flag from main) - Modify:
Cargo.tomlif a newwindowsfeature is needed (likelyWin32_System_Threadingalready coversOpenProcess/WaitForSingleObject/CreateProcessW)
Implementation Steps
-
Audit
windowscrate features. ConfirmWin32_System_Threadingis inCargo.toml(it is — line 31). VerifyCreateProcessW,STARTUPINFOW,PROCESS_INFORMATIONare accessible. AddWin32_Storage_FileSystemif not already present (needed for phase 3'sMoveFileExW). -
Create
src/update/handoff.rswith three pub fns:pub fn spawn_detached(exe: &Path, args: &[OsString]) -> io::Result<()>- Build a wide-char command line: quoted exe path + space-joined args, NUL-terminated.
STARTUPINFOWzero-initialized,cbset.CreateProcessW(NULL, cmdline_wide.as_mut_ptr(), NULL, NULL, FALSE, CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, NULL, NULL, &si, &pi).- Close
pi.hProcessandpi.hThreadimmediately (fire-and-forget).
pub fn wait_for_parent_exit(pid: u32, timeout_ms: u32)OpenProcess(SYNCHRONIZE, FALSE, pid). If it fails (parent already gone), return immediately.WaitForSingleObject(h, timeout_ms). Ignore return value.CloseHandle(h).
pub fn cleanup_stale_old_exes(current_exe: &Path)(used by phase 4; stub here, fill in phase 4)- Stub: returns
Ok(()).
- Stub: returns
-
Modify
src/update/mod.rsto addpub mod handoff;. -
Modify
src/main.rs— insert BEFOREapp::run():let wait_pid = args.iter() .position(|a| a == "--wait-pid") .and_then(|i| args.get(i + 1)) .and_then(|s| s.parse::<u32>().ok()); if let Some(pid) = wait_pid { update::handoff::wait_for_parent_exit(pid, 5_000); }Note: keep this AFTER
update::run_cliso the legacy--apply-updatepath still short-circuits cleanly. -
Modify
src/app.rs::run— gate mutex retry on whether--wait-pidappeared. Cheapest implementation: re-parsestd::env::argsonce at the top ofrun(). Then around line 156:let retry_mutex = std::env::args().any(|a| a == "--wait-pid"); let _mutex = acquire_singleton_mutex(retry_mutex)?; // new helperNew helper
acquire_singleton_mutex(retry: bool):- If
!retry: today's behavior (fail immediately onERROR_ALREADY_EXISTS). - If
retry: loop CreateMutexW → onALREADY_EXISTS,Sleep(200)and retry. Budget 15 iterations = ~3 seconds. Log every retry. After budget exhausted, return error and exit cleanly.
- If
-
Compile check.
cargo build --release. Fix any feature gaps. No behavior should change yet —--wait-pidarg is parsed but no caller passes it yet.
Success Criteria
cargo build --releasesucceeds with zero warnings beyond the existingdead_codeallow.- Running the binary normally (no flags) behaves identically to today (mutex check is immediate, no retry).
- Running the binary with
--wait-pid <pid-of-running-instance>against a live instance: the new process waits ≤5s for old one to exit, then acquires the mutex within ~200ms of its release. Verifiable by killing the original after 2s and watching the new one continue. update::handoff::spawn_detachedsmoke test: from a small one-off snippet inmain(gated behind a never-used flag) verify CreateProcessW returns success and pid increments. Remove before commit.
Risk Assessment
| Risk | Mitigation |
|---|---|
| Wide-char cmdline construction has off-by-one / missing NUL | Hand-test with a path containing spaces; assert OsStringExt::encode_wide produces expected bytes |
| `CREATE_NO_WINDOW | DETACHED_PROCESS` combination misbehaves on GUI subsystem binaries |
| Mutex retry loop hangs forever if budget logic wrong | Hard cap = 15 iterations × 200ms = 3.0s. After that, exit. Add log line per retry so a stuck loop is visible in --diagnose |
OpenProcess(SYNCHRONIZE, ...) returns access-denied for cross-session |
Fallback: WaitForSingleObject simply isn't called; mutex retry compensates |