diff --git a/CHANGELOG.md b/CHANGELOG.md index f06e9ee2..3fb5586d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.570] — 2026-06-22 — Release UC (Windows restart console suppression) + +### Fixed + +- **No more empty console window flashing on Windows when the WebUI restarts.** Both restart paths — the Update-button self-update restart and the bootstrap supervisor's auto-restart — now prefer `pythonw.exe` (windowless) over `python.exe` and pass `CREATE_NO_WINDOW`, so a brief empty terminal window no longer appears on each restart (closing that stray window previously took the WebUI down with it). Windows-only; no change on Linux or macOS. Thanks @perejaslav. (#4626) + ## [v0.51.569] — 2026-06-22 — Release UB (mobile drawer fixes) ### Fixed diff --git a/api/updates.py b/api/updates.py index 110cdc7e..c078eba4 100644 --- a/api/updates.py +++ b/api/updates.py @@ -1174,12 +1174,33 @@ def _schedule_restart(delay: float = 2.0) -> None: 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. + # Prefer pythonw.exe over python.exe so the restarted + # server does not create a visible console window. + # sys.executable may point at python.exe (console + # subsystem); substitute pythonw.exe if it exists + # next to python.exe. + _exe = sys.executable + if _exe.lower().endswith('python.exe'): + _w_exe = _exe[:-4] + 'w.exe' # python.exe -> pythonw.exe + if os.path.isfile(_w_exe): + if getattr(sys, "frozen", False): + args = sys.argv + else: + args = [_w_exe] + sys.argv + # Start new process fully detached with NO console + # window. DETACHED_PROCESS alone is not sufficient + # on modern Windows — without CREATE_NO_WINDOW a + # python.exe (console-subsystem) child still flashes + # an empty terminal window, which the user then + # manually kills (taking the WebUI with it). subprocess.Popen( args, cwd=os.getcwd(), - creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP, + creationflags=( + subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.CREATE_NO_WINDOW + ), close_fds=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, diff --git a/bootstrap.py b/bootstrap.py index 0f11df05..424f2de9 100755 --- a/bootstrap.py +++ b/bootstrap.py @@ -585,16 +585,41 @@ def main() -> int: # spawns a new process instead of replacing (Python calls CreateProcess), # orphaning it from any supervisor. Use Popen + exit there instead. if sys.platform == "win32": - # CREATE_NEW_PROCESS_GROUP only exists in the subprocess module on - # Windows; resolve it defensively (0 = no extra flags) so this line - # can't AttributeError if reached on a non-Windows interpreter - # (e.g. a win32-simulating test) — mirrors the getattr() guard used - # for SO_EXCLUSIVEADDRUSE. - _CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) - subprocess.Popen([python_exe, server_path], - cwd=server_cwd, - env=os.environ.copy(), - creationflags=_CREATE_NEW_PROCESS_GROUP) + # Mirror the robust pattern from api/updates._schedule_restart: + # 1. Prefer pythonw.exe (windowless subsystem) over python.exe + # so the restarted server never creates a visible console window. + # 2. DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW + # suppresses the brief console flash even when python.exe is used. + _exe = str(python_exe) + if _exe.lower().endswith("python.exe"): + _w = _exe[:-4] + "w.exe" # python.exe -> pythonw.exe + if os.path.isfile(_w): + _exe = _w + _flags = 0 + for _attr in ("DETACHED_PROCESS", "CREATE_NEW_PROCESS_GROUP", + "CREATE_NO_WINDOW"): + _flags |= getattr(subprocess, _attr, 0) + # Redirect the windowless child's stdout/stderr to a real log file + # (not DEVNULL): server.py writes startup/request/error diagnostics + # to stdout/stderr, and with no console (pythonw + CREATE_NO_WINDOW) + # there is nowhere else for them to go — DEVNULL would silently drop + # all Windows server logs after a supervisor restart. Mirror the + # default-path log sink (state_dir/bootstrap-.log). + _win_log_path = state_dir / f"bootstrap-{args.port}.log" + _win_log = _win_log_path.open("ab") + try: + subprocess.Popen( + [_exe, str(server_path)], + cwd=str(server_cwd), + env=os.environ.copy(), + creationflags=_flags, + close_fds=True, + stdin=subprocess.DEVNULL, + stdout=_win_log, + stderr=subprocess.STDOUT, + ) + finally: + _win_log.close() sys.exit(0) os.execv(python_exe, [python_exe, server_path]) # Unreachable — execv either replaces the process or raises. diff --git a/tests/test_issue4626_windows_restart_no_console.py b/tests/test_issue4626_windows_restart_no_console.py new file mode 100644 index 00000000..128f8f3f --- /dev/null +++ b/tests/test_issue4626_windows_restart_no_console.py @@ -0,0 +1,86 @@ +"""Regression tests for #4626 — suppress the Windows console window on WebUI restart. + +Two Windows restart paths spawn server.py: + 1. bootstrap.py (foreground supervisor auto-restart) + 2. api/updates._schedule_restart (Update-button self-update restart) + +Before #4626, both used python.exe (console subsystem) without CREATE_NO_WINDOW, so +every restart flashed an empty terminal window on Windows. If the user closed that +window it took the WebUI down with it. + +These are source-level assertions because the behavior is Windows-only (the +DETACHED_PROCESS / CREATE_NO_WINDOW subprocess constants and pythonw.exe only exist +on win32), so the spawn path can't be exercised on the Linux CI box. We pin: + - both restart paths add CREATE_NO_WINDOW to the Popen creationflags + - both prefer pythonw.exe over python.exe when it exists next to the interpreter + - the non-Windows paths are untouched (defensive getattr(..., 0) guards, win32 branch) +""" +from __future__ import annotations + +import pathlib + +REPO = pathlib.Path(__file__).resolve().parent.parent +UPDATES_PY = (REPO / "api" / "updates.py").read_text(encoding="utf-8") +BOOTSTRAP_PY = (REPO / "bootstrap.py").read_text(encoding="utf-8") + + +class TestWindowsRestartConsoleSuppression: + def test_updates_restart_adds_create_no_window(self): + assert "CREATE_NO_WINDOW" in UPDATES_PY, ( + "_schedule_restart must add CREATE_NO_WINDOW to the Windows restart " + "Popen creationflags so python.exe does not flash an empty console (#4626)" + ) + + def test_updates_restart_prefers_pythonw(self): + # python.exe -> pythonw.exe substitution (windowless subsystem). + assert "w.exe" in UPDATES_PY and "python.exe" in UPDATES_PY, ( + "_schedule_restart should prefer pythonw.exe over python.exe on Windows (#4626)" + ) + + def test_bootstrap_restart_adds_create_no_window(self): + assert "CREATE_NO_WINDOW" in BOOTSTRAP_PY, ( + "bootstrap.py Windows restart must add CREATE_NO_WINDOW to the Popen " + "creationflags so the supervisor auto-restart does not flash a console (#4626)" + ) + + def test_bootstrap_restart_prefers_pythonw(self): + assert "w.exe" in BOOTSTRAP_PY, ( + "bootstrap.py should prefer pythonw.exe over python.exe on Windows (#4626)" + ) + + def test_bootstrap_uses_defensive_getattr_for_flags(self): + # The flags must be resolved with getattr(subprocess, , 0) so the + # win32-only constants can't AttributeError if the branch is reached under + # a non-Windows interpreter (e.g. a win32-simulating test). + assert 'getattr(subprocess, _attr, 0)' in BOOTSTRAP_PY, ( + "bootstrap.py must resolve win32-only creationflags defensively via " + "getattr(subprocess, attr, 0) (#4626)" + ) + + def test_bootstrap_windows_restart_preserves_server_logs(self): + # The windowless Windows child (pythonw + CREATE_NO_WINDOW) has no console, + # so its stdout/stderr must go to a real log file — NOT DEVNULL, which would + # silently drop all server diagnostics after a supervisor restart. Pin that + # the win32 foreground branch redirects to the bootstrap log sink. + win_branch = BOOTSTRAP_PY.split('if sys.platform == "win32":', 1)[-1].split("os.execv", 1)[0] + assert "bootstrap-" in win_branch and ".log" in win_branch, ( + "bootstrap.py win32 restart must redirect the windowless child's stdout " + "to a real log file (state_dir/bootstrap-.log), not DEVNULL (#4626)" + ) + assert "stdout=subprocess.DEVNULL" not in win_branch, ( + "bootstrap.py win32 restart must NOT send the windowless child's stdout to " + "DEVNULL — server diagnostics would be lost with no console (#4626)" + ) + assert "stderr=subprocess.STDOUT" in win_branch, ( + "bootstrap.py win32 restart should fold stderr into the stdout log sink (#4626)" + ) + + def test_windows_restart_changes_are_win32_scoped(self): + # Both edits live under a sys.platform == 'win32' guard so there is no + # Linux/macOS behavior change. + assert 'sys.platform == "win32"' in BOOTSTRAP_PY or "sys.platform == 'win32'" in BOOTSTRAP_PY, ( + "bootstrap.py restart change must stay inside the win32 branch" + ) + assert "sys.platform == 'win32'" in UPDATES_PY or 'sys.platform == "win32"' in UPDATES_PY, ( + "api/updates.py restart change must stay inside the win32 branch" + )