From dd19be8bbf37a94239bb6c9fcbdd32f89079bd6b Mon Sep 17 00:00:00 2001 From: StyxX65 <150797939+StyxX65@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:01:17 +0200 Subject: [PATCH] Close leaked listening socket on update restart Werkzeug sets its server socket inheritable unconditionally, so the os.execv restart carried it into the new process as a zombie listener: one PID listening on both 5100 (never accepted) and 5101 (the real server). Mark all fds above stderr close-on-exec before exec'ing so the old socket dies and the new server rebinds the original port. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++++ routes/updates.py | 25 +++++++++++++++++++++++-- tests/test_updates.py | 17 +++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 367c26d..cd5d9e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ## [Unreleased] +### Fixed + +- **Update restart leaked the listening socket and hopped to port 5101** — Werkzeug marks its server socket inheritable (`srv.socket.set_inheritable(True)`, unconditionally, for its debug reloader), so the in-app update's `os.execv` restart carried the old listening socket into the new process as a zombie listener: same PID listening on both 5100 (never accepted — clients hang) and 5101 (the actual server). The 1.7.3 `SO_REUSEADDR`/grace-period fix couldn't help because the port genuinely was occupied — by the restarting process itself. `_restart_self()` now marks every fd above stderr close-on-exec before the exec (`_mark_fds_cloexec()`, enumerating `/proc/self/fd` on Linux), so the old socket dies with the exec and the new server rebinds 5100 immediately. + --- ## [1.7.5] — 2026-06-11 diff --git a/routes/updates.py b/routes/updates.py index f02ec59..f5acf3b 100644 --- a/routes/updates.py +++ b/routes/updates.py @@ -118,13 +118,34 @@ def apply_update() -> dict: "from": chk["current"], "to": chk["latest"]} +def _mark_fds_cloexec() -> None: + """Mark every fd above stderr close-on-exec. + + Werkzeug calls ``srv.socket.set_inheritable(True)`` unconditionally + (for its debug reloader), so without this the listening socket leaks + into the exec'd process: it sits on the port as a zombie listener no + one accepts from, the port probe sees the port as busy, and the new + server hops to port+1 while clients hang against the dead socket. + """ + try: + fds = [int(f) for f in os.listdir("/proc/self/fd")] # Linux + except (OSError, ValueError): + fds = list(range(3, 4096)) + for fd in fds: + if fd > 2: + try: + os.set_inheritable(fd, False) + except OSError: + pass + + def _restart_self() -> None: """Re-exec the current process so the updated code is loaded. Keeps the same PID, so it works both under systemd and when launched - manually via start_gdpr.sh. Listening sockets are close-on-exec, so - the new process can rebind the port. + manually via start_gdpr.sh. """ + _mark_fds_cloexec() try: os.execv(sys.executable, [sys.executable] + sys.argv) except OSError: diff --git a/tests/test_updates.py b/tests/test_updates.py index 71e8d5c..d8c6733 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -188,6 +188,23 @@ def test_apply_installs_requirements_when_changed(client, monkeypatch): assert "pip" in pip_calls[0] and "-r" in pip_calls[0] +# ── Restart fd hygiene ──────────────────────────────────────────────────────── + +def test_mark_fds_cloexec_unmarks_inheritable_socket(): + """Werkzeug sets the listening socket inheritable; the restart must undo + that or the socket leaks through execv and squats on the port.""" + import socket + import routes.updates as upd + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.set_inheritable(True) + assert s.get_inheritable() is True + upd._mark_fds_cloexec() + assert s.get_inheritable() is False + finally: + s.close() + + # ── /api/update/settings ────────────────────────────────────────────────────── def test_settings_roundtrip(client, monkeypatch):