Release v0.51.280 — Release IV (stage-p3i — Windows self-update restart fix #3647) (#3687)

* fix(updates): Windows self-update restart via detached Popen + bind-retry (os.execv doesn't replace proc on Windows) (#3647)

Co-authored-by: jja881 <jja881@users.noreply.github.com>

* docs(changelog): v0.51.280 — Release IV (stage-p3i)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: jja881 <jja881@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-05 14:54:59 -07:00
committed by GitHub
parent b5caf83ff9
commit ffc1ab6fd6
3 changed files with 53 additions and 4 deletions
+5
View File
@@ -3,6 +3,11 @@
## [Unreleased]
## [v0.51.280] — 2026-06-05 — Release IV (stage-p3i — Windows self-update restart fix)
### Fixed
- **Self-update now restarts correctly on Windows.** `os.execv` does not replace the current process on Windows (it spawns a new one while the old keeps running), so the old process held port 8787 and the new process failed to bind ("address already in use"), surfacing as "Update failed" after the timeout. On Windows the restart now launches a detached new process (`subprocess.Popen` with `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`) and exits the old one immediately to release the port, plus a bounded bind-retry loop in `server_bind()` (up to 10s) to ride out the `SO_EXCLUSIVEADDRUSE` teardown window. POSIX behavior is unchanged (still `os.execv`). (#3647, @jja881)
## [v0.51.279] — 2026-06-05 — Release IU (stage-p3h — preserve Activity/streaming turn on mid-stream scroll)
### Fixed
+30 -3
View File
@@ -1073,10 +1073,37 @@ def _schedule_restart(delay: float = 2.0) -> None:
# `[sys.executable] + sys.argv` form is the canonical CPython
# re-exec idiom (same shape Flask/Django reloaders use) and
# is the correct path.
if getattr(sys, "frozen", False):
os.execv(sys.executable, sys.argv)
#
# IMPORTANT: On Windows, os.execv() does NOT replace the
# current process — it spawns a new process while the old
# one keeps running. This causes "address already in use"
# because the old process still holds the port. On Windows
# we use subprocess.Popen() + os._exit() instead.
if sys.platform == 'win32':
import subprocess
if getattr(sys, "frozen", False):
args = sys.argv
else:
args = [sys.executable] + sys.argv
# Start new process detached, redirect all stdio to
# avoid broken-pipe errors when the parent exits.
subprocess.Popen(
args,
cwd=os.getcwd(),
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
close_fds=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Exit immediately — the port is released as soon as
# this process dies, allowing the new process to bind.
os._exit(0)
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
if getattr(sys, "frozen", False):
os.execv(sys.executable, sys.argv)
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
except Exception:
# Last-resort: if execv fails for any reason, just exit so the
# process supervisor (start.sh / Docker) restarts us.
+18 -1
View File
@@ -210,7 +210,24 @@ class QuietHTTPServer(ThreadingHTTPServer):
self.allow_reuse_address = False
SO_EXCLUSIVEADDRUSE = getattr(socket, 'SO_EXCLUSIVEADDRUSE', -5)
self.socket.setsockopt(socket.SOL_SOCKET, SO_EXCLUSIVEADDRUSE, 1)
super().server_bind()
# Retry bind on Windows to handle the case where a previous
# process (e.g. during self-update) is still releasing the port.
# The old process calls os._exit(0) which starts tearing down
# its socket, but with SO_EXCLUSIVEADDRUSE the OS blocks new
# binds until the teardown completes. Retry for up to 10 s.
max_retries = 20
retry_delay = 0.5
for attempt in range(max_retries):
try:
super().server_bind()
return
except OSError as e:
if e.winerror == 10048 and attempt < max_retries - 1: # WSAEADDRINUSE
time.sleep(retry_delay)
else:
raise
else:
super().server_bind()
def _handle_request_noblock(self):
"""Record accept-loop progress before dispatching a request handler.