Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 32 additions & 71 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -2938,7 +2899,7 @@ function createActiveBackend(backendArgs) {
root: ACTIVE_HERMES_ROOT,
bootstrap: true,
shell: false
})
}
}

function resolveHermesBackend(backendArgs) {
Expand Down Expand Up @@ -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.`)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -3238,7 +3199,7 @@ async function ensureRuntime(backend) {
running: true,
error: null
})
return applyWindowsNoConsoleSpawnHints(backend)
return backend
}

function fetchJson(url, token, options = {}) {
Expand Down
45 changes: 25 additions & 20 deletions apps/desktop/electron/windows-child-process.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading