Skip to content

Commit 0347cf6

Browse files
author
Dennis Sepede
committed
fix(updater): self-replace bat retries move; hard-exit to release lock
Dennis's "Update app from tray" produced no UAC prompt and no visible change — the old exe stayed in place silently. Cause: the .bat did a single `move /y` after a 2-second wait. Windows held the file lock on the just-exited PyInstaller exe (bootloader teardown + AV scanning the new file) for longer than 2 s. The single move silently failed; the relaunch then started the still-old exe. Two fixes baked in: 1) `_write_self_replace_bat` now retries `move /y` up to 30 times with 1 s between attempts after a 3 s initial wait. As soon as the move succeeds we relaunch; if all 30 retries fail we leave the new exe in place so the user can swap it manually. 2) `install_and_restart` now calls `os._exit(0)` instead of `sys.exit(0)`. SystemExit only unwinds the calling thread — daemon threads + pystray's Windows message loop kept the process alive (and the file lock with it) long enough that the bat's retry loop sometimes still timed out. The tray icon, bridge HTTP server, and presence loop are also stopped cleanly before the hard exit so resources have a chance to release. Bumps presence to 0.3.17. Firmware unchanged from 0.3.16.
1 parent b534bbe commit 0347cf6

4 files changed

Lines changed: 67 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.3.17] - 2026-05-25
11+
12+
### Fixed
13+
- **Auto-update appeared to succeed but the old exe stayed in place**: the `.bat` did a single `move /y` after a 2 s wait. That wasn't enough — Windows can hold the file lock on the just-exited PyInstaller exe for several seconds (bootloader teardown, AV scanning the new file). The single `move` silently failed and the user was left on the previous version with no error. The relaunch then started the still-old exe.
14+
- The self-replace `.bat` now waits 3 s, then retries `move /y` up to 30 times with 1 s spacing. As soon as the move succeeds it relaunches the new exe; if all 30 retries fail it leaves the new exe in place so the user can swap manually.
15+
- The Python side now calls `os._exit(0)` instead of `sys.exit(0)`, so daemon threads + pystray's Windows message loop don't keep the file lock alive after the "exit".
16+
- The tray icon, bridge HTTP server, and presence loop are stopped cleanly before the hard exit.
17+
1018
## [0.3.16] - 2026-05-25
1119

1220
### Fixed

