Skip to content

Cold uvx install of the attach bridge flashes a visible terminal window on Windows #851

Description

@dsarno

Observed live on Windows 11 with Windows Terminal as the default terminal host, and reproduced deterministically with an EnumWindows probe.

When an AI client spawns the attach bridge for a version uv has never built on that machine (first spawn after a release repin), a visible Windows Terminal window appears for the duration of the env build. Probe evidence: CASCADIA_HOSTING_WINDOW_CLASS windows titled …\uv\cache\archive-v0\…\Scripts\python.exe appear ~40s into a cold uvx --from godot-ai==X godot-ai attach … spawn and vanish when the build finishes. Warm-cache spawns show nothing.

The entry's pythonw -c <bootstrap> wrapper launches uvx itself with CREATE_NO_WINDOW, which is why normal operation is silent — but uv's own child python.exe processes during env creation are spawned with uv's flags, not ours, and under Windows-Terminal-as-default they allocate a visible window.

Impact: cosmetic, cold-path only — first bridge spawn per (machine × version), typically right after a plugin update repins entries. Still worth fixing: it looks like malware to an unwarned user.

Candidate directions, unverified:

  • have the bootstrap pre-attach a hidden console the descendant tree inherits (needs testing against WT's SW_HIDE handling);
  • warm the pinned env at Configure time from the editor side, so client spawns never build;
  • check whether newer uv versions set CREATE_NO_WINDOW on their workers (upstream fix may exist).

Probe script available from the 3.1.0 verification session; reproduce with any never-built version pin under pythonw.

🤖 Generated with Claude Code

Activity

  1. dsarno commented on Aug 25, 2026

    @dsarno
    ContributorAuthor

    Maintainer direction: keep this open, with a fresh Windows reproduction as the next gate.

    Astral merged uv #19235 on 2026-08-11, fixing a related Windows pythonw launcher-mode bug. That change does not obviously cover the cold-uvx environment-build workers observed here—the probe saw ...\\Scripts\\python.exe processes—so it is not enough to close this without rerunning the deterministic EnumWindows probe against the latest uv.

    If the flash still reproduces:

    1. file the minimal probe upstream with the CASCADIA_HOSTING_WINDOW_CLASS evidence and exact process tree;
    2. link that upstream issue here;
    3. use pre-warming of the exact pinned godot-ai==X environment during the user-initiated Configure flow as the local mitigation, with visible install/progress state in the dock;
    4. keep client startup on the already-warm environment.

    Do not add a speculative hidden-console inheritance trick without a Windows Terminal acceptance test; ConPTY behavior is the part that makes this failure non-obvious.

  2. dsarno commented on Aug 27, 2026

    @dsarno
    ContributorAuthor

    This has a second symptom, reported independently on Discord, with the same root cause — worth folding in here rather than tracking separately.

    The other symptom. Every couple of hours the Godot AI tools stop being visible to the client, and the user has to restart both the client and Godot. Their MCP logs show a clean stdio recycle after ~20.6 h, then a failed reconnect:

    Starting connection with timeout of 30000ms
    Server stderr: Installed 69 packages in 12.71s
    Connection failed after 30105ms (CONNECT_TIMEOUT)
    

    A later reconnect succeeded in 7.2 s once the cache was warm. So: not a crash, not the plugin — a cold env build that didn't fit inside the client's default 30 s connect timeout. Exactly this issue's cold path, surfacing as a timeout instead of a window flash.

    Measurements (on main, macOS, this tree):

    • godot-ai==3.2.2 resolves to 67 packages / ~78 MB (the reporter's 69 is the same tree plus Windows-only extras).
    • Cold uvx --from godot-ai==3.2.2 godot-ai --version: full env build. Warm: 0.11 s.
    • The weight is upstream and not trimmable from our side: fastmcp is exactly fastmcp-slim[client,server], and we use both. Resolved counts — bare slim 16, [client] 51, [server] 65, [client,server] 65. Dropping the client extra saves nothing.

    Why it's become more frequent. Configure writes an exact godot-ai==X pin, so every version bump guarantees a never-built environment on the next client launch. That matches the reporter's "it used to not happen but it's semi-regular now".

    A gap worth noting. prewarm_server_package (#890) fires only on self-update and server-lifecycle recovery. Neither covers the common case: the plugin adopts an already-running server, so no uvx spawn happens, nothing warms the env, and the user's first client launch pays in full.

    One thing I checked that could have quietly broken any pre-warm fix: the client entry carries --link-mode copy and the prewarm argv does not. Empirically this does not split uv's environment cache — a --link-mode copy launch reuses an env warmed without the flag — so warming without it is correct. Worth remembering as the silent-no-op failure mode if uv's cache keying ever changes.

    Two claims from that Discord thread that don't hold, in case they resurface:

    • "config pins godot-ai==3.2.2 but the server reports 3.4.7" — 3.4.7 is fastmcp's version, not ours. godot-ai's latest is 3.2.2 and 3.4.7 has never existed on PyPI. Not a version mismatch.
    • Setting MCP_TIMEOUT in the generated config would not help: clients read it from their own process env, not from the server entry's env block. It is not something we can fix from the config we write.

    #894 implements steps 3 and 4 of the direction above (pre-warm the pinned env during Configure, Installing… progress in the dock, client startup stays on the warm env). It deliberately does not touch the hidden-console approach, and the Windows EnumWindows re-run named as this issue's gate is still outstanding — so this stays open for the upstream question.

    🤖 Generated with Claude Code

  3. dsarno commented on Aug 28, 2026

    @dsarno
    ContributorAuthor

    Reran the deterministic EnumWindows probe on Windows — the gate named above. It no longer reproduces. Closing.

    Environment

    • Windows 11 Pro 10.0.26200, Windows Terminal as default console host
    • uv 0.9.13 (the machine's installed version, predates uv#19235) and uv 0.12.7 (latest, released 2026-08-27)
    • godot-ai==3.2.4 from PyPI, 69 packages
    • Cold cache forced with an isolated UV_CACHE_DIR per run, so every run genuinely built an environment uv had never built

    Precondition check first

    A negative result here is worthless unless Windows Terminal really is the default host, so that was verified independently rather than assumed. HKCU\Console\%%Startup has neither DelegationConsole nor DelegationTerminal (= "Let Windows decide"), and a plain CREATE_NEW_CONSOLE spawn of cmd.exe produces:

    Windows Terminal 67c9717e9ff0cc09    pid=29432
    CASCADIA_HOSTING_WINDOW_CLASS        pid=29432  title='Windows Terminal'
    PseudoConsoleWindow                  pid=33664
    

    So the CASCADIA_HOSTING_WINDOW_CLASS the original probe keyed on is exactly what this machine produces for a console app. The precondition holds.

    Results

    Each run spawns the verbatim _WINDOWS_STDIO_BOOTSTRAP shape from client_configurator.gd — pythonw.exe -c <bootstrap> uvx --link-mode copy --from godot-ai==3.2.4 … — with CREATE_NO_WINDOW | DETACHED_PROCESS, mimicking a GUI MCP client, while polling EnumWindows at 50 ms for CASCADIA_HOSTING_WINDOW_CLASS, CASCADIA_WINDOW_CLASS, ConsoleWindowClass and PseudoConsoleWindow.

    run uv argv cold build new console-host windows
    baseline 0.9.13 godot-ai --version 69 pkgs, 9.23 s 0
    latest 0.12.7 godot-ai --version 69 pkgs, 9.72 s 0
    gate 0.12.7 godot-ai attach --port 18130 --ws-port 19630 --disable-telemetry 69 pkgs, 22.32 s 0

    The third run is the faithful one: the exact client argv, and the bridge reached Starting MCP server 'Godot AI attach' with transport 'stdio' — so the whole cold path ran, build through bridge startup, with nothing appearing.

    Note the baseline: it does not reproduce on the old uv either. That matters for attribution — this is not evidence that uv#19235 fixed it, and the cause of the original sighting stays unexplained.

    One correction to this issue's stated root cause

    The issue attributes the windows to "uv's own child python.exe processes during env creation". The cold cache shows that is not what those paths are. archive-v0/<hash>/ contains pyvenv.cfg, Scripts/activate, Lib/site-packages/{godot_ai,fastmcp,…} — it is the ephemeral tool environment venv itself, which uv stores under archive-v0. So …\archive-v0\…\Scripts\python.exe was the tool env's own interpreter — the bridge process — not a uv build worker. Worth knowing if this resurfaces: the thing to instrument is the entry-point exec, not uv's installer.

    Also confirmed: no source builds occur (builds-v0 empty, all 69 deps resolve to wheels), so there is no build-backend worker path to blame.

    Why closing rather than parking

    • The gate this issue was held open for has been run and is negative on the machine where the flash was originally observed, with the precondition verified.
    • The second symptom folded in above — the cold env build overrunning an MCP client's default 30 s connect timeout — is addressed by fix: pre-warm the pinned uv environment at Configure time (#851) #894, which pre-warms the pinned environment during the user-initiated Configure flow and closes the adoption gap (plugin adopts a running server → no uvx spawn → nothing warms the env).
    • Nothing speculative was added: no hidden-console inheritance trick, per the direction above.

    Reopen with a fresh probe log if the flash is seen again — ideally with the uv version and the offending window's class, title and PID, since that identifies which process in the tree owns the console.

    Probe script (keeping it here so it outlives the session this time)
    """EnumWindows probe for #851. Usage: python probe.py <uvx.exe> <pythonw.exe> <version> [seconds]
    Force a cold build with UV_CACHE_DIR pointed at an empty directory."""
    import ctypes, ctypes.wintypes as wt, subprocess, sys, time
    
    user32 = ctypes.WinDLL("user32", use_last_error=True)
    Proc = ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)
    CONSOLEISH = {"CASCADIA_HOSTING_WINDOW_CLASS", "CASCADIA_WINDOW_CLASS",
                  "ConsoleWindowClass", "PseudoConsoleWindow"}
    # verbatim from client_configurator.gd _WINDOWS_STDIO_BOOTSTRAP
    BOOTSTRAP = ("import subprocess,sys; raise SystemExit(subprocess.call(sys.argv[1:], "
                 "stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr, creationflags=0x08000000))")
    
    def snapshot():
        found = {}
        def cb(hwnd, _l):
            cls = ctypes.create_unicode_buffer(256)
            user32.GetClassNameW(hwnd, cls, 256)
            if cls.value not in CONSOLEISH:
                return True
            n = user32.GetWindowTextLengthW(hwnd)
            title = ctypes.create_unicode_buffer(n + 1)
            user32.GetWindowTextW(hwnd, title, n + 1)
            pid = wt.DWORD()
            user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
            found[hwnd] = {"class": cls.value, "title": title.value, "pid": pid.value,
                           "visible": bool(user32.IsWindowVisible(hwnd))}
            return True
        user32.EnumWindows(Proc(cb), 0)
        return found
    
    uvx, pythonw, version = sys.argv[1:4]
    duration = float(sys.argv[4]) if len(sys.argv) > 4 else 240.0
    baseline = snapshot()
    args = [pythonw, "-c", BOOTSTRAP, uvx, "--link-mode", "copy",
            "--from", f"godot-ai=={version}", "godot-ai", "attach",
            "--port", "18130", "--ws-port", "19630", "--disable-telemetry"]
    t0 = time.time()
    proc = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
                            stderr=subprocess.STDOUT,
                            creationflags=0x08000000 | 0x00000008)  # CREATE_NO_WINDOW|DETACHED
    seen, new = dict(baseline), 0
    while time.time() - t0 < duration and proc.poll() is None:
        for h, w in snapshot().items():
            if h not in seen:
                seen[h] = w; new += 1
                print(f"[{time.time()-t0:7.2f}s] APPEAR {w['class']} pid={w['pid']} "
                      f"visible={w['visible']} title={w['title']!r}", flush=True)
        time.sleep(0.05)
    print(f"new console-ish windows: {new}")

    🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions