fix(bootstrap): suppress console window on Windows foreground restart

The foreground restart path in bootstrap.py (used by supervisor auto-restart
and Task Scheduler) spawned server.py with only CREATE_NEW_PROCESS_GROUP,
which still creates a visible console window on Windows.

Apply the same robust pattern already used in api/updates._schedule_restart:
- Prefer pythonw.exe (windowless subsystem) over python.exe
- Use DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW flags
- Redirect stdin/stdout/stderr to DEVNULL
- Set close_fds=True

Without this fix, every supervisor-triggered restart (crash recovery,
Task Scheduler autostart) flashes an empty terminal window that users
may mistakenly kill, taking down the WebUI process.
This commit is contained in:
perejaslav
2026-06-21 21:11:33 +03:00
parent f5a0d047ed
commit 6d46e630e3
2 changed files with 48 additions and 13 deletions
+24 -3
View File
@@ -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,
+24 -10
View File
@@ -542,16 +542,30 @@ 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)
subprocess.Popen(
[_exe, str(server_path)],
cwd=str(server_cwd),
env=os.environ.copy(),
creationflags=_flags,
close_fds=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
sys.exit(0)
os.execv(python_exe, [python_exe, server_path])
# Unreachable — execv either replaces the process or raises.