Both Windows restart paths (api/updates._schedule_restart Update-button + bootstrap.py supervisor auto-restart) now prefer pythonw.exe over python.exe and add CREATE_NO_WINDOW to the Popen creationflags, so a restart no longer flashes an empty console window. Both edits are inside sys.platform=='win32' guards (zero Linux/macOS change); bootstrap resolves the win32-only flags via getattr(subprocess, attr, 0). Added regression test (RED on master, GREEN here). Code byte-faithful from PR head 6d46e630e377. Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: perejaslav <perejaslav@users.noreply.github.com>
This commit is contained in:
4 files changed
+150
-12
No files matched your search
@@ -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
|
||||
|
||||
+24
-3
@@ -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,
|
||||
|
||||
+34
-9
@@ -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,
|
||||
# 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-<port>.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=_CREATE_NEW_PROCESS_GROUP)
|
||||
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.
|
||||
|
||||
@@ -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, <attr>, 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-<port>.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"
|
||||
)
|
||||
Reference in new issue
Block a user