From ffc1ab6fd6f6da0ccdca0ee42b0f5798b46994da Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 5 Jun 2026 14:54:59 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.280=20=E2=80=94=20Release=20IV?= =?UTF-8?q?=20(stage-p3i=20=E2=80=94=20Windows=20self-update=20restart=20f?= =?UTF-8?q?ix=20#3647)=20(#3687)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(updates): Windows self-update restart via detached Popen + bind-retry (os.execv doesn't replace proc on Windows) (#3647) Co-authored-by: jja881 * docs(changelog): v0.51.280 — Release IV (stage-p3i) --------- Co-authored-by: nesquena-hermes <[email protected]> Co-authored-by: jja881 --- CHANGELOG.md | 5 +++++ api/updates.py | 33 ++++++++++++++++++++++++++++++--- server.py | 19 ++++++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af863368e..bc5214809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/updates.py b/api/updates.py index 0d433a290..3547ae0c3 100644 --- a/api/updates.py +++ b/api/updates.py @@ -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. diff --git a/server.py b/server.py index 28b6530c7..cadd3ae66 100644 --- a/server.py +++ b/server.py @@ -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.