fix(windows): stop subprocess console-window popups + add CI guard (#53791)

* fix(windows): stop subprocess console-window popups + add CI guard

The single biggest source of Windows 'terminal popup' bug reports was bare
subprocess.run/Popen calls spawning a console window. The compat helpers
(windows_hide_flags / windows_detach_popen_kwargs) already existed but the
footgun checker had no rule to stop new bare calls from reintroducing the flash.

- scripts/check-windows-footguns.py: new AST-based rule flagging subprocess
  calls that can create a new console — output-redirection-aware (capture/
  redirect/check_output exempt) and POSIX-only-program-aware (launchctl/
  systemctl/brew/etc. exempt). Comprehensive on real popups, no annotation
  burden on calls that can't flash.
- Swept all genuine window-spawning sites through windows_hide_flags()/
  windows_detach_popen_kwargs(); marked intentionally-visible launches
  (editor/terminal/foreground re-exec) with '# windows-footgun: ok'.
- tests/scripts/test_windows_footgun_subprocess_rule.py: behavior-contract
  tests + full-repo cleanliness invariant.
- CONTRIBUTING.md: documents the rule + the helper pattern.

* test: accept creationflags kwarg in psutil_android fake_subprocess_run

The Windows no-window sweep added creationflags=windows_hide_flags() to
install_psutil_android.py's subprocess.run call; the test's fake stub had a
fixed (cmd) signature and raised TypeError on the new kwarg.
This commit is contained in:
Teknium 2026-06-27 13:03:51 -07:00 committed by GitHub
parent 3b44a3c8bb
commit ef17cd204d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 445 additions and 34 deletions

View file

@ -29,6 +29,7 @@ Suppress an intentional use (e.g. tests or platform-gated code) with:
from __future__ import annotations
import argparse
import ast
import os
import re
import subprocess
@ -327,6 +328,187 @@ FOOTGUNS: list[Footgun] = [
]
# -----------------------------------------------------------------------------
# AST-based rule: subprocess calls that flash a console window on Windows
# -----------------------------------------------------------------------------
#
# This is the high-volume Windows complaint: every `subprocess.run(...)` /
# `subprocess.Popen(...)` of a console program on Windows briefly flashes a
# cmd window unless the child either (a) inherits the parent's stdio handles
# via output redirection, or (b) is spawned with a no-window creationflag
# (CREATE_NO_WINDOW / DETACHED_PROCESS). The fix landscape already exists in
# `hermes_cli/_subprocess_compat.py` (windows_hide_flags / windows_detach_*),
# but nothing stopped new bare calls from re-introducing the popup — so the
# bug kept coming back PR after PR. This rule is the chokepoint.
#
# It is AST-based (not regex) because the deciding factor — whether the call
# redirects stdout/stderr — frequently lives several lines below the
# `subprocess.run(` opener, which a line-oriented regex cannot see.
#
# Comprehensive, not restrictive: a call is only flagged when it can ACTUALLY
# create a new console. Calls that capture or redirect output (capture_output=,
# stdout=, stderr=), or use check_output (which always captures), cannot pop a
# window and are silently ignored — no suppression comment needed. The intent
# is that the overwhelming majority of subprocess calls require no change at
# all; only the genuine window-spawners do.
# The subprocess functions that can spawn a child process.
_SUBPROCESS_FUNCS = frozenset({"run", "Popen", "call", "check_call", "check_output"})
# Module aliases we recognise as the stdlib subprocess module.
_SUBPROCESS_ALIASES = frozenset({"subprocess", "sp"})
# Executables that simply do not exist on Windows. A subprocess call whose
# program is one of these can never create a Windows console window, so the
# no-window flag is irrelevant — flagging them would force pointless
# suppression comments on macOS/Linux-only service-management and packaging
# code (launchctl, systemctl, brew, codesign …). Matched against the FIRST
# element of a list/tuple argv literal only; anything dynamic still gets
# flagged (we can't prove it's POSIX-only).
_POSIX_ONLY_PROGRAMS = frozenset(
{
"launchctl",
"systemctl",
"journalctl",
"loginctl",
"osascript",
"codesign",
"xattr",
"defaults",
"brew",
"apt",
"apt-get",
"dpkg",
"pacman",
"dnf",
"yum",
"sudo",
"open", # macOS `open`
"tail",
"sw_vers",
"scutil",
"diskutil",
"hdiutil",
"dscl",
}
)
SUBPROCESS_FOOTGUN_NAME = "subprocess without Windows no-window flag"
SUBPROCESS_FOOTGUN_MESSAGE = (
"subprocess.run/Popen/call on Windows flashes a console (cmd) window "
"unless the child inherits stdio (output is captured/redirected) or is "
"spawned with a no-window creationflag. This is the #1 source of Windows "
"'terminal popup' bug reports."
)
SUBPROCESS_FOOTGUN_FIX = (
"Pass creationflags=windows_hide_flags() (for short-lived/captured spawns) "
"or **windows_detach_popen_kwargs() (for detached daemons) from "
"hermes_cli._subprocess_compat (both no-op on POSIX). If a visible window "
"is intended (interactive launch, shell hand-off), add "
"'# windows-footgun: ok' on the call line."
)
def _call_attr_name(node: ast.Call) -> str | None:
"""Return 'run'/'Popen'/... when node is subprocess.<func>(...), else None."""
f = node.func
if not isinstance(f, ast.Attribute):
return None
if f.attr not in _SUBPROCESS_FUNCS:
return None
mod = getattr(f.value, "id", None)
if mod not in _SUBPROCESS_ALIASES:
return None
return f.attr
def _suppresses_window(node: ast.Call, func_name: str) -> bool:
"""True if this subprocess call cannot create a new console window.
A child that inherits or redirects the parent's stdio reuses the parent
console no new window is created, so CREATE_NO_WINDOW is moot. We treat
the following as window-safe:
* check_output always captures stdout
* capture_output=... captures both streams
* stdout= or stderr= at least one stream redirected/inherited
* creationflags=... author is already managing the console
* **<spread> kwargs may carry a _subprocess_compat helper;
flag-via-spread is the recommended fix, so we
must not penalise it. (We accept the small
false-negative: a spread that happens to omit
creationflags. The alternative flagging every
**kwargs call would punish the correct fix.)
"""
if func_name == "check_output":
return True
explicit = {kw.arg for kw in node.keywords if kw.arg}
if explicit & {"stdout", "stderr", "capture_output", "creationflags"}:
return True
if any(kw.arg is None for kw in node.keywords): # **kwargs spread
return True
if _is_posix_only_program(node):
return True
return False
def _is_posix_only_program(node: ast.Call) -> bool:
"""True if the call's program is a statically-known POSIX-only executable.
Only inspects a literal list/tuple first arg whose first element is a
string constant (e.g. ``["launchctl", "bootout", target]``). Dynamic
argv (variables, f-strings) is treated as unknown and still flagged.
"""
if not node.args:
return False
first = node.args[0]
if isinstance(first, (ast.List, ast.Tuple)) and first.elts:
head = first.elts[0]
if isinstance(head, ast.Constant) and isinstance(head.value, str):
prog = head.value.rsplit("/", 1)[-1]
return prog in _POSIX_ONLY_PROGRAMS
return False
def scan_subprocess_window_footguns(
path: Path, text: str
) -> list[tuple[int, str, Footgun]]:
"""AST pass: flag subprocess calls that can flash a Windows console.
Honours the same `# windows-footgun: ok` line suppression as the regex
rules. Returns the same (lineno, line, Footgun) shape so results merge
cleanly into scan_file's output.
"""
try:
tree = ast.parse(text)
except SyntaxError:
return []
lines = text.splitlines()
rule = Footgun(
name=SUBPROCESS_FOOTGUN_NAME,
pattern=re.compile(r"^$"), # unused; AST-driven
message=SUBPROCESS_FOOTGUN_MESSAGE,
fix=SUBPROCESS_FOOTGUN_FIX,
)
out: list[tuple[int, str, Footgun]] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func_name = _call_attr_name(node)
if func_name is None:
continue
if _suppresses_window(node, func_name):
continue
lineno = node.lineno
line = lines[lineno - 1] if 0 <= lineno - 1 < len(lines) else ""
# Inline suppression — check the opener line AND, for multi-line calls,
# any line in the call's span (a developer may mark the closing paren).
end = getattr(node, "end_lineno", lineno) or lineno
span = lines[lineno - 1 : end]
if any(SUPPRESS_MARKER.search(l) for l in span):
continue
out.append((lineno, line.rstrip(), rule))
return out
def should_scan_file(path: Path) -> bool:
"""Return True if this file is in scope for the checker."""
# Skip the excluded dirs
@ -416,6 +598,11 @@ def scan_file(path: Path, footguns: list[Footgun]) -> list[tuple[int, str, Footg
return []
matches: list[tuple[int, str, Footgun]] = []
# AST-based rule (subprocess console-window footgun). Runs only on Python
# source; merges into the same result list as the regex rules below.
if path.suffix in {".py", ".pyw", ".pyi"}:
matches.extend(scan_subprocess_window_footguns(path, text))
# Track whether we're inside a triple-quoted string (docstring/raw block).
# Simple state machine — handles both ''' and """, toggled by the FIRST
# triple-quote we see; we don't try to handle nested or f-string cases.
@ -548,6 +735,12 @@ def print_rules() -> None:
print(f" {fg.message}")
print(f" Fix: {fg.fix}")
print()
# AST-based rule (not in the regex FOOTGUNS list).
n = len(FOOTGUNS) + 1
print(f"{n:2}. {SUBPROCESS_FOOTGUN_NAME} (AST-based)")
print(f" {SUBPROCESS_FOOTGUN_MESSAGE}")
print(f" Fix: {SUBPROCESS_FOOTGUN_FIX}")
print()
def main(argv: list[str]) -> int:

View file

@ -42,6 +42,7 @@ from hermes_cli.psutil_android import (
PsutilAndroidInstallError,
prepare_patched_psutil_sdist,
)
from hermes_cli._subprocess_compat import windows_hide_flags
@ -90,7 +91,7 @@ def main() -> int:
cmd = install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)]
print(f" $ {' '.join(cmd)}")
result = subprocess.run(cmd)
result = subprocess.run(cmd, creationflags=windows_hide_flags())
if result.returncode != 0:
return result.returncode