fix(update): survive an exe that cannot be renamed
Several operators hit "stage current exe: rename …\OpsLog.exe …\OpsLog.exe.old: Accès refusé" and could not update again. Two separate causes, both ours to handle. The staging name was fixed. os.Rename replaces its target, so a single leftover ".old" that could not be deleted — a scanner holding it open is the usual reason, and the pre-existing os.Remove was best-effort and ignored — made every later update fail with that error, permanently, recoverable only by deleting the file by hand. Staging now uses a unique ".old-<nanos>", which no leftover can block, and the startup cleanup sweeps the pattern instead of one name. Renaming a running image is legal on Windows, but some endpoint protection (Bitdefender's ransomware remediation among them) blocks it outright, and no retry gets past that. So the swap is deferred: the new build is parked beside the old one and a detached helper moves it into place after this process exits, when the file is no longer a running image. It keeps trying for ten seconds, since a scanner tends to let go a beat after the process dies. A short retry stays in front of both, for the ordinary case of a scanner holding the file it has just watched being written. If even the deferred move fails, OpsLog restarts on the CURRENT version rather than leaving the operator with nothing — someone mid-QSO losing their logger is worse than an update that waits — and only a successful swap passes --post-update, so the download survives for the next attempt instead of being swept by the cleanup.
This commit is contained in:
@@ -150,15 +150,55 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
||||
// Swap: rename the running exe out of the way (Windows allows renaming a
|
||||
// running image), move the new one into its place, then relaunch. Roll back if
|
||||
// the second rename fails so we never end up with no exe.
|
||||
oldExe := exe + ".old"
|
||||
_ = os.Remove(oldExe)
|
||||
if err := os.Rename(exe, oldExe); err != nil {
|
||||
_ = os.Remove(newExe)
|
||||
return fmt.Errorf("stage current exe: %w", err)
|
||||
//
|
||||
// The staging name is UNIQUE, not a fixed ".old". With a fixed name, one
|
||||
// leftover that could not be deleted — an antivirus holding it open is the
|
||||
// usual reason — poisoned every later update: the rename replaces its target,
|
||||
// the target was locked, and the operator got "stage current exe: … Accès
|
||||
// refusé" for ever with no way out but deleting the file by hand.
|
||||
oldExe := fmt.Sprintf("%s.old-%d", exe, time.Now().UnixNano())
|
||||
var stageErr error
|
||||
staged := false
|
||||
// Retry briefly: a real-time scanner opens the file it has just seen written
|
||||
// and holds it for a moment, so the first attempt lands exactly in that window.
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
if stageErr = os.Rename(exe, oldExe); stageErr == nil {
|
||||
staged = true
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Duration(150*(attempt+1)) * time.Millisecond)
|
||||
}
|
||||
if err := os.Rename(newExe, exe); err != nil {
|
||||
_ = os.Rename(oldExe, exe) // roll back
|
||||
return fmt.Errorf("install new exe: %w", err)
|
||||
|
||||
if staged {
|
||||
if err := os.Rename(newExe, exe); err != nil {
|
||||
_ = os.Rename(oldExe, exe) // roll back
|
||||
return fmt.Errorf("install new exe: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Could not rename our own running image at all. Some endpoint protection
|
||||
// (Bitdefender's ransomware remediation among them) blocks precisely that,
|
||||
// and no amount of retrying gets past it.
|
||||
//
|
||||
// So don't fight it: leave the new build beside the old one and let the
|
||||
// relaunch helper do the swap AFTER this process has exited, when the file
|
||||
// is no longer a running image. Reported by several operators, all with the
|
||||
// same "Accès refusé" on the staging rename.
|
||||
applog.Printf("update: cannot rename the running exe (%v) — deferring the swap to after exit", stageErr)
|
||||
pending := exe + ".new"
|
||||
_ = os.Remove(pending)
|
||||
if err := os.Rename(newExe, pending); err != nil {
|
||||
_ = os.Remove(newExe)
|
||||
return fmt.Errorf("stage new exe: %w (the folder %s must be writable, and an antivirus may be holding OpsLog.exe)", err, dir)
|
||||
}
|
||||
if err := a.scheduleDeferredSwap(exe, pending); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.Quit(a.ctx)
|
||||
} else {
|
||||
os.Exit(0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
|
||||
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
|
||||
@@ -189,6 +229,46 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// scheduleDeferredSwap hands the exe swap to a detached helper that runs AFTER
|
||||
// this process is gone.
|
||||
//
|
||||
// The fallback for when the running image cannot be renamed at all. Once OpsLog
|
||||
// has exited its exe is an ordinary file again, so the move that was refused a
|
||||
// moment earlier succeeds — and the helper keeps trying for ten seconds, because
|
||||
// an antivirus that was holding the file usually lets go a beat after the
|
||||
// process dies rather than instantly.
|
||||
//
|
||||
// OpsLog is restarted either way. If the move failed, that starts the OLD build
|
||||
// — the update simply has not applied — and the operator keeps a working logger
|
||||
// instead of having it vanish mid-session, which for someone in a QSO is worse
|
||||
// than an update that waits. Only a successful swap passes --post-update, so a
|
||||
// failure leaves the .new file in place for the next attempt rather than having
|
||||
// the cleanup delete the download.
|
||||
func (a *App) scheduleDeferredSwap(exe, pending string) error {
|
||||
// Clear the "downloaded from the internet" mark before it becomes the exe —
|
||||
// SmartScreen silently blocks a programmatic launch of a marked file, and the
|
||||
// mark follows the file across the move.
|
||||
_ = os.Remove(pending + ":Zone.Identifier")
|
||||
|
||||
q := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
|
||||
ps := fmt.Sprintf(
|
||||
"Wait-Process -Id %d -ErrorAction SilentlyContinue; "+
|
||||
"$ok=$false; "+
|
||||
"for ($i=0; $i -lt 40; $i++) { "+
|
||||
"try { Move-Item -LiteralPath '%s' -Destination '%s' -Force -ErrorAction Stop; $ok=$true; break } "+
|
||||
"catch { Start-Sleep -Milliseconds 250 } }; "+
|
||||
"if ($ok) { Start-Process -FilePath '%s' -ArgumentList '--post-update' } "+
|
||||
"else { Start-Process -FilePath '%s' }",
|
||||
os.Getpid(), q(pending), q(exe), q(exe), q(exe))
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("schedule the update swap: %w", err)
|
||||
}
|
||||
applog.Printf("update: swap scheduled for after exit (%s → %s)", filepath.Base(pending), filepath.Base(exe))
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadWithProgress streams url into dest, emitting "update:progress" (0-100).
|
||||
func (a *App) downloadWithProgress(url, dest string) error {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
@@ -274,12 +354,27 @@ func extractExeFromZip(zipPath, dir string) (string, error) {
|
||||
return "", fmt.Errorf("no .exe inside the archive")
|
||||
}
|
||||
|
||||
// cleanupOldUpdateBinary removes the previous exe left behind by a self-update
|
||||
// (exe + ".old"). Called at startup after a --post-update relaunch. Best-effort:
|
||||
// the file may still be briefly locked, in which case the next launch gets it.
|
||||
// cleanupOldUpdateBinary removes what a self-update left behind. Called at
|
||||
// startup after a --post-update relaunch. Best-effort throughout: a file may
|
||||
// still be locked by a scanner, and the next launch will get it.
|
||||
//
|
||||
// Sweeps a PATTERN, not one name. Staging uses a unique ".old-<nanos>" precisely
|
||||
// so a locked leftover cannot block the next update, which means leftovers
|
||||
// accumulate unless something collects them — and the pre-0.24.1 ".old" may be
|
||||
// sitting there too, from the very update that could not delete it.
|
||||
func cleanupOldUpdateBinary() {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
_ = os.Remove(exe + ".old")
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(exe + ".old") // the old fixed name
|
||||
_ = os.Remove(exe + ".new") // a deferred swap that has been applied
|
||||
matches, err := filepath.Glob(exe + ".old-*")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, m := range matches {
|
||||
_ = os.Remove(m)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user