Skip to content
Merged
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
24 changes: 20 additions & 4 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,12 @@ import {
stagedUpdaterSupportsPrewrittenMarker,
wrapHandoffForDetachedConsole
} from './updater-process'
import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan'
import {
formatBlockerMessage,
formatProbeFailedMessage,
scanVenvBlockers,
stopSafeVenvBlockers
} from './venv-blocker-scan'
import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace'
import { createWakeIndicatorWindowController } from './wake-indicator-window'
import { readWindowBelow } from './window-below'
Expand Down Expand Up @@ -3265,7 +3270,7 @@ async function releaseBackendLock(updateRoot, tag) {
//
// Detection (checkUpdates / commit changelog / "N behind") stays in the UI;
// only this apply action changed.
async function applyUpdates(opts = {}) {
async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) {
if (updateInFlight) {
throw new Error('An update is already in progress.')
}
Expand Down Expand Up @@ -3402,7 +3407,18 @@ async function applyUpdates(opts = {}) {
// malformed output, missing psutil) abort the handoff — never proceed
// to the detached updater when the venv state is unknown.
if (IS_WINDOWS) {
const scanOutcome = await scanVenvBlockers(updateRoot)
let scanOutcome = await scanVenvBlockers(updateRoot)

if (scanOutcome.kind === 'blocked' && opts.stopSafeBlockers) {
const stopResult = await stopSafeVenvBlockers(updateRoot, scanOutcome.result)
rememberLog(
`[updates] user-approved blocker cleanup: stopped=${stopResult.stopped.join(',') || 'none'} failed=${stopResult.failed.join(',') || 'none'}`
)
// Let verified process-tree termination finish unwinding wrapper shells,
// then make the scanner — not the stale renderer payload — authoritative.
await new Promise(resolve => setTimeout(resolve, 300))
scanOutcome = await scanVenvBlockers(updateRoot)
}

if (scanOutcome.kind === 'blocked') {
const message = formatBlockerMessage(scanOutcome.result)
Expand All @@ -3411,7 +3427,7 @@ async function applyUpdates(opts = {}) {
emitUpdateProgress({ stage: 'error', message, percent: null })
startHermes().catch(() => {})

return { ok: false, error: 'venv-blocked', message }
return { ok: false, error: 'venv-blocked', message, blockers: scanOutcome.result.processes }
}

if (scanOutcome.kind === 'probe-failure') {
Expand Down
140 changes: 138 additions & 2 deletions apps/desktop/electron/venv-blocker-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
formatProbeFailedMessage,
parseVenvBlockerScanOutput,
resolveVenvPython,
scanVenvBlockers
scanVenvBlockers,
stopSafeVenvBlockers
} from './venv-blocker-scan'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -56,7 +57,9 @@ describe('formatBlockerMessage', () => {
it('includes PID, name, cmdline, remote-client warning, and retry suggestion', () => {
const msg = formatBlockerMessage({
blocked: true,
processes: [{ pid: 101, name: 'python.exe', cmdline: 'serve --host 10.0.0.1' }]
processes: [
{ pid: 101, name: 'python.exe', cmdline: 'serve --host 10.0.0.1', kind: 'other', safeToStop: false }
]
})

assert.ok(msg.includes('PID 101'))
Expand Down Expand Up @@ -99,6 +102,85 @@ describe('parseVenvBlockerScanOutput', () => {
assert.equal(o.kind, 'blocked')
})

it('classifies Python http.server blockers as safe local previews with a human label', () => {
const o = parseVenvBlockerScanOutput(
ok({
blocked: true,
processes: [
{
pid: 47484,
name: 'python.exe',
cmdline: 'C:\\Hermes\\venv\\Scripts\\python.exe -m http.server 8766 --directory C',
kind: 'local-preview',
safeToStop: true,
label: 'Example Preview',
port: 8766,
createTime: 1722798000.25
}
]
})
)

assert.equal(o.kind, 'blocked')

if (o.kind !== 'blocked') {
return
}

assert.deepEqual(o.result.processes[0], {
pid: 47484,
name: 'python.exe',
cmdline: 'C:\\Hermes\\venv\\Scripts\\python.exe -m http.server 8766 --directory C',
kind: 'local-preview',
safeToStop: true,
label: 'Example Preview',
port: 8766,
createTime: 1722798000.25
})
})

it('does not trust a truncated http.server command line without scanner identity metadata', () => {
const o = parseVenvBlockerScanOutput(
ok({
blocked: true,
processes: [
{
pid: 47484,
name: 'python.exe',
cmdline: 'python.exe -m http.server 8766 --directory C'
}
]
})
)

assert.equal(o.kind, 'blocked')

if (o.kind !== 'blocked') {
return
}

assert.equal(o.result.processes[0]?.kind, 'other')
assert.equal(o.result.processes[0]?.safeToStop, false)
})

it('never marks an arbitrary Python process safe to stop', () => {
const o = parseVenvBlockerScanOutput(
ok({
blocked: true,
processes: [{ pid: 9, name: 'python.exe', cmdline: 'python.exe important-script.py' }]
})
)

assert.equal(o.kind, 'blocked')

if (o.kind !== 'blocked') {
return
}

assert.equal(o.result.processes[0]?.kind, 'other')
assert.equal(o.result.processes[0]?.safeToStop, false)
})

it('malformed JSON', () => {
assert.equal(parseVenvBlockerScanOutput('not json').kind, 'probe-failure')
})
Expand Down Expand Up @@ -216,3 +298,57 @@ describe('scanVenvBlockers', () => {
assert.ok(c.timeout > 0)
})
})

describe('stopSafeVenvBlockers', () => {
it('stops only blockers explicitly classified as safe local previews', async () => {
const calls: Array<{ command: string; args: string[] }> = []

const exec = (async (command: string, args: string[]) => {
calls.push({ command, args })

return { stdout: '', stderr: '' }
}) as any

const outcome = await stopSafeVenvBlockers(
'/update/root',
{
blocked: true,
processes: [
{
pid: 47484,
name: 'python.exe',
cmdline: 'python.exe -m http.server 8766 --directory C:\\preview',
kind: 'local-preview',
safeToStop: true,
label: 'preview',
port: 8766,
createTime: 1722798000.25
},
{
pid: 99,
name: 'python.exe',
cmdline: 'python.exe important-script.py',
kind: 'other',
safeToStop: false
}
]
},
exec,
() => 'C:\\Hermes\\venv\\Scripts\\python.exe'
)

assert.deepEqual(calls, [
{
command: 'C:\\Hermes\\venv\\Scripts\\python.exe',
args: [
'-m',
'hermes_cli._scan_venv_blockers',
'--terminate-safe',
'47484',
'1722798000.25'
]
}
])
assert.deepEqual(outcome, { stopped: [47484], failed: [] })
})
})
102 changes: 101 additions & 1 deletion apps/desktop/electron/venv-blocker-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,17 @@ const execFileAsync = promisify(execFile)
// Types
// ---------------------------------------------------------------------------

export type VenvBlockerKind = 'local-preview' | 'other'

export interface VenvBlockerProcess {
pid: number
name: string
cmdline: string
kind: VenvBlockerKind
safeToStop: boolean
label?: string
port?: number
createTime?: number
}

export interface VenvBlockerScanResult {
Expand All @@ -45,6 +52,99 @@ const SCAN_MODULE = 'hermes_cli._scan_venv_blockers'
// Public API
// ---------------------------------------------------------------------------

function classifyVenvBlocker(
process: Pick<VenvBlockerProcess, 'pid' | 'name' | 'cmdline'>,
hints?: Record<string, unknown>
): VenvBlockerProcess {
const moduleMatch = process.cmdline.match(/(?:^|\s)-m\s+http\.server(?:\s+(\d{1,5}))?(?:\s|$)/i)
const isPython = /^python(?:w)?(?:\.exe)?$/i.test(process.name)
const hintedCreateTime = typeof hints?.createTime === 'number' ? hints.createTime : undefined

const trustedScannerIdentity =
hints?.kind === 'local-preview' &&
hints.safeToStop === true &&
hintedCreateTime !== undefined &&
Number.isFinite(hintedCreateTime) &&
hintedCreateTime > 0

if (!isPython || !moduleMatch || !trustedScannerIdentity) {
return { ...process, kind: 'other', safeToStop: false }
}

const parsedPort = moduleMatch[1] ? Number(moduleMatch[1]) : 8000
const hintedPort = trustedScannerIdentity && typeof hints?.port === 'number' ? hints.port : undefined
const candidatePort = hintedPort ?? parsedPort

const port =
Number.isInteger(candidatePort) && candidatePort > 0 && candidatePort <= 65535 ? candidatePort : undefined

const directoryMatch = process.cmdline.match(/(?:^|\s)--directory\s+(?:"([^"]+)"|'([^']+)'|(.+))$/i)
const directory = (directoryMatch?.[1] || directoryMatch?.[2] || directoryMatch?.[3] || '').trim()
const parsedLabel = directory ? path.win32.basename(directory.replace(/["']$/, '')) : undefined
const hintedLabel = trustedScannerIdentity && typeof hints?.label === 'string' ? hints.label.trim() : ''
const label = hintedLabel || parsedLabel

return {
...process,
kind: 'local-preview',
safeToStop: true,
...(label ? { label } : {}),
...(port ? { port } : {}),
createTime: hintedCreateTime
}
}

/**
* Stop only blockers that the fresh scanner identified as Python static-file
* preview servers. Unknown Python/Hermes processes are deliberately ignored.
*/
export async function stopSafeVenvBlockers(
updateRoot: string,
result: VenvBlockerScanResult,
execOverride?: typeof execFileAsync,
resolvePython: typeof resolveVenvPython = resolveVenvPython
): Promise<{ stopped: number[]; failed: number[] }> {
const execFn = execOverride || execFileAsync
const stopped: number[] = []
const failed: number[] = []
const pythonPath = resolvePython(updateRoot)

for (const process of result.processes) {
if (
!pythonPath ||
!process.safeToStop ||
process.kind !== 'local-preview' ||
!process.createTime ||
!Number.isFinite(process.createTime)
) {
if (process.safeToStop && process.kind === 'local-preview') {
failed.push(process.pid)
}

continue
}

try {
await execFn(
pythonPath,
[
'-m',
'hermes_cli._scan_venv_blockers',
'--terminate-safe',
String(process.pid),
String(process.createTime)
],
{ cwd: updateRoot, windowsHide: true, timeout: 10_000, maxBuffer: 256 * 1024 }
)
stopped.push(process.pid)
} catch {
failed.push(process.pid)
}
}

return { stopped, failed }
}

/**
* Strictly validate and parse the JSON output from the venv-blocker scan.
* Pure function — no side effects.
Expand Down Expand Up @@ -91,7 +191,7 @@ export function parseVenvBlockerScanOutput(raw: string): ScanOutcome {
return { kind: 'probe-failure', error: 'process cmdline must be a string' }
}

processes.push({ pid, name, cmdline })
processes.push(classifyVenvBlocker({ pid, name, cmdline }, entry))
}

// Reject inconsistent combinations
Expand Down
Loading
Loading