presence_helper/src/busylight_presence/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
previous manual state when the call ends.
77
"""
88

9-
__version__ = "0.3.16"
9+
__version__ = "0.3.17"

presence_helper/src/busylight_presence/tray.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -441,15 +441,28 @@ def cb(written: int, total: int) -> None:
441441
progress.set_progress(1.0)
442442
progress.set_message(
443443
"Download complete. Restarting BusyLight…\n"
444-
"Approve the Windows UAC prompt when it appears."
444+
"If Windows asks for permission, click Yes."
445445
)
446446
import time
447447
time.sleep(1.5)
448448
progress.close()
449-
self._stop_event.set() # let worker threads wind down
450-
install_and_restart(new_exe)
449+
# Tell every cooperating thread we're going away so they
450+
# release their handles + ports. Then stop the tray icon
451+
# cleanly. The .bat already retries the move for 30 s in
452+
# case Windows holds the exe lock longer than expected.
453+
self._stop_event.set()
454+
try:
455+
self._bridge.stop()
456+
except Exception: # noqa: BLE001
457+
pass
458+
try:
459+
if self._icon is not None:
460+
self._icon.stop()
461+
except Exception: # noqa: BLE001
462+
pass
463+
install_and_restart(new_exe) # never returns
451464
except SystemExit:
452-
raise # install_and_restart exits the process by design
465+
raise
453466
except Exception as e: # noqa: BLE001
454467
log.exception("update install failed: %s", e)
455468
self._notify("Update failed", str(e))

presence_helper/src/busylight_presence/updater.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,11 @@ def install_and_restart(new_exe: Path) -> None:
201201
"""Replace the currently-running exe with `new_exe` and relaunch.
202202
203203
Windows only. Spawns a detached `cmd /c <bat>` and exits the current
204-
process — the .bat then waits, moves the file, restarts, and deletes
205-
itself.
204+
process — the .bat then waits, moves the file (with retries),
205+
restarts, and deletes itself. We use `os._exit` rather than
206+
`sys.exit` because the latter only raises `SystemExit` on the
207+
calling thread; daemon threads + pystray's Windows message loop
208+
would keep the exe alive and the file lock with it.
206209
"""
207210
if sys.platform != "win32":
208211
raise RuntimeError("auto-install is Windows-only")
@@ -214,21 +217,17 @@ def install_and_restart(new_exe: Path) -> None:
214217

215218
bat = _write_self_replace_bat(new_exe=new_exe, target=current)
216219
log.info("spawning installer %s -> %s", new_exe, current)
217-
# DETACHED_PROCESS + CREATE_NEW_PROCESS_GROUP so the .bat survives
218-
# our own exit. /c keeps cmd alive only until the bat finishes,
219-
# which is what we want.
220220
DETACHED_PROCESS = 0x00000008
221221
CREATE_NEW_PROCESS_GROUP = 0x00000200
222222
subprocess.Popen(
223223
["cmd.exe", "/c", str(bat)],
224224
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
225225
close_fds=True,
226226
)
227-
# Give the launcher a beat to start, then bow out so the move can
228-
# actually proceed (Windows holds an exclusive lock on the running
229-
# exe).
230-
log.info("exiting current process so updater can take over")
231-
sys.exit(0)
227+
# Hard exit so all threads die and Windows releases the exe lock.
228+
log.info("hard-exiting current process so updater can take over")
229+
import os
230+
os._exit(0)
232231

233232

234233
# ---------------------------------------------------------------------------
@@ -250,21 +249,44 @@ def _self_version() -> str:
250249

251250

252251
def _write_self_replace_bat(*, new_exe: Path, target: Path) -> Path:
253-
"""Emit a one-shot .bat that does: wait, move, relaunch, self-delete."""
252+
"""Emit a one-shot .bat that does: wait, retry-move, relaunch,
253+
self-delete.
254+
255+
The "retry-move" loop is important: Windows can hold the file
256+
lock on the old exe for several seconds after the process
257+
nominally exits (PyInstaller bootloader, antivirus scanning of
258+
the new file, etc). A single `move` 2 seconds later often used
259+
to fail silently, leaving the user with the old version and no
260+
error message.
261+
"""
254262
bat = Path(tempfile.gettempdir()) / "busylight-update.bat"
255-
# Use 8.3-safe absolute paths in quotes so spaces don't break the
256-
# move. `move /y` overwrites; `start ""` keeps the relaunched exe
257-
# decoupled from the bat's cmd window.
263+
# `setlocal` + numeric tries; on each attempt sleep 1s and try to
264+
# `move /y`. If it succeeds, jump out and relaunch. After 30
265+
# attempts (≈30 s) give up — better than spinning forever.
258266
bat.write_text(
259267
"@echo off\r\n"
260268
"rem BusyLight Presence self-updater (auto-generated)\r\n"
261-
# Wait ~2s for the original process to exit so Windows releases
262-
# the file lock on the .exe.
263-
"timeout /t 2 /nobreak >nul\r\n"
264-
f'move /y "{new_exe}" "{target}" >nul\r\n'
269+
"setlocal enabledelayedexpansion\r\n"
270+
# Initial pause so the original process has a moment to start
271+
# tearing down (icon stop, thread joins, sys.exit).
272+
"timeout /t 3 /nobreak >nul\r\n"
273+
"set /a attempts=0\r\n"
274+
":retry\r\n"
275+
"set /a attempts=!attempts!+1\r\n"
276+
f'move /y "{new_exe}" "{target}" >nul 2>&1\r\n'
277+
"if exist " + f'"{new_exe}"' + " (\r\n"
278+
" if !attempts! geq 30 (\r\n"
279+
" rem Out of retries — the old exe is still locked. Leave\r\n"
280+
" rem the new exe in place so the user can move it manually.\r\n"
281+
" goto :done\r\n"
282+
" )\r\n"
283+
" timeout /t 1 /nobreak >nul\r\n"
284+
" goto :retry\r\n"
285+
")\r\n"
265286
f'start "" "{target}"\r\n'
287+
":done\r\n"
266288
# Delete the bat itself last so its own handle is gone.
267-
f'del "%~f0"\r\n',
289+
f'(goto) 2>nul & del "%~f0"\r\n',
268290
encoding="ascii",
269291
)
270292
return bat

0 commit comments

Comments
 (0)