fix(desktop): run venv-blocker scan async off the main-process event loop

The preflight's execFileSync froze the Electron UI event loop for up to
15s while psutil scanned the full process table. Convert
scanVenvBlockers to async execFile and await it in applyUpdates; tests
updated to async DI stubs. Also adds trailing newlines.
This commit is contained in:
Teknium 2026-07-28 12:05:02 -07:00
parent d210500b54
commit 1dd1c449bb
4 changed files with 71 additions and 42 deletions

View file

@ -2891,7 +2891,7 @@ 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 = scanVenvBlockers(updateRoot)
const scanOutcome = await scanVenvBlockers(updateRoot)
if (scanOutcome.kind === 'blocked') {
const message = formatBlockerMessage(scanOutcome.result)

View file

@ -165,46 +165,46 @@ describe('scanVenvBlockers', () => {
})
function execReturn(json: string): any {
return ((...args: any[]) => json) as any
return (async (...args: any[]) => ({ stdout: json, stderr: '' })) as any
}
function execThrow(status: number, stderr: string): any {
return ((...args: any[]) => { const e: any = new Error(); e.status = status; e.stderr = Buffer.from(stderr); throw e }) as any
return (async (...args: any[]) => { const e: any = new Error(); e.status = status; e.stderr = Buffer.from(stderr); throw e }) as any
}
it('clear scan returns clear', () => {
assert.equal(scanVenvBlockers('/r', execReturn(okJson), stubVenv).kind, 'clear')
it('clear scan returns clear', async () => {
assert.equal((await scanVenvBlockers('/r', execReturn(okJson), stubVenv)).kind, 'clear')
})
it('blocked scan returns blocked', () => {
assert.equal(scanVenvBlockers('/r', execReturn(blockedJson), stubVenv).kind, 'blocked')
it('blocked scan returns blocked', async () => {
assert.equal((await scanVenvBlockers('/r', execReturn(blockedJson), stubVenv)).kind, 'blocked')
})
it('non-zero exit is probe-failure', () => {
const o = scanVenvBlockers('/r', execThrow(2, 'ModuleNotFoundError'), stubVenv)
it('non-zero exit is probe-failure', async () => {
const o = await scanVenvBlockers('/r', execThrow(2, 'ModuleNotFoundError'), stubVenv)
assert.equal(o.kind, 'probe-failure')
})
it('missing venv python is probe-failure', () => {
const o = scanVenvBlockers('/r', execReturn(okJson), () => null)
it('missing venv python is probe-failure', async () => {
const o = await scanVenvBlockers('/r', execReturn(okJson), () => null)
assert.equal(o.kind, 'probe-failure')
})
it('malformed subprocess output is probe-failure', () => {
const o = scanVenvBlockers('/r', execReturn('bad json'), stubVenv)
it('malformed subprocess output is probe-failure', async () => {
const o = await scanVenvBlockers('/r', execReturn('bad json'), stubVenv)
assert.equal(o.kind, 'probe-failure')
})
it('calls subprocess with correct args, cwd, timeout, stdio', () => {
it('calls subprocess with correct args, cwd and timeout', async () => {
const calls: any[] = []
const spy = ((cmd: string, args: string[], opts: any) => {
calls.push({ cmd, args, cwd: opts.cwd, timeout: opts.timeout, stdio: opts.stdio })
const spy = (async (cmd: string, args: string[], opts: any) => {
calls.push({ cmd, args, cwd: opts.cwd, timeout: opts.timeout })
return okJson
return { stdout: okJson, stderr: '' }
}) as any
scanVenvBlockers('/update/root', spy, stubVenv)
await scanVenvBlockers('/update/root', spy, stubVenv)
assert.equal(calls.length, 1)
const c = calls[0]
assert.ok(c.cmd.endsWith('python.exe'))
@ -212,6 +212,5 @@ describe('scanVenvBlockers', () => {
assert.equal(c.cwd, '/update/root')
assert.equal(typeof c.timeout, 'number')
assert.ok(c.timeout > 0)
assert.deepEqual(c.stdio, ['ignore', 'pipe', 'pipe'])
})
})

View file

@ -7,9 +7,12 @@
* returns a typed result for the Desktop update preflight.
*/
import { execFileSync } from 'node:child_process'
import { execFile } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
// ---------------------------------------------------------------------------
// Types
@ -106,15 +109,17 @@ export function parseVenvBlockerScanOutput(raw: string): ScanOutcome {
}
/**
* Run the venv-blocker scan subprocess. Accepts optional overrides for
* testing (dependency injection).
* Run the venv-blocker scan subprocess. Async so the Electron main-process
* event loop is never blocked by the psutil process scan (up to 15s on a
* loaded Windows box). Accepts optional overrides for testing (dependency
* injection).
*/
export function scanVenvBlockers(
export async function scanVenvBlockers(
updateRoot: string,
execOverride?: typeof execFileSync,
execOverride?: typeof execFileAsync,
resolveOverride?: typeof resolveVenvPython,
): ScanOutcome {
const execFn = execOverride || execFileSync
): Promise<ScanOutcome> {
const execFn = execOverride || execFileAsync
const resolveFn = resolveOverride || resolveVenvPython
const venvPython = resolveFn(updateRoot)
@ -125,21 +130,20 @@ export function scanVenvBlockers(
let stdout: string
try {
const proc = execFn(
const proc = await execFn(
venvPython,
['-m', SCAN_MODULE],
{
cwd: updateRoot,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: SCAN_TIMEOUT_MS,
windowsHide: true,
} as any,
)
stdout = (proc as unknown as string)
stdout = String((proc as any).stdout ?? '')
} catch (err: any) {
const diag = [`exit code ${err.status ?? -1}`]
const diag = [`exit code ${err.status ?? err.code ?? -1}`]
if (err.stderr) {diag.push(String(err.stderr).slice(0, 200))}

50
package-lock.json generated
View file

@ -165,6 +165,7 @@
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-react": "^6.0.1",
"bippy": "0.5.43",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"electron": "40.10.2",
@ -183,8 +184,7 @@
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"wait-on": "^9.0.5",
"bippy": "0.5.43"
"wait-on": "^9.0.5"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@ -1939,6 +1939,7 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -1955,6 +1956,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -1971,6 +1973,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -1987,6 +1990,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2003,6 +2007,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2019,6 +2024,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2035,6 +2041,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2051,6 +2058,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2067,6 +2075,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2083,6 +2092,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2099,6 +2109,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2115,6 +2126,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2131,6 +2143,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2147,6 +2160,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2163,6 +2177,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2179,6 +2194,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2195,6 +2211,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2211,6 +2228,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2227,6 +2245,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2243,6 +2262,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2259,6 +2279,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2275,6 +2296,7 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2291,6 +2313,7 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2307,6 +2330,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2323,6 +2347,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -2339,6 +2364,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -7730,6 +7756,16 @@
"integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==",
"license": "MIT"
},
"node_modules/bippy": {
"version": "0.5.43",
"resolved": "https://registry.npmjs.org/bippy/-/bippy-0.5.43.tgz",
"integrity": "sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"react": ">=17.0.0"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@ -19748,16 +19784,6 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/bippy": {
"version": "0.5.43",
"resolved": "https://registry.npmjs.org/bippy/-/bippy-0.5.43.tgz",
"integrity": "sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"react": ">=17.0.0"
}
}
}
}