From aa2ae36c3fcb6ea9af76c603d262389d832a24dd Mon Sep 17 00:00:00 2001 From: emozilla Date: Sun, 28 Jun 2026 23:57:47 -0400 Subject: [PATCH] fix(desktop): launch Windows backend as console python so child consoles are inherited, not flashed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recurring Windows desktop console-flash bug (#54220) is governed by the *parent's* console, not by each child spawn. The desktop backend was launched as GUI-subsystem pythonw.exe, which has no console at all — so every console-subsystem child it spawns (git, gh, cmd, wmic, powershell, ...) had to allocate its own console, flashing a window. That is why the fix had become an endless per-call-site sweep of CREATE_NO_WINDOW flags: each leaf spawn was papering over a missing console on the root. Launch the backend as the venv's console python.exe instead. Under the existing hiddenWindowsChildOptions() wrapper (windowsHide: true -> CREATE_NO_WINDOW) the backend owns a single *windowless* console, and every descendant spawn inherits it instead of allocating a visible one. This makes "no flashing windows" a property of the one backend launch rather than a flag that must be remembered at every spawn site — including spawns inside third-party libraries that no call-site sweep can reach. Verified on Windows 11 25H2 (Windows Terminal default): with the per-site hide flag forcibly neutered, the canonical culprits (git/gh/cmd/wmic/powershell) spawned naively and none flashed, while the same naive spawn from the old console-less pythonw parent did flash — isolating the parent console as the cause. Two premises behind the old pythonw approach did not hold up on current Windows and are dropped here: - The venv Scripts\python.exe uv shim, under CREATE_NO_WINDOW, re-execs base python *windowless* — it does not flash a conhost (the #52239 concern), so the base-pythonw detour is unnecessary. - Console python restores stdout, so the backend announces its port on the normal HERMES_DASHBOARD_READY stdout line; the pythonw-only ready-file side channel is no longer needed and the readyFile opt-in is removed. Removes the now-dead pythonw machinery (getNoConsoleVenvPython, toNoConsolePython, applyWindowsNoConsoleSpawnHints, readVenvHome) and updates the test to assert the new invariant: backend command is never pythonw, both backend spawns still go through hiddenWindowsChildOptions, and no backend opts into the ready-file path. Scope: this fixes the high-frequency backend-descendant flash classes. The updater/UAC handoff (#54543) and embedded-terminal PTY accumulation (#53555) classes have separate root causes and are unaffected. --- apps/desktop/electron/main.cjs | 105 ++++++------------ .../electron/windows-child-process.test.cjs | 45 ++++---- 2 files changed, 59 insertions(+), 91 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 1b5f7fa9ae2..e800034500b 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1320,12 +1320,12 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null const venvRoot = path.dirname(scriptsDir) - const python = getNoConsoleVenvPython(venvRoot) + const python = getVenvPython(venvRoot) if (!fileExists(python)) return null const root = path.dirname(venvRoot) return { - label: `existing Hermes no-console Python at ${python}`, + label: `existing Hermes Python at ${python}`, command: python, args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, @@ -1338,7 +1338,6 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { // Surfaced so backendSupportsServe() can read this runtime's source for the // `serve` capability check instead of falling back to a heavyweight probe. root, - readyFile: true, shell: false } } @@ -1622,62 +1621,26 @@ function getVenvPython(venvRoot) { return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python')) } -function readVenvHome(venvRoot) { - try { - const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8') - const match = cfg.match(/^home\s*=\s*(.+?)\s*$/im) - return match ? match[1].trim() : null - } catch { - return null - } -} - -function getNoConsoleVenvPython(venvRoot) { - if (!IS_WINDOWS) return getVenvPython(venvRoot) - - // The venv's ``Scripts\pythonw.exe`` is a uv launcher shim that re-execs the - // base console ``python.exe``, allocating a conhost/Windows Terminal window - // that CREATE_NO_WINDOW can't suppress. Use the base ``pythonw.exe`` directly; - // callers put the venv site-packages on PYTHONPATH so imports still resolve. - const baseHome = readVenvHome(venvRoot) - if (baseHome) { - const basePythonw = path.join(baseHome, 'pythonw.exe') - if (fileExists(basePythonw)) return basePythonw - } - - return path.join(venvRoot, 'Scripts', 'pythonw.exe') -} - -function toNoConsolePython(pythonPath) { - if (!IS_WINDOWS || !pythonPath) return pythonPath - - const resolved = String(pythonPath) - if (/pythonw\.exe$/i.test(resolved)) return resolved - - if (/python\.exe$/i.test(resolved)) { - const pythonw = path.join(path.dirname(resolved), 'pythonw.exe') - if (fileExists(pythonw)) return pythonw - } - - return pythonPath -} - -function applyWindowsNoConsoleSpawnHints(backend) { - if (!IS_WINDOWS || !backend?.command) return backend - - const usesHermesModule = - backend.kind === 'python' || - (Array.isArray(backend.args) && backend.args[0] === '-m' && backend.args[1] === 'hermes_cli.main') - - if (!usesHermesModule) return backend - - backend.command = toNoConsolePython(backend.command) - if (/pythonw\.exe$/i.test(path.basename(String(backend.command || '')))) { - backend.readyFile = true - } - - return backend -} +// Windows console-window flashes are governed by the *parent's* console, not by +// each child spawn. A GUI-subsystem parent (pythonw.exe) has no console, so every +// console-subsystem child it spawns (git, gh, cmd, ...) must allocate its own — +// which flashes a window. A console-subsystem parent (python.exe) instead owns a +// single console that all of its children inherit, so none of them flash. +// +// Note this change adds no new creationflag: the backend spawn is ALREADY wrapped +// in hiddenWindowsChildOptions() (windowsHide: true), but that setting is INERT +// against pythonw.exe — a GUI-subsystem process has no console for it to act on. +// Switching the backend to the venv's console python.exe is what makes the +// existing wrapper load-bearing: with windowsHide the process comes up owning a +// *windowless* console (verified at runtime — it has an attachable console whose +// window handle is NULL), and its children inherit that one windowless console +// instead of each allocating a visible one. +// +// This makes "no flashing windows" a property of the one backend launch rather +// than a flag that has to be remembered at every descendant spawn site. Restoring +// console python also restores stdout, so the backend announces its port on the +// normal HERMES_DASHBOARD_READY stdout line and no ready-file side channel is +// needed. function getVenvSitePackagesEntries(venvRoot) { const entries = [] @@ -2899,9 +2862,9 @@ function createPythonBackend(root, label, backendArgs, options = {}) { const venvRoot = path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) - const command = IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python) + const command = IS_WINDOWS && fileExists(venvPython) ? venvPython : python - return applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label, command, @@ -2914,7 +2877,7 @@ function createPythonBackend(root, label, backendArgs, options = {}) { root, bootstrap: Boolean(options.bootstrap), shell: false - }) + } } // createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the @@ -2923,9 +2886,9 @@ function createPythonBackend(root, label, backendArgs, options = {}) { // ensureRuntime() to create / refresh it before launch. function createActiveBackend(backendArgs) { const venvPython = getVenvPython(VENV_ROOT) - const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython()) + const command = fileExists(venvPython) ? venvPython : findSystemPython() - return applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command, @@ -2938,7 +2901,7 @@ function createActiveBackend(backendArgs) { root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false - }) + } } function resolveHermesBackend(backendArgs) { @@ -3045,15 +3008,15 @@ function resolveHermesBackend(backendArgs) { // failure, fall through to step 6 so the bootstrap runner pulls // a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv. if (canImportHermesCli(python)) { - return applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label: `installed hermes_cli module via ${python}`, - command: toNoConsolePython(python), + command: python, args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, env: {}, shell: false - }) + } } rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } @@ -3087,7 +3050,7 @@ function resolveHermesBackend(backendArgs) { async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) - return applyWindowsNoConsoleSpawnHints(backend) + return backend } // backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't @@ -3229,7 +3192,7 @@ async function ensureRuntime(backend) { ) } - backend.command = getNoConsoleVenvPython(VENV_ROOT) + backend.command = getVenvPython(VENV_ROOT) backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})` updateBootProgress({ phase: 'runtime.ready', @@ -3238,7 +3201,7 @@ async function ensureRuntime(backend) { running: true, error: null }) - return applyWindowsNoConsoleSpawnHints(backend) + return backend } function fetchJson(url, token, options = {}) { diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs index 3b42c6d7318..2562643d9c7 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -39,34 +39,39 @@ test('desktop background child processes opt into hidden Windows consoles', () = requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/) assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/) - assert.match(source, /existing Hermes no-console Python at/) - assert.match(source, /function getNoConsoleVenvPython\(venvRoot\)/) - assert.match(source, /function toNoConsolePython\(pythonPath\)/) - assert.match(source, /function applyWindowsNoConsoleSpawnHints\(backend\)/) - assert.match(source, /function readVenvHome\(venvRoot\)/) - assert.match(source, /path\.join\(venvRoot, 'Scripts', 'pythonw\.exe'\)/) - assert.match(source, /backendStartFailure/) - assert.match(source, /HERMES_DESKTOP_READY_FILE/) - assert.match(source, /readyFile: true/) assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/) assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/) assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/) }) -test('getNoConsoleVenvPython prefers base pythonw over the uv re-exec shim', () => { +test('desktop backend launches console python so child consoles are inherited, not pythonw', () => { const source = readElectronFile('main.cjs') - const body = source.slice( - source.indexOf('function getNoConsoleVenvPython(venvRoot)'), - source.indexOf('function getVenvSitePackagesEntries(venvRoot)') + + // The flash fix is structural: the backend runs as a console-subsystem + // python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it + // owns ONE windowless console that every descendant spawn inherits. Launching + // it as GUI-subsystem pythonw.exe is what made each child allocate (and flash) + // its own console, so the backend command must never be pythonw. + assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe') + assert.doesNotMatch( + source, + /function getNoConsoleVenvPython\b/, + 'pythonw-conversion helper should be gone; console python is launched directly' + ) + assert.doesNotMatch( + source, + /function applyWindowsNoConsoleSpawnHints\b/, + 'pythonw spawn-hint rewriter should be gone' ) - // The venv Scripts\pythonw.exe re-execs a console python.exe (flashes a - // conhost); the base pythonw must be resolved first so it never runs. - const baseIdx = body.indexOf('basePythonw') - const shimIdx = body.indexOf("'Scripts', 'pythonw.exe'") - assert.notEqual(baseIdx, -1, 'base pythonw resolution missing') - assert.notEqual(shimIdx, -1, 'venv shim fallback missing') - assert.ok(baseIdx < shimIdx, 'base pythonw must be preferred before the venv Scripts shim') + // Console python restores stdout, so the port is announced on the normal + // HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set. + assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path') + + // Both desktop backend launches must still go through hiddenWindowsChildOptions + // so the single backend console is created windowless. + requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/) + requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) }) test('intentional or interactive desktop child processes stay documented', () => {