diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index f2d3083e74d..3fe047aa71c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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) diff --git a/apps/desktop/electron/venv-blocker-scan.test.ts b/apps/desktop/electron/venv-blocker-scan.test.ts index 3314848d702..164c291917b 100644 --- a/apps/desktop/electron/venv-blocker-scan.test.ts +++ b/apps/desktop/electron/venv-blocker-scan.test.ts @@ -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']) }) }) \ No newline at end of file diff --git a/apps/desktop/electron/venv-blocker-scan.ts b/apps/desktop/electron/venv-blocker-scan.ts index ec019ebab2b..67a6c1f7ce1 100644 --- a/apps/desktop/electron/venv-blocker-scan.ts +++ b/apps/desktop/electron/venv-blocker-scan.ts @@ -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 { + 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))} diff --git a/package-lock.json b/package-lock.json index e0031df8ff2..c4275fb8fff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" - } } } }