Findings & Anomalies¶
This document records the key findings from running the py-launch-lab scenario matrix on Windows, including anomalies discovered, root causes identified, and upstream issues referenced.
Summary¶
Out of 20 scenarios tested, 3 consistently produce anomalies — all
related to the same upstream bug in uv where venv pythonw.exe is
created as a CUI (console) binary instead of a GUI binary.
| Scenario | Anomaly | Root Cause |
|---|---|---|
venv-gui-entrypoint |
Console Window: expected No, got Yes | uv venv pythonw.exe is CUI |
venv-dual-gui-entrypoint |
Console Window: expected No, got Yes | uv venv pythonw.exe is CUI |
venv-pythonw-script-py |
PE Subsystem: expected GUI, got CUI | uv venv pythonw.exe is CUI |
The uv pythonw.exe Problem (uv#9781)¶
uv venv creates CUI pythonw.exe
Upstream Issue: astral-sh/uv#9781 Investigation: joelvaneenwyk/uv#1 Fix PR (in progress): joelvaneenwyk/uv#2
When uv venv creates a virtual environment, it does not copy the
real GUI-subsystem pythonw.exe. Instead, it generates a CUI trampoline —
a small console-subsystem executable that internally launches the base
interpreter.
Expected vs Actual¶
| venv Tool | pythonw.exe PE Subsystem | Console Window? |
|---|---|---|
python -m venv |
GUI | No |
uv venv |
CUI (trampoline) | Yes |
Downstream Effects¶
This single bug causes cascading issues:
-
pythonw.exescripts flash a terminal. Runningvenv/Scripts/pythonw.exe hello.pyopens a console window that immediately closes — visible as a "terminal flash" on the desktop. -
GUI entry-point wrappers also flash a terminal. pip/uv-generated GUI wrappers (e.g.
lab-window-gui.exe) invokepythonw.exeinternally. Because the childpythonw.exeis CUI, Windows allocates a console for the child process even though the wrapper itself is GUI. -
The "no console" promise of
[project.gui-scripts]is broken. Packages that define[project.gui-scripts]inpyproject.tomlexpect their entry-points to launch silently. In auv venv, they don't.
How py-launch-lab Detects This¶
The runner uses _detect_child_python_subsystem() to inspect the PE
subsystem of the interpreter that a venv wrapper will invoke:
def _detect_child_python_subsystem(exe: str) -> str | None:
# Check if the exe is a venv wrapper (has sibling python.exe)
# GUI wrappers → check pythonw.exe PE subsystem
# CUI wrappers → check python.exe PE subsystem
...
When a GUI wrapper's child interpreter is CUI, the runner overrides
console_window = True regardless of what direct detection reported,
because the console allocation is deterministic (it always happens).
Findings by Launcher Category¶
python / pythonw (Direct)¶
All 4 scenarios behave as expected:
| Scenario | PE | Console | stdout | Status |
|---|---|---|---|---|
python-script-py |
CUI | Yes | Yes | OK |
python-script-pyw |
CUI | Yes | No | OK |
pythonw-script-py |
GUI | No | Yes | OK |
pythonw-script-pyw |
GUI | No | No | OK |
python.exe is CUI: always allocates a console.
pythonw.exe is GUI: never allocates a console.
The .py vs .pyw extension affects stdout availability but not console
creation — that's determined solely by the launcher's PE subsystem.
uv run¶
| Scenario | PE | Console | stdout | Status |
|---|---|---|---|---|
uv-run-script-py |
CUI | Yes | Yes | OK |
uv-run-script-pyw |
CUI | Yes | No | OK |
uv-run-gui-script |
CUI | Yes | Yes | OK |
All three work as expected. Note that uv run --gui-script is intended
to suppress the console, but because uv.exe itself is CUI, Windows
still allocates a console. This is a known uv limitation — the flag
only affects the child process, not the parent launcher.
uvw¶
| Scenario | PE | Console | stdout | Status |
|---|---|---|---|---|
uvw-run-script-py |
GUI | No | Yes | OK |
uvw.exe is the GUI counterpart to uv.exe, mirroring the python/pythonw
split. No console is allocated.
uvx / uv tool¶
| Scenario | PE | Console | stdout | Status |
|---|---|---|---|---|
uvx-pkg-console |
CUI | Yes | Yes | OK |
uv-tool-run-pkg-console |
CUI | Yes | Yes | OK |
uv-tool-install-console |
CUI | Yes | No | OK |
uv-tool-install-gui |
CUI | Yes | No | OK |
Tool install commands produce no stdout (progress goes to stderr).
venv-direct¶
| Scenario | PE | Console | stdout | Status |
|---|---|---|---|---|
venv-python-script-py |
CUI | Yes | Yes | OK |
venv-pythonw-script-py |
CUI | Yes | Yes | ANOMALY |
venv-console-entrypoint |
CUI | Yes | Yes | OK |
venv-gui-entrypoint |
GUI | Yes | No | ANOMALY |
venv-dual-console-entrypoint |
CUI | Yes | Yes | OK |
venv-dual-gui-entrypoint |
GUI | Yes | No | ANOMALY |
The three anomalies are all caused by the uv pythonw.exe CUI trampoline problem described above.
pyshim-win (Rust shim)¶
| Scenario | PE | Console | stdout | Status |
|---|---|---|---|---|
shim-python-script-py |
GUI | No | Yes | OK |
shim-uv-run-script-py |
GUI | No | Yes | OK |
The Rust shim successfully suppresses console windows by using
CREATE_NO_WINDOW when spawning child processes. Despite python.exe
and uv.exe being CUI binaries, the shim prevents console allocation.
What Worked Well¶
-
Two-phase detection is reliable. Separating window/console detection (Phase 1, no pipes) from output capture (Phase 2, with pipes) was essential. Pipes suppress console allocation, so a single-phase approach would never detect consoles.
-
PE inspection is deterministic. Reading the PE header directly is much more reliable than heuristics like "does the name contain 'w'?". It correctly handles edge cases like uv's CUI pythonw trampoline.
-
Keepalive strategy covers fast-exiting processes.
uv tool install,uvx, and venv wrapper tests all exit in <100ms. Re-launching with a sleep command gives detection enough time to snapshot the process tree. -
Child PE inspection catches the uv bug. Without
_detect_child_python_subsystem(), GUI entry-point wrappers would reportconsole_window = False(because direct detection misses the briefly-appearing console). The child PE override catches this deterministically.
What Didn't Work¶
-
Direct detection alone is unreliable for fast processes. Even with aggressive polling (10 × 50 ms), many processes exit before
CreateToolhelp32Snapshotcan capture them. The keepalive fallback was necessary. -
conhost.exedetection misses GUI wrapper children. Whenlab-window-gui.exe(GUI wrapper) launchespythonw.exe(CUI in uv venvs),conhost.exeappears as a child ofpythonw.exe, notlab-window-gui.exe. Sinceget_process_tree()only captures direct children,detect_console_host()returns False. The child PE override was needed to compensate. -
uv run --gui-scriptdoesn't prevent console allocation. The--gui-scriptflag only affects the child process (it usespythonwinstead ofpython). Sinceuv.exeitself is CUI, Windows allocates a console foruv.exebefore the child is spawned. -
Timing sensitivity. Process tree snapshots are inherently racy. A process can exit between the
Popen()call and theCreateToolhelp32Snapshot()call. The keepalive strategy mitigates this, but the fundamental problem remains.
Upstream Issues Referenced¶
| Issue | Description | Impact |
|---|---|---|
| astral-sh/uv#9781 | uv venv pythonw.exe is CUI trampoline | 3 scenario anomalies |
| joelvaneenwyk/uv#1 | Investigation and reproduction of the bug | Documents root cause |
| joelvaneenwyk/uv#2 | Fix PR in progress | Pending upstream review |