Compare commits

...

2 Commits

Author SHA1 Message Date
emozilla
7eaf537cff Merge commit '388268ecde085a22c15474fea1723db161a930da' into fix/windows-desktop-flashing
# Conflicts:
#	apps/desktop/electron/main.cjs
#	apps/desktop/electron/windows-child-process.test.cjs
2026-06-29 01:27:06 -04:00
emozilla
02aa1dbb7d fix(desktop): launch Windows backend as console python so child consoles are inherited, not flashed
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.
2026-06-29 01:18:36 -04:00
2 changed files with 57 additions and 91 deletions

View File

@@ -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,24 @@ 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.
//
// We add 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.
// Launching the backend as 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 repeated
// 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 +2860,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 +2875,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 +2884,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 +2899,7 @@ function createActiveBackend(backendArgs) {
root: ACTIVE_HERMES_ROOT,
bootstrap: true,
shell: false
})
}
}
function resolveHermesBackend(backendArgs) {
@@ -3045,15 +3006,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 +3048,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 +3190,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 +3199,7 @@ async function ensureRuntime(backend) {
running: true,
error: null
})
return applyWindowsNoConsoleSpawnHints(backend)
return backend
}
function fetchJson(url, token, options = {}) {

View File

@@ -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', () => {