diff --git a/apps/desktop/electron/bootstrap-quit.test.ts b/apps/desktop/electron/bootstrap-quit.test.ts new file mode 100644 index 0000000000..1d0a8614e7 --- /dev/null +++ b/apps/desktop/electron/bootstrap-quit.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { test } from 'vitest' + +import { runBootstrap } from './bootstrap-runner' + +for (const boundary of ['resolution', 'manifest'] as const) { + test.skipIf(process.platform === 'win32')(`quit during ${boundary} cancels bootstrap before stages`, async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-quit-')) + const controller = new AbortController() + const marker = path.join(home, 'manifest-started') + let manifestPid: number | undefined + + fs.mkdirSync(path.join(home, 'scripts')) + fs.writeFileSync( + path.join(home, 'scripts/install.sh'), + `#!/bin/bash\nprintf started > "$HERMES_HOME/manifest-started"\nprintf 'manifest-pid=%s\\n' "$$"\nwhile :; do :; done\n` + ) + + try { + const result = await runBootstrap({ + installStamp: null, + activeRoot: path.join(home, 'agent'), + sourceRepoRoot: home, + hermesHome: home, + abortSignal: controller.signal, + onEvent: event => { + if (boundary === 'resolution' && event.line?.includes('using local')) { + controller.abort() + } + + const match = event.line?.match(/^manifest-pid=(\d+)$/) + + if (match) { + manifestPid = Number(match[1]) + controller.abort() + } + } + }) + + assert.equal(result.ok, false) + assert.equal(result.cancelled, true) + assert.equal(fs.existsSync(marker), boundary === 'manifest') + + if (manifestPid) { + assert.throws(() => process.kill(manifestPid!, 0)) + } + } finally { + controller.abort() + + if (manifestPid) { + try { + process.kill(manifestPid, 'SIGKILL') + } catch { + /* already exited */ + } + } + + fs.rmSync(home, { recursive: true, force: true }) + } + }) +} diff --git a/apps/desktop/electron/bootstrap-runner.ts b/apps/desktop/electron/bootstrap-runner.ts index dee008ff5d..ddc273f8dd 100644 --- a/apps/desktop/electron/bootstrap-runner.ts +++ b/apps/desktop/electron/bootstrap-runner.ts @@ -690,7 +690,17 @@ function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = t return args } -async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp, pinCommit }) { +async function fetchManifest({ + scriptPath, + installerKind, + emit, + hermesHome, + activeRoot, + installStamp, + pinCommit, + abortSignal +}) { + abortSignal?.throwIfAborted() const isPosix = installerKind === 'posix' const args = isPosix @@ -700,6 +710,7 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, { emit, stageName: '__manifest__', + abortSignal, hermesHome }) @@ -928,6 +939,8 @@ async function runBootstrap(opts) { // 1. Resolve the platform installer. const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) + abortSignal?.throwIfAborted() + const installerKind = scriptInfo.kind || 'powershell' // 2. Fetch manifest @@ -938,9 +951,12 @@ async function runBootstrap(opts) { hermesHome, activeRoot, installStamp, - pinCommit + pinCommit, + abortSignal }) + abortSignal?.throwIfAborted() + emit({ type: 'manifest', stages: manifest.stages, @@ -1007,12 +1023,18 @@ async function runBootstrap(opts) { return { ok: true, marker } } catch (err) { + if (abortSignal?.aborted) { + emit({ type: 'failed', error: 'bootstrap cancelled by user' }) + + return { ok: false, cancelled: true } + } + emit({ type: 'failed', error: err.message || String(err) }) return { ok: false, error: err.message || String(err) } } finally { try { - runLog.stream.end() + await new Promise(resolve => runLog.stream.end(resolve)) } catch { void 0 }