Aller au contenu
login
arrow_backRetour aux issues
nesquena/hermes-webui #7195

Windows self-restart re-executes sys.argv — infinite recursive pytest spawn + process leak

ecoDébutant bug help wanted investigation priority performance

descriptionDescription

# Bug Report: Windows self-restart re-executes `sys.argv` — infinite recursive pytest spawn + process leak **Repo:** nesquena/hermes-webui **Component:** `api/updates.py` — `_schedule_restart()` Windows branch **Severity:** High (process leak / fork-bomb under test; resource exhaustion) **Environment:** Windows 11, Python 3.11/3.12, WebUI v0.52.113 (HEAD c67fd2dd) ## Summary On Windows, the WebUI self-restart path (`_schedule_restart()` in `api/updates.py`) re-executes the **current process's `sys.argv`** via `subprocess.Popen([sys.executable] + sys.argv) + os._exit(0)`. When the WebUI process was launched **under pytest** (the `tests/conftest.py` `test_server` fixture Popen's `server.py`), `sys.argv` is the **pytest command line** — so the "restart" re-runs pytest, which runs the same update tests, which hit the restart path again → **infinite recursive pytest spawn**. Each pytest generation also spawns real `server.py` subprocesses via the conftest `test_server` fixture, which leak because the parent pytest never completes teardown. Observed result: 100+ orphaned Python processes, ~4.4 GB RAM, sustained CPU burn. ## Reproduction 1. Run the WebUI update test suite on Windows: ``` python -m pytest tests/test_updates.py tests/test_update_checker.py tests/test_update_channels.py ``` 2. Any test that reaches `apply_update()` → `_schedule_restart()` (e.g. `test_apply_update_*` with `_restart_blocker_snapshot` patched to `restart_blocked: False`) triggers the Windows restart branch. 3. The restart Popen's `[sys.executable] + sys.argv` — which under pytest is the pytest command — and `os._exit(0)` kills the current process. 4. The new pytest process runs the same tests → recursion. Each generation spawns a fresh `server.py` via the conftest fixture. Processes accumulate until manually killed. ## Root cause `api/updates.py` lines ~1757–1803: ```python if sys.platform == 'win32': import subprocess if getattr(sys, "frozen", False): args = sys.argv else: args = [sys.executable] + sys.argv ... subprocess.Popen( args, ... creationflags=(subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW), ... ) os._exit(0) ``` The comment documents that `os.execv()` does NOT replace the process on Windows, so the code uses `Popen + os._exit`. But `sys.argv` is only correct when the process was launched as `server.py`. Under pytest (or any wrapper), `sys.argv` is the wrapper's command line — the restart re-runs the wrapper, not the server. Additionally, the Popen'd child **survives the parent's exit** (documented in the code as intended for the server case), so every restart attempt leaves an orphaned process pair (venv launcher + runtime child) even in the non-pytest case. Each orphan binds its own port and runs a full WebUI loop (gateway watcher polling state.db every 5s, SSE, update checker) — ~12% CPU each. ## Impact - **Under pytest:** infinite recursive spawn (fork-bomb). Observed: 14+ pytest generations, each spawning server.py pairs every ~2.5s. 105 processes, ~4.4 GB RAM, ~22,000 CPU-seconds before manual cleanup. - **In production (non-pytest):** every WebUI self-update leaks one orphaned server pair. Over repeated updates (e.g. nightly auto-update), orphans accumulate indefinitely. ## Suggested fix 1. **Restart must re-exec the server, not `sys.argv`.** Resolve the server entry point explicitly (e.g. `[sys.executable, str(REPO_ROOT / 'server.py')]` or the frozen-app argv) instead of `sys.argv`. This makes the restart correct under any launcher (pytest, `python -m`, supervisor). 2. **Kill the old process tree before/after Popen on Windows.** The current code relies on `os._exit(0)` releasing the port, but the Popen'd child is a *new* process that doesn't inherit the port — the old process's port is only released when the old process dies. If `os._exit(0)` is delayed or the child fails to bind, the old process lingers. Use a proper pr
codeOuvre sur GitHub