diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml new file mode 100644 index 00000000000..af021fb59d9 --- /dev/null +++ b/.github/workflows/bun-profile-tests.yml @@ -0,0 +1,106 @@ +name: Bun profile persistence + +on: + pull_request: + paths: + - 'src/main/persistence/**' + - 'src/main/sqlite/**' + - 'src/main/worker-thread-entry-path.ts' + - 'src/main/orcad/**' + - 'src/main/daemon/pty-subprocess/**' + - 'src/main/providers/**' + - 'src/shared/**' + - 'config/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.github/actions/install-node-dependencies/**' + - '.github/workflows/bun-profile-tests.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: bun-profile-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + persistence: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm, macos-14, macos-15-intel, windows-2022, windows-11-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Build the native Windows process reader + if: runner.os == 'Windows' + run: node config/scripts/build-windows-process-tree-relay-addon.mjs + - run: pnpm build:orcad + - run: pnpm test:bun:profile --artifact + - uses: actions/setup-node@v6 + if: runner.arch == 'X64' + with: + node-version: '18' + - name: Verify Node 18 loads and hands off to bundled Bun + if: runner.arch == 'X64' + run: | + node out/orcad/orcad.js --orcad-smoke-load-check + node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018 + + linux_glibc_floor: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + container: ubuntu:20.04 + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - name: Install Ubuntu 20.04 prerequisites + run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Trust the checked-out workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: ./.github/actions/install-node-dependencies + - run: pnpm build:orcad + - run: pnpm test:bun:profile --artifact + + linux_musl: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Verify native Alpine artifact and persistence + run: | + docker run --rm --init -i \ + -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$GITHUB_WORKSPACE:/work" -w /work \ + node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION' + set -eu + apk add --no-cache bash git libstdc++ python3 make g++ + git config --global --add safe.directory /work + npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")" + pnpm install --frozen-lockfile --ignore-scripts + pnpm build:orcad + pnpm test:bun:profile --artifact + BUN_QUALIFICATION diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index abf94296035..f60721ba50b 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -188,6 +188,9 @@ module.exports = { // Why: these repo-only inputs are either bundled into out/ or copied via // extraResources. Shipping them in app.asar bloats the desktop bundle. '!src{,/**/*}', + '!out/orcad{,/**/*}', + '!out/orcad-template{,/**/*}', + '!out/.orcad-*{,/**/*}', '!config{,/**/*}', '!docs{,/**/*}', '!mobile{,/**/*}', diff --git a/config/scripts/build-orcad-bun.mjs b/config/scripts/build-orcad-bun.mjs new file mode 100644 index 00000000000..cddd766e6cc --- /dev/null +++ b/config/scripts/build-orcad-bun.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto' +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join, resolve } from 'node:path' +import { orcadBunRuntimeFilename } from '../../src/shared/orcad-artifacts.ts' +import { + ORCAD_BUN_RELEASE_ASSETS, + ORCAD_BUN_VERSION, + orcadBunReleaseUrl +} from '../../src/shared/orcad-bun-runtime.ts' +import { runProcessSync } from './script-child-process.mjs' +import { getZipExtractorCommand } from './zip-extractor-command.mjs' + +const root = resolve(import.meta.dirname, '../..') +const cacheRoot = join(root, 'out', '.orcad-bun-runtime', `v${ORCAD_BUN_VERSION}`) + +export function currentTarget() { + if (process.platform === 'darwin') { + return `darwin-${process.arch}` + } + if (process.platform === 'win32') { + return `win32-${process.arch}` + } + if (process.platform !== 'linux') { + throw new Error(`Unsupported Bun platform: ${process.platform}`) + } + const glibc = process.report?.getReport()?.header?.glibcVersionRuntime + return `linux-${process.arch}-${glibc ? 'glibc' : 'musl'}` +} + +function argument(name) { + const index = process.argv.indexOf(name) + return index === -1 ? null : process.argv[index + 1] +} + +async function download(url, destination) { + const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(120_000) }) + if (!response.ok) { + await response.body?.cancel() + throw new Error(`Bun download failed: ${response.status} ${response.statusText}`) + } + writeFileSync(destination, new Uint8Array(await response.arrayBuffer())) +} + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +export function bunExecutableName(target) { + return target.startsWith('win32-') ? 'bun.exe' : 'bun' +} + +export function findBunExecutable(rootDir, target) { + const expected = bunExecutableName(target) + const entries = readdirSync(rootDir, { recursive: true, withFileTypes: true }) + const entry = entries.find((candidate) => candidate.isFile() && candidate.name === expected) + if (!entry) { + throw new Error(`Downloaded archive contained no ${expected}`) + } + return join(entry.parentPath, entry.name) +} + +function verifyRuntime(path) { + const result = runProcessSync({ program: path, args: ['--version'] }) + if (result.code !== 0 || result.stdout.trim() !== ORCAD_BUN_VERSION) { + throw new Error( + `Expected Bun ${ORCAD_BUN_VERSION} at ${path}, got ${result.stdout.trim() || result.stderr.trim()}` + ) + } +} + +async function materializeRuntime(target, outputPath) { + const asset = ORCAD_BUN_RELEASE_ASSETS[target] + if (!asset) { + throw new Error(`Unsupported Bun target: ${target}`) + } + const cached = join(cacheRoot, target, orcadBunRuntimeFilename(target)) + if (existsSync(cached) && sha256(cached) !== asset.executableSha256) { + rmSync(cached, { force: true }) + } + if (!existsSync(cached)) { + const temporary = mkdtempSync(join(tmpdir(), 'orca-bun-download-')) + try { + const zipPath = join(temporary, basename(asset.filename)) + await download(orcadBunReleaseUrl(asset), zipPath) + const actual = sha256(zipPath) + if (actual !== asset.sha256) { + throw new Error(`Bun checksum mismatch for ${asset.filename}: ${actual}`) + } + const extracted = join(temporary, 'extracted') + mkdirSync(extracted) + // Node 24.16 can leave extract-zip's stream promise unsettled with no active handles. + const command = getZipExtractorCommand(zipPath, extracted) + const result = runProcessSync({ + program: command.file, + args: command.args, + timeoutMs: 120_000 + }) + if (result.code !== 0) { + throw new Error( + `Bun archive extraction failed with exit ${result.code}: ${result.stderr || result.stdout}` + ) + } + mkdirSync(join(cacheRoot, target), { recursive: true }) + copyFileSync(findBunExecutable(extracted, target), cached) + if (!target.startsWith('win32-')) { + chmodSync(cached, 0o755) + } + } finally { + rmSync(temporary, { recursive: true, force: true }) + } + } + const executableHash = sha256(cached) + if (executableHash !== asset.executableSha256) { + throw new Error(`Bun executable checksum mismatch for ${target}: ${executableHash}`) + } + if (target === currentTarget()) { + verifyRuntime(cached) + } + mkdirSync(resolve(outputPath, '..'), { recursive: true }) + if (resolve(cached) !== resolve(outputPath)) { + copyFileSync(cached, outputPath) + } + if (!target.startsWith('win32-')) { + chmodSync(outputPath, 0o755) + } +} + +async function main() { + const target = argument('--target') ?? currentTarget() + const outputDir = argument('--out-dir') + const cachedRuntimePath = join(cacheRoot, target, orcadBunRuntimeFilename(target)) + const runtimePath = + process.argv.includes('--runtime-only') && outputDir + ? join(resolve(outputDir), orcadBunRuntimeFilename(target)) + : cachedRuntimePath + await materializeRuntime(target, runtimePath) + + if (process.argv.includes('--runtime-only')) { + process.stdout.write(`${runtimePath}\n`) + return + } + const result = runProcessSync({ + program: process.execPath, + args: [join(root, 'config/scripts/build-orcad.mjs')], + cwd: root, + env: { + ...process.env, + ORCAD_BUILD_TARGET: target, + ORCAD_BUILD_TARGET_IS_CURRENT: target === currentTarget() ? '1' : '0', + ORCAD_BUN_RUNTIME_PATH: runtimePath, + ...(outputDir ? { ORCAD_OUT_DIR: resolve(outputDir) } : {}) + }, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + process.exit(result.code ?? 1) + } +} + +if (process.argv[1]?.endsWith('build-orcad-bun.mjs')) { + await main() +} diff --git a/config/scripts/build-orcad-bun.test.mjs b/config/scripts/build-orcad-bun.test.mjs new file mode 100644 index 00000000000..bb3f06bc079 --- /dev/null +++ b/config/scripts/build-orcad-bun.test.mjs @@ -0,0 +1,39 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { bunExecutableName, findBunExecutable } from './build-orcad-bun.mjs' + +const temporaryDirs = [] + +afterEach(() => { + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function archiveTree(filename) { + const root = mkdtempSync(join(tmpdir(), 'orcad-bun-archive-')) + temporaryDirs.push(root) + const nested = join(root, 'bun-release') + mkdirSync(nested) + writeFileSync(join(nested, filename), '') + return root +} + +describe('orcad Bun archive extraction', () => { + it('selects bun.exe for a Windows target on a non-Windows builder', () => { + const root = archiveTree('bun.exe') + expect(findBunExecutable(root, 'win32-x64')).toBe(join(root, 'bun-release', 'bun.exe')) + }) + + it('selects bun for a POSIX target', () => { + const root = archiveTree('bun') + expect(findBunExecutable(root, 'linux-x64-glibc')).toBe(join(root, 'bun-release', 'bun')) + }) + + it('derives executable names from the target rather than the builder host', () => { + expect(bunExecutableName('win32-arm64')).toBe('bun.exe') + expect(bunExecutableName('darwin-arm64')).toBe('bun') + }) +}) diff --git a/config/scripts/build-orcad-template.mjs b/config/scripts/build-orcad-template.mjs new file mode 100644 index 00000000000..a69a6ee8485 --- /dev/null +++ b/config/scripts/build-orcad-template.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto' +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_TEMPLATE_MANIFEST_FILENAME, + ORCAD_TEMPLATE_TARGETS_DIR, + ORCAD_RIPGREP_ARTIFACTS, + orcadTemplateCommonFilenames +} from '../../src/shared/orcad-artifacts.ts' +import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' +import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' +import { runProcessSync } from './script-child-process.mjs' +import { materializeWatcherPackage } from './orcad-watcher-package.mjs' +import { verifyPackagedOrcadTemplate } from './verify-packaged-orcad-template.cjs' + +const root = resolve(import.meta.dirname, '../..') +const outputDir = join(root, 'out', 'orcad-template') +const buildDir = join(root, 'out', '.orcad-template-build') +const commonArtifacts = orcadTemplateCommonFilenames() + +function copy(source, destination, executable = false) { + mkdirSync(dirname(destination), { recursive: true }) + copyFileSync(source, destination) + if (executable && process.platform !== 'win32') { + chmodSync(destination, 0o755) + } +} + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +function targetPlatform(target) { + return target.split('-')[0] +} + +function targetArch(target) { + return target.split('-')[1] +} + +function buildCommonArtifacts() { + rmSync(buildDir, { recursive: true, force: true }) + const result = runProcessSync({ + program: process.execPath, + args: [join(root, 'config/scripts/build-orcad-bun.mjs'), '--out-dir', buildDir], + cwd: root, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + throw new Error(`Common orcad artifact build failed with exit ${result.code ?? 'unknown'}`) + } +} + +async function stageTarget(target) { + const destination = join(outputDir, ORCAD_TEMPLATE_TARGETS_DIR, target) + const targetIdentity = join(destination, ORCAD_BUILD_TARGET_FILENAME) + mkdirSync(destination, { recursive: true }) + writeFileSync(targetIdentity, `${target}\n`) + const watcherSource = await materializeWatcherPackage(target) + const watcherDestination = join(destination, 'watcher.node') + copy(watcherSource, watcherDestination) + + const browserName = orcadAgentBrowserNativeName( + targetPlatform(target), + targetArch(target), + target.endsWith('-musl') ? 'musl' : 'glibc' + ) + const browserSource = join(root, 'node_modules', 'agent-browser', 'bin', browserName) + const browserDestination = join(destination, browserName) + if (existsSync(browserSource)) { + copy(browserSource, browserDestination, true) + } + return { + targetSha256: sha256(targetIdentity), + watcherSha256: sha256(watcherDestination), + ...(existsSync(browserDestination) + ? { browserName, browserSha256: sha256(browserDestination) } + : {}) + } +} + +async function main() { + buildCommonArtifacts() + rmSync(outputDir, { recursive: true, force: true }) + mkdirSync(outputDir, { recursive: true }) + for (const filename of commonArtifacts) { + copy( + join(buildDir, filename), + join(outputDir, filename), + ORCAD_RIPGREP_ARTIFACTS.some((artifact) => artifact === filename && artifact.endsWith('/rg')) + ) + } + const targets = Object.fromEntries( + await Promise.all( + ORCAD_TEMPLATE_TARGETS.map(async (target) => [target, await stageTarget(target)]) + ) + ) + const commonSha256 = Object.fromEntries( + commonArtifacts.map((filename) => [filename, sha256(join(outputDir, filename))]) + ) + writeFileSync( + join(outputDir, ORCAD_TEMPLATE_MANIFEST_FILENAME), + `${JSON.stringify({ schemaVersion: 2, commonSha256, targets }, null, 2)}\n` + ) + verifyPackagedOrcadTemplate(join(root, 'out')) + rmSync(buildDir, { recursive: true, force: true }) + process.stdout.write(`[build-orcad-template] ok — ${ORCAD_TEMPLATE_TARGETS.length} targets\n`) +} + +await main() diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 0b882689f94..b1d3430c4d1 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -1,36 +1,46 @@ #!/usr/bin/env node -/** - * Bundle `orcad` — the Orca runtime served from plain Node, no Electron. - * - * Variant B (see docs/design/node-only-runtime-backend.html): the browser-pane and - * speech clusters are excluded. That is not a size optimisation — those modules are - * the only ones that statically import `node:sqlite`, so dropping them is what keeps - * the host Node floor at 18 instead of 22.5+. - */ +// Ship Bun with orcad; keep module loading compatible with legacy Node launchers. import { fork, spawnSync } from 'node:child_process' import { build } from 'esbuild' +import { + buildOrcadEntry, + externalNativeAddons, + ORCAD_EXTERNAL_MODULES +} from './orcad-entry-build.mjs' +import { createRequire } from 'node:module' import { chmodSync, copyFileSync, cpSync, + existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { arch, platform, tmpdir } from 'node:os' -import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs' +import { materializeWatcherPackage } from './orcad-watcher-package.mjs' +import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_EMOJI_SHORTCODE_DATASET, + orcadBunRuntimeFilename, + ORCAD_PARCEL_WATCHER_ENTRY, + ORCAD_PARCEL_WATCHER_NATIVE, ORCAD_VERSION_FILENAME, ORCAD_RIPGREP_ARTIFACTS } from '../../src/shared/orcad-artifacts.ts' import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' +import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' +import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' const ROOT = join(import.meta.dirname, '..', '..') -const OUT_DIR = join(ROOT, 'out', 'orcad') -const ENTRY = join(ROOT, 'src/main/orcad/main.ts') +const OUT_DIR = process.env.ORCAD_OUT_DIR + ? resolve(process.env.ORCAD_OUT_DIR) + : join(ROOT, 'out', 'orcad') // Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher // fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath` // looks for it in the app root. A deployment has no desktop out/main to fall back to. @@ -41,46 +51,86 @@ const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') // orcad restart would SIGKILL every running terminal. const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts') const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') -const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}` +const PTY_GATE_ENTRY = join(ROOT, 'src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts') +const PTY_GATE_OUT_FILE = join(OUT_DIR, 'windows-bun-pty-gate-entry.js') const OUT_FILE = join(OUT_DIR, 'orcad.js') +const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET +if (!BUILD_TARGET) { + throw new Error('ORCAD_BUILD_TARGET is required; run `pnpm build:orcad`') +} +const [targetPlatform, targetArch] = BUILD_TARGET.split('-') +const targetIsWindows = targetPlatform === 'win32' +const targetIsCurrent = process.env.ORCAD_BUILD_TARGET_IS_CURRENT === '1' +const AGENT_BROWSER_NAME = orcadAgentBrowserNativeName( + targetPlatform, + targetArch, + BUILD_TARGET.endsWith('-musl') ? 'musl' : 'glibc' +) const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME) const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME) +const WATCHER_MODULE_DIR = join(OUT_DIR, 'node_modules', '@parcel', 'watcher') -// Native addons must exist on the host; they cannot be bundled. -// `electron` is external so a residual import fails loudly at require() time rather -// than silently bundling the npm package's installer shim, which is what happened the -// first time and made the bundle look clean while it was not. -// Why only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad -// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the -// server boots without it but every watch install fails. `fsevents` is macOS-only and -// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here -// defensively and appear nowhere in the graph; listing them implied a shipping burden -// that does not exist. -const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] - -/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */ -const jsoncParserEsm = { - name: 'jsonc-parser-esm', - setup(pluginBuild) { - pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({ - path: join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js') - })) - } -} - -/** Why: optional native deps reference prebuilt .node files that may not exist here. */ -const externalNativeAddons = { - name: 'external-native-addons', - setup(pluginBuild) { - pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true })) - } +async function stageParcelWatcher(target) { + const requireFromWatcher = createRequire( + join(ROOT, 'node_modules', '@parcel', 'watcher', 'index.js') + ) + const nativeSource = await materializeWatcherPackage(target) + const wrapperSource = requireFromWatcher.resolve('@parcel/watcher/wrapper.js') + mkdirSync(WATCHER_MODULE_DIR, { recursive: true }) + await build({ + stdin: { + contents: + `const {createWrapper}=require(${JSON.stringify(wrapperSource)});` + + `module.exports=createWrapper(require('./watcher.node'));`, + resolveDir: ROOT, + sourcefile: 'orcad-parcel-watcher-entry.js' + }, + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(OUT_DIR, ORCAD_PARCEL_WATCHER_ENTRY), + external: ['./watcher.node'], + minify: true, + sourcemap: false, + logLevel: 'error' + }) + copyFileSync(nativeSource, join(OUT_DIR, ORCAD_PARCEL_WATCHER_NATIVE)) } rmSync(OUT_DIR, { recursive: true, force: true }) mkdirSync(OUT_DIR, { recursive: true }) -copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT) -if (process.platform !== 'win32') { - chmodSync(AGENT_BROWSER_OUTPUT, 0o755) +const bunRuntimeSource = process.env.ORCAD_BUN_RUNTIME_PATH +if (!bunRuntimeSource) { + throw new Error('ORCAD_BUN_RUNTIME_PATH is required; run `pnpm build:orcad`') +} +if (targetIsCurrent) { + const version = spawnSync(bunRuntimeSource, ['--version'], { encoding: 'utf8' }) + if (version.status !== 0 || version.stdout.trim() !== ORCAD_BUN_VERSION) { + throw new Error( + `ORCAD_BUN_RUNTIME_PATH must be Bun ${ORCAD_BUN_VERSION}; got ${version.stdout.trim() || version.stderr.trim()}` + ) + } +} +const bunRuntimeOutput = join(OUT_DIR, orcadBunRuntimeFilename(BUILD_TARGET)) +copyFileSync(bunRuntimeSource, bunRuntimeOutput) +writeFileSync(join(OUT_DIR, ORCAD_BUILD_TARGET_FILENAME), `${BUILD_TARGET}\n`) +if (!targetIsWindows) { + chmodSync(bunRuntimeOutput, 0o755) +} +await stageParcelWatcher(BUILD_TARGET) +stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET) +const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET) +mkdirSync(dirname(emojiDatasetOutput), { recursive: true }) +copyFileSync( + createRequire(import.meta.url).resolve('emojibase-data/en/shortcodes/emojibase.json'), + emojiDatasetOutput +) +if (existsSync(AGENT_BROWSER_SOURCE)) { + copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT) + if (!targetIsWindows) { + chmodSync(AGENT_BROWSER_OUTPUT, 0o755) + } } // Why every platform: an SSH deployment can target a different host than the build machine. for (const artifact of ORCAD_RIPGREP_ARTIFACTS) { @@ -100,8 +150,10 @@ cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep', recursive: true }) -// Child and worker resolvers require flat entries beside orcad.js. -function buildIsolatedEntry(entryPoint, outfile) { +/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source + * directory under `outdir`, and both children must land flat beside orcad.js — that is where + * their runtime resolvers look for them. */ +function buildForkedChild(entryPoint, outfile) { return build({ entryPoints: [entryPoint], bundle: true, @@ -109,42 +161,31 @@ function buildIsolatedEntry(entryPoint, outfile) { target: 'node18', format: 'cjs', outfile, - external: EXTERNAL, + external: ORCAD_EXTERNAL_MODULES, plugins: [externalNativeAddons], metafile: true, minify: true, sourcemap: false, - define: { 'process.env.NODE_ENV': '"production"' }, + define: { + 'process.env.NODE_ENV': '"production"' + }, logLevel: 'error' }) } -const isolatedResults = await Promise.all([ - buildIsolatedEntry(WATCHER_ENTRY, WATCHER_OUT_FILE), - buildIsolatedEntry(DAEMON_ENTRY, DAEMON_OUT_FILE), +const childResults = await Promise.all([ + buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), + buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE), + buildForkedChild(PTY_GATE_ENTRY, PTY_GATE_OUT_FILE), ...['writer', 'backup'].map((role) => - buildIsolatedEntry( + buildForkedChild( join(ROOT, `src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`), join(OUT_DIR, `profile-state-${role}-worker-entry.js`) ) ) ]) -const result = await build({ - entryPoints: [ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: OUT_FILE, - external: EXTERNAL, - plugins: [jsoncParserEsm, externalNativeAddons], - metafile: true, - minify: true, - sourcemap: false, - define: { 'process.env.NODE_ENV': '"production"' }, - logLevel: 'error' -}) +const result = await buildOrcadEntry(OUT_FILE) const output = Object.values(result.metafile.outputs).find( (o) => o.entryPoint === 'src/main/orcad/main.ts' @@ -152,7 +193,9 @@ const output = Object.values(result.metafile.outputs).find( // Why check `original` and not just `path`: when electron is bundleable, esbuild // rewrites `path` to the resolved file under node_modules and the naive check passes // while the package is very much in the bundle. -// Every isolated entry ships under the same plain-Node compatibility contract. +// Why both metafiles: the forked children ship in the same deployment and runtime. A +// daemon-entry that reached electron would fail at fork time, on the +// path whose whole point is that terminals survive. function collectImporters(metafiles, matches) { const importers = new Set() for (const metafile of metafiles) { @@ -167,7 +210,7 @@ function collectImporters(metafiles, matches) { return importers } -const metafiles = [result.metafile, ...isolatedResults.map((entry) => entry.metafile)] +const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)] const electronImporters = collectImporters( metafiles, (specifier) => specifier === 'electron' || specifier.startsWith('electron/') @@ -199,7 +242,7 @@ if (graphErrors.length > 0) { process.exitCode = 1 } else { // Why smoke-load and not just read the metafile: the import scan proves no module - // *names* electron, but a graph can still fail to resolve under plain Node — a + // *names* electron, but the rollback graph can still fail to resolve under plain Node — a // dynamic require, a missing native, a top-level throw. The plain-node-entry-guard // smoke-loads its entries for exactly this reason, and orcad cannot join that guard // because it is an esbuild artifact rather than a rollup input. @@ -214,7 +257,7 @@ if (graphErrors.length > 0) { const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}` if (smoke.error || smoke.signal || smoke.status !== 0) { console.error( - `[build-orcad] the bundle did not load under plain Node.\n` + + `[build-orcad] the bundle lost Node load compatibility.\n` + `Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` + `signal=${smoke.signal ?? 'none'} ` + `error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}` @@ -243,26 +286,30 @@ if (graphErrors.length > 0) { const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}` if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) { console.error( - `[build-orcad] the daemon child did not load under plain Node.\n` + + `[build-orcad] the daemon child lost Node load compatibility.\n` + `Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` + `signal=${daemonSmoke.signal ?? 'none'} ` + `error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}` ) process.exitCode = 1 } - const watcherFailure = await smokeLoadWatcherChild() + const watcherFailure = targetIsCurrent ? await smokeLoadWatcherChild(bunRuntimeOutput) : null if (watcherFailure) { console.error( - `[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}` + `[build-orcad] the watcher child failed under the bundled runtime.\n${watcherFailure}` ) process.exitCode = 1 } - try { - await smokeProfileStateWorkers(OUT_DIR) - } catch (error) { - console.error('[build-orcad] profile state worker check failed:', error) - process.exitCode = 1 +} + +try { + await smokeProfileStateWorkers(OUT_DIR) + if (targetIsCurrent) { + await smokeProfileStateWorkers(OUT_DIR, { runtimePath: bunRuntimeOutput }) } +} catch (error) { + console.error('[build-orcad] profile state worker check failed:', error) + process.exitCode = 1 } // Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on @@ -270,26 +317,26 @@ if (graphErrors.length > 0) { // already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale // bytes while reporting the new version. if (process.exitCode !== 1) { - const fullVersion = computeOrcadFullVersion(OUT_DIR) + const fullVersion = computeOrcadFullVersion(OUT_DIR, { + target: BUILD_TARGET, + agentBrowserFilename: AGENT_BROWSER_NAME + }) writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion) console.log( - `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.` + `[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports, Bun ${ORCAD_BUN_VERSION} included.` ) } -/** - * Fork the shipped watcher child and drive one message through it. - * - * Why a real fork and not existsSync: the file being present says nothing about whether - * its graph resolves under plain Node, and this child is only ever reached through - * `fork()` at runtime — a broken one degrades silently to in-process watching. - * `subscribe-started` is acked before the native module is touched, so this passes on a - * build machine with no compiled @parcel/watcher. - */ -async function smokeLoadWatcherChild() { +// Verify the shipped native watcher actually subscribes under the bundled runtime. +async function smokeLoadWatcherChild(runtimePath) { const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-')) - const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + const child = fork(WATCHER_OUT_FILE, [], { + execPath: runtimePath, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + windowsHide: true + }) let stderr = '' + let subscribed = false child.stderr?.on('data', (chunk) => { stderr += String(chunk) }) @@ -297,16 +344,14 @@ async function smokeLoadWatcherChild() { return await new Promise((resolve) => { const timer = setTimeout(() => { child.kill('SIGKILL') - resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`) + resolve(`Watcher did not complete its subscription within 30s.\n${stderr.slice(0, 2000)}`) }, 30_000) const settle = (failure) => { clearTimeout(timer) resolve(failure) } child.on('message', (message) => { - // Wait until the subscribe lifecycle has sent its final acknowledgement. - // Disconnecting on subscribe-started races the subsequent subscribed or - // subscribe-failed message and makes the child report an expected EPIPE. + subscribed ||= message?.op === 'subscribed' if (message?.op === 'subscribed' || message?.op === 'subscribe-failed') { child.disconnect() } @@ -315,7 +360,11 @@ async function smokeLoadWatcherChild() { // Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code // or a signal here is a load failure rather than a clean teardown. child.on('exit', (code, signal) => - settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`) + settle( + code === 0 && subscribed + ? null + : `subscribed=${subscribed} exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}` + ) ) child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} }) }) diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index 77f45f62113..f74c1c09e01 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -527,6 +527,8 @@ describe('packaged runtime resources', () => { ) }) +const BUN_RUNTIME_BUILTINS = new Set(['bun:ffi', 'bun:sqlite']) + // Why source-anchored: the bundler renames a createRequire()'d require, so // verifyPackagedMainRuntimeDeps' `require("x")` scan cannot see these specifiers — packaging // stays green while the packaged app throws MODULE_NOT_FOUND the first time the path runs. @@ -555,7 +557,7 @@ function collectLazyRequireSpecifiers(directory, found = new Map()) { continue } for (const match of source.matchAll(/\brequire[A-Za-z0-9_]*\(\s*'([^']+)'\s*\)/g)) { - if (isPackagedExternalSpecifier(match[1])) { + if (!BUN_RUNTIME_BUILTINS.has(match[1]) && isPackagedExternalSpecifier(match[1])) { found.set(match[1], relative(projectRoot, entryPath).replaceAll('\\', '/')) } } @@ -572,6 +574,26 @@ function packagedResourceDestinations(platform) { } describe('lazily required packages reach Resources/node_modules', () => { + it('excludes Bun runtime builtins while retaining ordinary lazy dependencies', async () => { + const sourceDir = await mkdtemp(join(tmpdir(), 'orca-lazy-bun-builtins-')) + try { + await writeFile( + join(sourceDir, 'runtime.ts'), + [ + 'const requireFromMain = createRequire(import.meta.url)', + "requireFromMain('node:fs')", + "requireFromMain('bun:ffi')", + "requireFromMain('bun:sqlite')", + "requireFromMain('zod')", + "requireFromMain('bun-sqlite')" + ].join('\n') + ) + expect([...collectLazyRequireSpecifiers(sourceDir).keys()]).toEqual(['zod', 'bun-sqlite']) + } finally { + await removeTree(sourceDir) + } + }) + it('copies every createRequire specifier main uses into the packaged resource plan', () => { const specifiers = collectLazyRequireSpecifiers(join(projectRoot, 'src', 'main')) expect(specifiers.size).toBeGreaterThan(0) diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs index 63f575f1d9a..a9d2bd8c000 100644 --- a/config/scripts/install-electron-package-binary.mjs +++ b/config/scripts/install-electron-package-binary.mjs @@ -16,6 +16,7 @@ import { createRequire } from 'node:module' import { platform as osPlatform, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { getElectronPlatformPath } from './electron-platform-path.mjs' +import { getZipExtractorCommand } from './zip-extractor-command.mjs' import { shareElectronDistFromCache, hasAdoptedSharedElectronDist, @@ -448,33 +449,7 @@ function getExtractorCommand(zipPath, extractDir) { } } - if (osPlatform() === 'win32') { - return { - file: process.env.ORCA_POWERSHELL_BIN || 'powershell', - args: [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-Command', - [ - "$ErrorActionPreference = 'Stop'", - `Expand-Archive -LiteralPath ${quotePowerShellLiteral(zipPath)} -DestinationPath ${quotePowerShellLiteral(extractDir)} -Force` - ].join('; ') - ], - label: 'powershell Expand-Archive' - } - } - - return { - file: process.env.ORCA_UNZIP_BIN || 'unzip', - args: ['-q', zipPath, '-d', extractDir], - label: 'unzip' - } -} - -function quotePowerShellLiteral(value) { - return `'${String(value).replaceAll("'", "''")}'` + return getZipExtractorCommand(zipPath, extractDir) } function formatExtractorFailure(command, result) { diff --git a/config/scripts/mobile-web-app-render-harness.mjs b/config/scripts/mobile-web-app-render-harness.mjs index ae38a2ef471..b7a43815a1e 100644 --- a/config/scripts/mobile-web-app-render-harness.mjs +++ b/config/scripts/mobile-web-app-render-harness.mjs @@ -631,8 +631,27 @@ export function installPageErrorSentinel() { * say that there was something to leak before it says that nothing did. */ export function installSchedulerRecorder() { - globalThis.__orcaScheduler = { watching: false, scheduled: [], leaked: [] } + globalThis.__orcaScheduler = { watching: false, scheduled: [], leaked: [], heldFrames: 0 } const state = globalThis.__orcaScheduler + const requestFrame = globalThis.requestAnimationFrame.bind(globalThis) + const cancelFrame = globalThis.cancelAnimationFrame.bind(globalThis) + const heldFrames = new Map() + let nextHeldFrame = -2 + globalThis.__orcaReleaseFrames = () => { + state.holdFramesFrom = null + for (const callback of heldFrames.values()) { + requestFrame(callback) + } + heldFrames.clear() + state.heldFrames = 0 + } + globalThis.cancelAnimationFrame = (id) => { + if (heldFrames.delete(id)) { + state.heldFrames-- + } else { + cancelFrame(id) + } + } const wrap = (schedule, kind) => function (callback, ...rest) { if (!state.watching || typeof callback !== 'function') { @@ -646,21 +665,22 @@ export function installSchedulerRecorder() { // it was cancelled or is merely waiting, and cancelling never sets it. const entry = { kind, caller, owned: container !== null, fired: false } state.scheduled.push(entry) - return schedule( - (...args) => { - entry.fired = true - if (container !== null && !container.isConnected) { - state.leaked.push(`${kind} from ${caller}`) - } - return callback(...args) - }, - ...rest - ) + const recorded = (...args) => { + entry.fired = true + if (container !== null && !container.isConnected) { + state.leaked.push(`${kind} from ${caller}`) + } + return callback(...args) + } + if (kind === 'frame' && state.holdFramesFrom && caller.includes(state.holdFramesFrom)) { + const id = nextHeldFrame-- + heldFrames.set(id, recorded) + state.heldFrames++ + return id + } + return schedule(recorded, ...rest) } - globalThis.requestAnimationFrame = wrap( - globalThis.requestAnimationFrame.bind(globalThis), - 'frame' - ) + globalThis.requestAnimationFrame = wrap(requestFrame, 'frame') globalThis.setTimeout = wrap(globalThis.setTimeout.bind(globalThis), 'timer') globalThis.setInterval = wrap(globalThis.setInterval.bind(globalThis), 'interval') } diff --git a/config/scripts/mobile-web-app-rich-markdown-render.test.mjs b/config/scripts/mobile-web-app-rich-markdown-render.test.mjs index b9c93dbc5c9..d1133bce143 100644 --- a/config/scripts/mobile-web-app-rich-markdown-render.test.mjs +++ b/config/scripts/mobile-web-app-rich-markdown-render.test.mjs @@ -583,13 +583,16 @@ describeEditor( { timeout: 15_000 } ) } - // The inserted image painted: `naturalWidth` is 0 for an element the browser refused - // or never fetched, which is what a policy that did not admit it would leave. - expect( - await page.evaluate( - () => document.querySelector('#first-surface #editor img')?.naturalWidth ?? 0 + // Insertion precedes image loading; a refused image must still fail this paint check. + await expect + .poll( + () => + page.evaluate( + () => document.querySelector('#first-surface #editor img')?.naturalWidth ?? 0 + ), + { timeout: 15_000 } ) - ).toBeGreaterThan(0) + .toBeGreaterThan(0) expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([]) expect(consoleErrors).toEqual([]) } finally { diff --git a/config/scripts/mobile-web-app-terminal-render.test.mjs b/config/scripts/mobile-web-app-terminal-render.test.mjs index 682384f6b4f..4adebf33132 100644 --- a/config/scripts/mobile-web-app-terminal-render.test.mjs +++ b/config/scripts/mobile-web-app-terminal-render.test.mjs @@ -453,31 +453,7 @@ describeRender( }, 300_000) it('takes back the frames it is owed, not only the timers', async () => { - // The timer case above is witnessed by a 550 ms timeout, which every module's own stop - // cancels by the handle the scope holds. A frame is the other shape: `applyFitScale` asks - // for one through the scope's registry and never holds its id, so `stopFitScale` can only - // bump the token it tests itself against — the frame still runs. Nothing but - // `cancelDocumentFrames` takes it back. - // - // Two things have to be pinned down for that to be readable, and the first version of this - // case had neither. - // - // The witness has to be owed whenever the dispose lands. A single refit is not: the retry - // loop commits on its first attempt whenever the grid still measures, so one resize buys - // one frame and a dispose after it owes nothing — which agrees with an empty leak list for - // exactly the reason under test, once in five runs. So the refit is re-armed from a frame - // of the test's own, which leaves the document owed a frame at the end of every frame the - // browser serves, and dispose cannot land inside one. - // - // And the leak has to be counted from the moment dispose returned, not from the moment the - // host element left the DOM. React unmounts in two steps: the mutation phase detaches the - // host, and the passive cleanup that calls `dispose` runs after it — 1 ms apart here, 20 to - // 35 ms apart with the CPU throttled 20x, which is the CI runner this failed on. A frame - // served in that gap runs with a detached container while the document is still live and - // has not been asked to stop, and no registry could take it back. It went through - // `scheduleDocumentFrame` like every other; the old oracle called it a leak because it - // judged by the container rather than by dispose. Only what runs after the last statement - // of `dispose` is the document keeping something it gave up. + // Hold a real refit frame across disposal; ResizeObserver delivery cannot race the witness. let documentChunk = null const { page } = await openPage(PROBE_ROUTE, { scheduler: true, @@ -492,72 +468,74 @@ describeRender( }) } }) - await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { - timeout: 60_000, - polling: 100 - }) - await openProbeTerminal(page) - expect(documentChunk, 'the document was served as its own chunk').not.toBe(null) - - await page.evaluate((chunk) => { - const state = globalThis.__orcaScheduler - state.disposed = null - state.watching = true - // `dispose` empties the host and drops its class last, after `cancelDocumentFrames`, so - // the class going is the moment it returned. Observed on the element rather than on the - // tree because React may have detached it already. - const host = document.querySelector('.orca-terminal-document-host') - const observer = new MutationObserver(() => { - if (state.disposed !== null || host.classList.contains('orca-terminal-document-host')) { - return - } - state.disposed = { - // A cancelled frame never runs, so it is still owed here. That is the point. - owed: state.scheduled.filter( - (entry) => entry.kind === 'frame' && !entry.fired && entry.caller.includes(chunk) - ).length, - leakedBefore: state.leaked.length - } - observer.disconnect() + try { + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 }) - observer.observe(host, { attributes: true, attributeFilter: ['class'] }) - // The page's refit follows the host's box, not the window, so the pulse resizes the host. - let narrow = false - const pulse = () => { - if (state.disposed !== null) { - return - } - narrow = !narrow - host.style.width = narrow ? '99%' : '' - requestAnimationFrame(pulse) - } - requestAnimationFrame(pulse) - globalThis.setTimeout(() => globalThis.__orcaTerminalProbe.setMounted(false), 200) - }, documentChunk) - await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) - await page.evaluate(() => { - globalThis.__orcaTerminalReady = false - globalThis.__orcaTerminalProbe.setMounted(true) - }) - await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { - timeout: 60_000, - polling: 100 - }) - await openProbeTerminal(page) - await page.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 3000))) + await openProbeTerminal(page) + expect(documentChunk, 'the document was served as its own chunk').not.toBe(null) - const scheduler = await page.evaluate(() => globalThis.__orcaScheduler) - expect( - scheduler.disposed?.owed, - 'the document owed a frame at the moment dispose returned' - ).toBeGreaterThan(0) - expect( - scheduler.leaked - .slice(scheduler.disposed.leakedBefore) - .filter((entry) => entry.startsWith('frame ') && entry.includes(documentChunk)) - ).toEqual([]) - await page.unrouteAll({ behavior: 'ignoreErrors' }) - await page.close() + await page.evaluate((chunk) => { + const state = globalThis.__orcaScheduler + state.disposed = null + state.watching = true + state.holdFramesFrom = chunk + const host = document.querySelector('.orca-terminal-document-host') + // Dispose drops this class after cancelling frames; DOM detachment precedes cleanup. + const observer = new MutationObserver(() => { + if (state.disposed !== null || host.classList.contains('orca-terminal-document-host')) { + return + } + state.disposed = { + owed: state.scheduled.filter( + (entry) => entry.kind === 'frame' && !entry.fired && entry.caller.includes(chunk) + ).length, + leakedBefore: state.leaked.length + } + observer.disconnect() + }) + observer.observe(host, { attributes: true, attributeFilter: ['class'] }) + host.style.width = '80%' + }, documentChunk) + await page.waitForFunction(() => globalThis.__orcaScheduler.heldFrames > 0, undefined, { + timeout: 30_000 + }) + await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false)) + await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 }) + await page.waitForFunction(() => globalThis.__orcaScheduler.disposed !== null) + await page.evaluate(() => { + globalThis.__orcaScheduler.holdFramesFrom = null + globalThis.__orcaTerminalReady = false + globalThis.__orcaTerminalProbe.setMounted(true) + }) + await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, { + timeout: 60_000, + polling: 100 + }) + await openProbeTerminal(page) + // Uncancelled work must actually run against the replacement, so the hold cannot hide leaks. + await page.evaluate( + () => + new Promise((resolve) => { + globalThis.__orcaReleaseFrames() + requestAnimationFrame(() => requestAnimationFrame(resolve)) + }) + ) + + const scheduler = await page.evaluate(() => globalThis.__orcaScheduler) + expect( + scheduler.disposed?.owed, + 'the document owed a frame at the moment dispose returned' + ).toBeGreaterThan(0) + expect( + scheduler.leaked + .slice(scheduler.disposed.leakedBefore) + .filter((entry) => entry.startsWith('frame ') && entry.includes(documentChunk)) + ).toEqual([]) + } finally { + await page.unrouteAll({ behavior: 'ignoreErrors' }).finally(() => page.close()) + } }, 300_000) it('styles what it owns, and only that', async () => { diff --git a/config/scripts/orcad-artifact-version.mjs b/config/scripts/orcad-artifact-version.mjs index ef85540bbaa..e7f2284f59f 100644 --- a/config/scripts/orcad-artifact-version.mjs +++ b/config/scripts/orcad-artifact-version.mjs @@ -1,11 +1,15 @@ import { createHash } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { ORCAD_VERSION, orcadArtifactFilenames } from '../../src/shared/orcad-artifacts.ts' +import { + ORCAD_VERSION, + orcadArtifactFilenames, + orcadArtifactHashPrefix +} from '../../src/shared/orcad-artifacts.ts' -export function computeOrcadFullVersion(artifactDir) { - const hash = createHash('sha256') - for (const filename of orcadArtifactFilenames()) { +export function computeOrcadFullVersion(artifactDir, { target = '', agentBrowserFilename } = {}) { + const hash = createHash('sha256').update(orcadArtifactHashPrefix(target)) + for (const filename of orcadArtifactFilenames(target)) { const artifactPath = join(artifactDir, filename) if (!existsSync(artifactPath)) { throw new Error( @@ -15,5 +19,8 @@ export function computeOrcadFullVersion(artifactDir) { } hash.update(readFileSync(artifactPath)) } + if (agentBrowserFilename && existsSync(join(artifactDir, agentBrowserFilename))) { + hash.update(readFileSync(join(artifactDir, agentBrowserFilename))) + } return `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}` } diff --git a/config/scripts/orcad-artifact-version.test.mjs b/config/scripts/orcad-artifact-version.test.mjs index a6d316b7f41..01c1ae3f8ff 100644 --- a/config/scripts/orcad-artifact-version.test.mjs +++ b/config/scripts/orcad-artifact-version.test.mjs @@ -1,30 +1,67 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { + ORCAD_BUILD_TARGET_FILENAME, ORCAD_RIPGREP_ARTIFACTS, orcadArtifactFilenames } from '../../src/shared/orcad-artifacts.ts' +import { ORCAD_BUN_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' +import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' +import { readOrcadArtifactIdentity } from '../../src/main/orcad/orcad-artifact-identity.ts' import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' +const directories = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function createArtifactDirectory(target = '') { + const directory = mkdtempSync(join(tmpdir(), 'orcad-version-')) + directories.push(directory) + for (const filename of orcadArtifactFilenames(target)) { + const path = join(directory, filename) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, filename) + } + writeFileSync(join(directory, ORCAD_BUILD_TARGET_FILENAME), `${target}\n`) + return directory +} + describe('standalone runtime version', () => { it('changes when a shipped search binary changes and rejects a missing binary', () => { - const dir = mkdtempSync(join(tmpdir(), 'orcad-version-')) - try { - for (const filename of orcadArtifactFilenames()) { - const path = join(dir, filename) - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, filename) - } - const before = computeOrcadFullVersion(dir) - const binary = join(dir, ORCAD_RIPGREP_ARTIFACTS[0]) - writeFileSync(binary, 'updated binary') - expect(computeOrcadFullVersion(dir)).not.toBe(before) - rmSync(binary) - expect(() => computeOrcadFullVersion(dir)).toThrow(ORCAD_RIPGREP_ARTIFACTS[0]) - } finally { - rmSync(dir, { recursive: true, force: true }) - } + const dir = createArtifactDirectory() + const before = computeOrcadFullVersion(dir) + const binary = join(dir, ORCAD_RIPGREP_ARTIFACTS[0]) + writeFileSync(binary, 'updated binary') + expect(computeOrcadFullVersion(dir)).not.toBe(before) + rmSync(binary) + expect(() => computeOrcadFullVersion(dir)).toThrow(ORCAD_RIPGREP_ARTIFACTS[0]) }) + + it.each(ORCAD_BUN_TARGETS)( + 'matches the installed %s identity with and without its optional browser', + async (target) => { + const dir = createArtifactDirectory(target) + const [platform, arch] = target.split('-') + const agentBrowserFilename = orcadAgentBrowserNativeName( + platform, + arch, + target.endsWith('-musl') ? 'musl' : 'glibc' + ) + const options = { target, agentBrowserFilename } + const withoutBrowser = computeOrcadFullVersion(dir, options) + expect(withoutBrowser).toBe(await readOrcadArtifactIdentity(dir)) + writeFileSync(join(dir, agentBrowserFilename), 'browser') + const withBrowser = computeOrcadFullVersion(dir, options) + expect(withBrowser).not.toBe(withoutBrowser) + expect(withBrowser).toBe(await readOrcadArtifactIdentity(dir)) + writeFileSync(join(dir, agentBrowserFilename), 'updated-browser') + expect(computeOrcadFullVersion(dir, options)).not.toBe(withBrowser) + expect(computeOrcadFullVersion(dir, options)).toBe(await readOrcadArtifactIdentity(dir)) + } + ) }) diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs new file mode 100644 index 00000000000..3ad6f841910 --- /dev/null +++ b/config/scripts/orcad-entry-build.mjs @@ -0,0 +1,49 @@ +import { build } from 'esbuild' +import { join } from 'node:path' + +const root = join(import.meta.dirname, '..', '..') + +export const ORCAD_EXTERNAL_MODULES = [ + 'electron', + 'node-pty', + '@parcel/watcher', + 'fsevents', + 'bun:ffi', + 'bun:sqlite' +] + +// Native binaries are staged separately from every JavaScript entry. +export const externalNativeAddons = { + name: 'external-native-addons', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true })) + } +} + +// The UMD build's relative dynamic requires cannot be bundled. +const jsoncParserEsm = { + name: 'jsonc-parser-esm', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({ + path: join(root, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js') + })) + } +} + +export function buildOrcadEntry(outfile) { + return build({ + entryPoints: [join(root, 'src/main/orcad/main.ts')], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile, + external: ORCAD_EXTERNAL_MODULES, + plugins: [jsoncParserEsm, externalNativeAddons], + metafile: true, + minify: true, + sourcemap: false, + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'error' + }) +} diff --git a/config/scripts/orcad-template-test-fixture.mjs b/config/scripts/orcad-template-test-fixture.mjs new file mode 100644 index 00000000000..2f11a32e83f --- /dev/null +++ b/config/scripts/orcad-template-test-fixture.mjs @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_TEMPLATE_MANIFEST_FILENAME, + ORCAD_TEMPLATE_TARGETS_DIR, + orcadTemplateCommonFilenames +} from '../../src/shared/orcad-artifacts.ts' +import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' + +async function write(path, contents) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, contents) + return createHash('sha256').update(contents).digest('hex') +} + +export async function writeOrcadTemplateTestFixture(resourcesDir) { + const templateDir = join(resourcesDir, 'orcad-template') + const commonFilenames = orcadTemplateCommonFilenames() + const commonSha256 = {} + for (const filename of commonFilenames) { + commonSha256[filename] = await write( + join(templateDir, ...filename.split('/')), + Buffer.from(`common:${filename}`) + ) + } + const targets = {} + for (const target of ORCAD_TEMPLATE_TARGETS) { + const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) + targets[target] = { + targetSha256: await write(join(targetDir, ORCAD_BUILD_TARGET_FILENAME), `${target}\n`), + watcherSha256: await write(join(targetDir, 'watcher.node'), `watcher:${target}`) + } + } + const browserName = 'agent-browser-linux-x64' + targets['linux-x64-glibc'] = { + ...targets['linux-x64-glibc'], + browserName, + browserSha256: await write( + join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, 'linux-x64-glibc', browserName), + 'browser' + ) + } + await writeFile( + join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME), + JSON.stringify({ schemaVersion: 2, commonSha256, targets }) + ) + return templateDir +} diff --git a/config/scripts/orcad-watcher-package.mjs b/config/scripts/orcad-watcher-package.mjs new file mode 100644 index 00000000000..5107d23fe58 --- /dev/null +++ b/config/scripts/orcad-watcher-package.mjs @@ -0,0 +1,86 @@ +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { x as extractTar } from 'tar' +import { parseAllDocuments } from 'yaml' +import { ORCAD_BUN_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' + +const root = resolve(import.meta.dirname, '../..') +const require = createRequire(import.meta.url) +const archiveLimit = 16 * 1024 * 1024 + +export function parseWatcherLockfile(contents) { + const packages = {} + for (const document of parseAllDocuments(contents)) { + if (document.errors.length) { + throw document.errors[0] + } + Object.assign(packages, document.toJS()?.packages) + } + return { packages } +} + +export function watcherPackageIdentity(target, version, lockfile) { + if (!ORCAD_BUN_TARGETS.includes(target)) { + throw new Error(`Unsupported watcher target: ${target}`) + } + const name = `@parcel/watcher-${target}` + const integrity = lockfile.packages?.[`${name}@${version}`]?.resolution?.integrity + if (typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+=*$/.test(integrity)) { + throw new Error(`The lockfile does not pin ${name}@${version}`) + } + return { + integrity, + url: `https://registry.npmjs.org/${name}/-/watcher-${target}-${version}.tgz` + } +} + +export function verifyWatcherArchive(bytes, integrity) { + if (bytes.length > archiveLimit) { + throw new Error('Watcher archive exceeds the size limit') + } + if (`sha512-${createHash('sha512').update(bytes).digest('base64')}` !== integrity) { + throw new Error('Watcher archive does not match the lockfile integrity') + } +} + +// Fetch only these small release assets; ordinary installs remain host-only. +export async function materializeWatcherPackage(target) { + const { version } = require('@parcel/watcher/package.json') + const lockfile = parseWatcherLockfile(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) + const { integrity, url } = watcherPackageIdentity(target, version, lockfile) + const cache = join(root, 'out', '.orcad-watchers', version, target) + const archivePath = join(cache, 'package.tgz') + await mkdir(cache, { recursive: true }) + let bytes + try { + bytes = await readFile(archivePath) + verifyWatcherArchive(bytes, integrity) + } catch { + const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }) + if (!response.ok || !response.body) { + await response.body?.cancel() + throw new Error(`Watcher download failed: ${response.status} ${response.statusText}`) + } + const chunks = [] + let length = 0 + for await (const chunk of response.body) { + length += chunk.length + if (length > archiveLimit) { + throw new Error('Watcher archive exceeds the size limit') + } + chunks.push(chunk) + } + bytes = Buffer.concat(chunks) + verifyWatcherArchive(bytes, integrity) + await writeFile(archivePath, bytes) + } + await extractTar({ + file: archivePath, + cwd: cache, + strict: true, + filter: (path, entry) => path === 'package/watcher.node' && entry.type === 'File' + }) + return join(cache, 'package', 'watcher.node') +} diff --git a/config/scripts/orcad-watcher-package.test.mjs b/config/scripts/orcad-watcher-package.test.mjs new file mode 100644 index 00000000000..9baaa71dc7e --- /dev/null +++ b/config/scripts/orcad-watcher-package.test.mjs @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + parseWatcherLockfile, + verifyWatcherArchive, + watcherPackageIdentity +} from './orcad-watcher-package.mjs' + +describe('locked watcher release assets', () => { + const archive = Buffer.from('archive') + const integrity = `sha512-${createHash('sha512').update(archive).digest('base64')}` + + it('reads dependency pins after the package-manager document in pnpm 12 lockfiles', () => { + const lockfile = parseWatcherLockfile( + `---\npackages: {}\n---\npackages:\n '@parcel/watcher-linux-x64-glibc@2.5.6':\n resolution:\n integrity: ${integrity}\n` + ) + expect(watcherPackageIdentity('linux-x64-glibc', '2.5.6', lockfile).integrity).toBe(integrity) + }) + + it('resolves a target using the exact installed wrapper version and locked integrity', () => { + const lockfile = { + packages: { '@parcel/watcher-linux-x64-musl@2.5.6': { resolution: { integrity } } } + } + expect(watcherPackageIdentity('linux-x64-musl', '2.5.6', lockfile)).toEqual({ + integrity, + url: 'https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz' + }) + expect(() => watcherPackageIdentity('linux-x64-glibc', '2.5.6', lockfile)).toThrow('lockfile') + expect(() => watcherPackageIdentity('linux-x64-musl', '2.5.7', lockfile)).toThrow('lockfile') + }) + + it('rejects missing, unknown and corrupted inputs before extraction', () => { + expect(() => watcherPackageIdentity('../x64', '2.5.6', {})).toThrow('Unsupported') + expect(() => verifyWatcherArchive(archive, integrity)).not.toThrow() + expect(() => verifyWatcherArchive(Buffer.from('tampered'), integrity)).toThrow('integrity') + expect(() => verifyWatcherArchive(Buffer.alloc(16 * 1024 * 1024 + 1), integrity)).toThrow( + 'size limit' + ) + }) +}) diff --git a/config/scripts/orcad-windows-process-tree.mjs b/config/scripts/orcad-windows-process-tree.mjs new file mode 100644 index 00000000000..af1b3ceceec --- /dev/null +++ b/config/scripts/orcad-windows-process-tree.mjs @@ -0,0 +1,48 @@ +import { copyFileSync, lstatSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { ORCAD_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/orcad-artifacts.ts' +import { + inspectWindowsProcessTreeAddon, + windowsProcessTreeAddonPath +} from './windows-process-tree-gyp-rebuild.mjs' + +const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)( + './windows-pe-machine.cjs' +) + +export function stageOrcadWindowsProcessTree( + root, + outputDir, + target, + host = { platform: process.platform, arch: process.arch } +) { + if (!target.startsWith('win32-')) { + return + } + const arch = target.slice('win32-'.length) + let source = join( + root, + '.build', + 'windows-process-tree', + arch, + ORCAD_WINDOWS_PROCESS_TREE_FILENAME + ) + // Ordinary Windows installs already compile this N-API addon for the host. + if (target === `${host.platform}-${host.arch}` && !lstatSync(source, { throwIfNoEntry: false })) { + source = windowsProcessTreeAddonPath( + join(root, 'node_modules', '@vscode', 'windows-process-tree') + ) + } + if (inspectWindowsProcessTreeAddon(source) !== 'clean') { + throw new Error( + `Orcad ${target} requires a patched process reader. On Windows, run: ` + + `node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${arch}` + ) + } + const machine = readPeMachine(source) + if (machine !== PE_MACHINE[arch]) { + throw new Error(`Orcad ${target} process reader has ${describePeMachine(machine)}`) + } + copyFileSync(source, join(outputDir, ORCAD_WINDOWS_PROCESS_TREE_FILENAME)) +} diff --git a/config/scripts/orcad-windows-process-tree.test.mjs b/config/scripts/orcad-windows-process-tree.test.mjs new file mode 100644 index 00000000000..5e3db4ee377 --- /dev/null +++ b/config/scripts/orcad-windows-process-tree.test.mjs @@ -0,0 +1,106 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs' +import { windowsProcessTreeAddonPath } from './windows-process-tree-gyp-rebuild.mjs' + +const windowsHost = { platform: 'win32', arch: 'x64' } + +const roots = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function fixture(machine = 0x8664, suffix = '') { + const root = mkdtempSync(join(tmpdir(), 'orcad-process-reader-')) + roots.push(root) + const source = join(root, '.build/windows-process-tree/x64') + const output = join(root, 'output') + mkdirSync(source, { recursive: true }) + mkdirSync(output) + const bytes = Buffer.alloc(0x90) + bytes.write('MZ') + bytes.writeUInt32LE(0x80, 0x3c) + bytes.write('PE\0\0', 0x80) + bytes.writeUInt16LE(machine, 0x84) + const file = join(source, 'windows-process-tree.node') + writeFileSync(file, Buffer.concat([bytes, Buffer.from(suffix)])) + return { root, output, file } +} + +function installedFixture(machine = 0x8664, suffix = '') { + const prepared = fixture(machine, suffix) + const packageDir = join(prepared.root, 'node_modules', '@vscode', 'windows-process-tree') + mkdirSync(join(packageDir, 'build', 'Release'), { recursive: true }) + const installed = windowsProcessTreeAddonPath(packageDir) + writeFileSync(installed, readFileSync(prepared.file)) + rmSync(prepared.file) + return { ...prepared, installed } +} + +it('stages only a clean reader for the requested machine', () => { + const { root, output, file } = fixture() + stageOrcadWindowsProcessTree(root, output, 'win32-x64') + expect(readFileSync(join(output, 'windows-process-tree.node'))).toEqual(readFileSync(file)) +}) + +it('rejects an unpatched reader even when its architecture matches', () => { + const { root, output } = fixture(0x8664, 'ReadProcessMemory') + expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64')).toThrow( + 'patched process reader' + ) +}) + +it('rejects wrong architecture and absent artifacts', () => { + const { root, output } = fixture(0xaa64) + expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64')).toThrow('machine 0xaa64') + expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-arm64')).toThrow( + 'patched process reader' + ) +}) + +it('keeps POSIX builds independent of Windows build tools', () => { + expect(() => stageOrcadWindowsProcessTree('absent', 'absent', 'linux-x64-glibc')).not.toThrow() +}) + +it('reuses the checked native addon from an ordinary Windows host install', () => { + const { root, output, installed } = installedFixture() + stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost) + expect(readFileSync(join(output, 'windows-process-tree.node'))).toEqual(readFileSync(installed)) +}) + +it.each([ + { platform: 'darwin', arch: 'x64' }, + { platform: 'win32', arch: 'arm64' } +])('does not reuse host installation for a different target: %j', (host) => { + const { root, output } = installedFixture() + expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64', host)).toThrow( + 'patched process reader' + ) +}) + +it('requires the installed fallback to have the matching architecture and patch', () => { + const { root, output, installed } = installedFixture(0xaa64) + expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost)).toThrow( + 'machine 0xaa64' + ) + writeFileSync(installed, 'ReadProcessMemory') + expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost)).toThrow( + 'patched process reader' + ) + rmSync(installed) + expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost)).toThrow( + 'patched process reader' + ) +}) + +it('prefers explicit architecture builds and refuses to mask a stale one', () => { + const { root, output, file, installed } = installedFixture() + const staged = Buffer.concat([readFileSync(installed), Buffer.from('staged')]) + writeFileSync(file, staged) + stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost) + expect(readFileSync(join(output, 'windows-process-tree.node'))).toEqual(staged) + writeFileSync(file, 'ReadProcessMemory') + expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost)).toThrow( + 'patched process reader' + ) +}) diff --git a/config/scripts/profile-state-worker-smoke.mjs b/config/scripts/profile-state-worker-smoke.mjs index a866c9da554..81ff1b5eaf1 100644 --- a/config/scripts/profile-state-worker-smoke.mjs +++ b/config/scripts/profile-state-worker-smoke.mjs @@ -1,10 +1,17 @@ import { deepStrictEqual } from 'node:assert' +import { randomUUID } from 'node:crypto' import { build } from 'esbuild' import { mkdtempSync, rmSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { Worker } from 'node:worker_threads' +import { runProcessSync } from './script-child-process.mjs' +import { + ORCAD_PROFILE_PREFLIGHT_FLAG, + parseOrcadProfilePreflight +} from '../../src/shared/orcad-profile-preflight.ts' +import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' async function initializeFixture(directory, databasePath, profileId) { const fixture = join(directory, 'initialize.cjs') @@ -76,7 +83,22 @@ function runWorker(entry, workerData, steps, timeoutMs) { } /** Exercise the shipped entries and copied state before publishing their content version. */ -export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000 } = {}) { +export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000, runtimePath } = {}) { + if (runtimePath) { + const nonce = randomUUID() + const result = runProcessSync({ + program: runtimePath, + args: [join(outDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs, + maxOutputBytes: 64 * 1024 + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error(`Packaged profile runtime preflight failed: ${result.stderr}`) + } + parseOrcadProfilePreflight(result.stdout, nonce, ORCAD_BUN_VERSION) + return + } const directory = mkdtempSync(join(tmpdir(), 'orca-profile-worker-smoke-')) const databasePath = join(directory, 'profile.db') const targetPath = join(directory, 'backup.db') diff --git a/config/scripts/run-bun-profile-tests.mjs b/config/scripts/run-bun-profile-tests.mjs new file mode 100644 index 00000000000..8d0388d132b --- /dev/null +++ b/config/scripts/run-bun-profile-tests.mjs @@ -0,0 +1,96 @@ +import { join, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { + ORCAD_VERSION_FILENAME, + orcadBunRuntimeFilename +} from '../../src/shared/orcad-artifacts.ts' +import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts' +import { + ORCAD_PROFILE_PREFLIGHT_FLAG, + parseOrcadProfilePreflight +} from '../../src/shared/orcad-profile-preflight.ts' +import { currentTarget } from './build-orcad-bun.mjs' +import { runProcessSync } from './script-child-process.mjs' + +const root = resolve(import.meta.dirname, '../..') +const target = currentTarget() +const artifact = process.argv.includes('--artifact') +const testArgs = process.argv.slice(2).filter((arg) => arg !== '--artifact') +const runtimeDir = artifact + ? join(root, 'out', 'orcad') + : join(root, 'out', '.bun-profile-test-runtime', target) +const runtimePath = join(runtimeDir, orcadBunRuntimeFilename(target)) +const env = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', BUN_EXECUTABLE: runtimePath } + +function run(program, args) { + const result = runProcessSync({ + program, + args, + cwd: root, + env, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + process.exit(result.code ?? 1) + } +} + +if (artifact) { + const nonce = randomUUID() + const result = runProcessSync({ + program: runtimePath, + args: [join(runtimeDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], + cwd: root, + env, + timeoutMs: 90_000 + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error(`Bundled runtime readiness failed: ${result.stderr}`) + } + const response = parseOrcadProfilePreflight( + result.stdout, + nonce, + ORCAD_BUN_VERSION, + readFileSync(join(runtimeDir, ORCAD_VERSION_FILENAME), 'utf8').trim() + ) + process.stdout.write(`${JSON.stringify({ target, ...response })}\n`) +} else { + run(process.execPath, [ + join(root, 'config/scripts/build-orcad-bun.mjs'), + '--runtime-only', + '--out-dir', + runtimeDir + ]) +} +run(runtimePath, [ + join(root, 'node_modules/vitest/vitest.mjs'), + 'run', + '--config', + 'config/vitest.config.ts', + ...(testArgs.length > 0 + ? testArgs + : [ + 'src/main/persistence/profile-state', + 'src/main/persistence/loading-store/profile-state', + 'src/main/sqlite', + 'src/main/orcad/orcad-entry.test.ts', + 'src/main/orcad/orcad-push-startup.test.ts', + ...(artifact + ? [ + 'src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts', + 'src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts', + 'src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts', + 'src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts', + 'src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts', + 'tests/e2e/daemon-running-work-probe.unit.test.ts', + 'src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts', + 'src/main/providers/local-pty-bun-artifact.integration.test.ts', + 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', + 'src/main/orcad/orcad-bun-launcher.integration.test.ts', + 'config/scripts/zip-extractor-command.test.mjs' + ] + : []) + ]) +]) diff --git a/config/scripts/script-child-process.mjs b/config/scripts/script-child-process.mjs new file mode 100644 index 00000000000..08bd8b36306 --- /dev/null +++ b/config/scripts/script-child-process.mjs @@ -0,0 +1,33 @@ +import { build } from 'esbuild' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const root = resolve(import.meta.dirname, '../..') +const temporary = mkdtempSync(join(tmpdir(), 'orca-script-child-process-')) +const output = join(temporary, 'child-process.mjs') +let implementation + +try { + await build({ + stdin: { + contents: [ + `export { runProcessSync } from ${JSON.stringify(join(root, 'src/shared/child-process/run-process.ts'))}` + ].join('\n'), + resolveDir: root, + sourcefile: 'script-child-process-entry.ts' + }, + bundle: true, + platform: 'node', + target: 'node20', + format: 'esm', + outfile: output, + logLevel: 'silent' + }) + implementation = await import(pathToFileURL(output).href) +} finally { + rmSync(temporary, { recursive: true, force: true }) +} + +export const runProcessSync = implementation.runProcessSync diff --git a/config/scripts/script-module-dependencies.mjs b/config/scripts/script-module-dependencies.mjs index b92db587a81..51381cdc4fb 100644 --- a/config/scripts/script-module-dependencies.mjs +++ b/config/scripts/script-module-dependencies.mjs @@ -1,8 +1,8 @@ import { copyFileSync, mkdirSync, readFileSync } from 'node:fs' -import { basename, dirname, join } from 'node:path' +import { dirname, join, relative } from 'node:path' /** - * Copy a script and every co-located module it imports into a fixture's `config/scripts`. + * Copy a script and its relative modules, preserving their paths in the fixture. * * Walked rather than listed: a module the script needs but the fixture never copied fails every * test in the suite with a module-resolution error that looks nothing like the defect it hides. @@ -10,7 +10,9 @@ import { basename, dirname, join } from 'node:path' export function copyScriptWithLocalModules(sourceScriptPath, destinationScriptsDir) { mkdirSync(destinationScriptsDir, { recursive: true }) for (const modulePath of collectScriptModules(sourceScriptPath)) { - copyFileSync(modulePath, join(destinationScriptsDir, basename(modulePath))) + const destination = join(destinationScriptsDir, relative(dirname(sourceScriptPath), modulePath)) + mkdirSync(dirname(destination), { recursive: true }) + copyFileSync(modulePath, destination) } } @@ -27,7 +29,7 @@ function collectScriptModules(scriptPath, seen = new Set()) { // not against this file, so following them would stage the wrong path. const source = readFileSync(scriptPath, 'utf8') const specifiers = source.matchAll( - /(?:\bfrom|\brequire\s*\(|\bimport\s*\(|\bimport)\s*'(\.\/[^']+)'/g + /(?:\bfrom|\brequire\s*\(|\bimport\s*\(|\bimport)\s*'(\.\.?\/[^']+)'/g ) for (const [, specifier] of specifiers) { collectScriptModules(join(dirname(scriptPath), specifier), seen) diff --git a/config/scripts/script-module-dependencies.test.mjs b/config/scripts/script-module-dependencies.test.mjs index 998226e0427..68482954ee3 100644 --- a/config/scripts/script-module-dependencies.test.mjs +++ b/config/scripts/script-module-dependencies.test.mjs @@ -1,4 +1,11 @@ -import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -23,6 +30,23 @@ function copiedNames(files, entryName) { } describe('copyScriptWithLocalModules', () => { + it('preserves parent paths for modules shared with the runtime', () => { + const sourceDir = sourceTree({ 'runtime.ts': 'export const value = 42\n' }) + mkdirSync(join(sourceDir, 'scripts')) + writeFileSync( + join(sourceDir, 'scripts', 'entry.mjs'), + "export { value } from '../runtime.ts'\n" + ) + const destinationRoot = mkdtempSync(join(fixtureDir, 'dest-')) + copyScriptWithLocalModules( + join(sourceDir, 'scripts', 'entry.mjs'), + join(destinationRoot, 'scripts') + ) + expect(readFileSync(join(destinationRoot, 'runtime.ts'), 'utf8')).toBe( + 'export const value = 42\n' + ) + }) + it('takes the entry script itself', () => { expect(copiedNames({ 'entry.mjs': 'export const a = 1\n' }, 'entry.mjs')).toEqual(['entry.mjs']) }) diff --git a/config/scripts/verify-linux-glibc-floor.cjs b/config/scripts/verify-linux-glibc-floor.cjs index 3d4a0ca3c1d..ebfdb2b8816 100644 --- a/config/scripts/verify-linux-glibc-floor.cjs +++ b/config/scripts/verify-linux-glibc-floor.cjs @@ -366,23 +366,38 @@ function parseImportedSymbols(objdumpOutput) { /** Version needs + DT_NEEDED from a single `objdump -p` (fail-closed). */ function readDynamicInfo(filePath, objdumpPath) { const output = runObjdump(objdumpPath, '-p', filePath) + const versionNeeds = parseVersionNeeds(output) + const neededLibraries = parseNeededLibraries(output) return { - versionNeeds: parseVersionNeeds(output), - neededLibraries: parseNeededLibraries(output) + versionNeeds, + neededLibraries, + // LLVM prints an empty Dynamic Section even for static executables. + isStatic: + /^Program Header:/m.test(output) && + /^\s+LOAD\s+off\s+0x[0-9a-f]+/m.test(output) && + !/^\s+(?:DYNAMIC|INTERP)\s+off\s+/m.test(output) && + versionNeeds.length === 0 && + neededLibraries.size === 0 } } +function isMuslTemplatePayload(filePath, neededLibraries, versionNeeds) { + return ( + /(?:^|[/\\])orcad-template[/\\]targets[/\\]linux-(?:x64|arm64)-musl[/\\]/.test(filePath) && + [...neededLibraries].some( + (name) => name === 'libc.so' || /^libc\.musl-[\w-]+\.so\.1$/.test(name) + ) && + ![...neededLibraries, ...versionNeeds.map((need) => need.library)].some((name) => + /^(?:libc\.so\.6|libm\.so\.6|libpthread\.so\.0|libdl\.so\.2|librt\.so\.1|ld-linux.*)$/.test( + name + ) + ) + ) +} + /** Imported (undefined) dynamic symbols from `objdump -T` (fail-closed). */ function readImportedSymbols(filePath, objdumpPath) { - try { - return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath)) - } catch (error) { - // Why: a statically linked binary (bundled ripgrep) has no dynamic symbol table to import from. - if (error instanceof Error && error.message.includes('not a dynamic object')) { - return new Set() - } - throw error - } + return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath)) } /** @@ -433,15 +448,20 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { const offenders = [] for (const filePath of binaries) { - const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath) - const floorViolations = findFloorViolations(versionNeeds, filePath) + const { versionNeeds, neededLibraries, isStatic } = readDynamicInfo(filePath, objdumpPath) + const isMuslTarget = isMuslTemplatePayload(filePath, neededLibraries, versionNeeds) + // Remote musl payloads use their host's C++ runtime, not Ubuntu's libstdc++ or libutil. + const floorViolations = findFloorViolations(versionNeeds, filePath).filter( + (need) => !isMuslTarget || !isLibstdcxxNode(need.name) + ) // Only pay for `objdump -T` when a relocated-symbol provider is not already // in DT_NEEDED (the common, healthy case short-circuits without it). - const providerViolations = Object.values(RELOCATED_SYMBOL_PROVIDERS).some( - (library) => !neededLibraries.has(library) - ) - ? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries) - : [] + const providerViolations = + !isStatic && + !isMuslTarget && + Object.values(RELOCATED_SYMBOL_PROVIDERS).some((library) => !neededLibraries.has(library)) + ? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries) + : [] if (floorViolations.length > 0 || providerViolations.length > 0) { offenders.push({ filePath, floorViolations, providerViolations }) } @@ -473,7 +493,7 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) { } console.log( - `[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries all load on ${FLOOR_LABEL}` + `[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries meet applicable ${FLOOR_LABEL} requirements` ) } diff --git a/config/scripts/verify-linux-glibc-payloads.test.mjs b/config/scripts/verify-linux-glibc-payloads.test.mjs new file mode 100644 index 00000000000..9a68142cf9e --- /dev/null +++ b/config/scripts/verify-linux-glibc-payloads.test.mjs @@ -0,0 +1,124 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs') +const roots = [] +const STATIC_HEADERS = 'Program Header:\n LOAD off 0x0000000000000000\n' +const MUSL_TARGET = 'orcad-template/targets/linux-arm64-musl/watcher.node' + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function writeObjdumpFixture( + headers, + { filename = 'browser', symbolTableError = false } = {} +) { + const root = await mkdtemp(join(tmpdir(), 'orca-glibc-payload-')) + roots.push(root) + const app = join(root, 'app') + const binary = join(app, ...filename.split('/')) + await mkdir(dirname(binary), { recursive: true }) + const elf = Buffer.alloc(64) + elf.write('\x7fELF', 0, 'latin1') + elf[4] = 2 + elf[5] = 1 + elf[6] = 1 + elf.writeUInt16LE(0xb7, 18) + await writeFile(binary, elf) + await writeFile(join(root, 'private-headers.txt'), headers) + const objdumpPath = join(root, 'objdump-stub.sh') + await writeFile( + objdumpPath, + [ + '#!/bin/sh', + 'case "$1" in', + ' --version) echo "GNU objdump (fixture)" ;;', + ' -p) cat "$(dirname "$0")/private-headers.txt" ;;', + symbolTableError + ? ' -T) echo "not a dynamic object" >&2; exit 1 ;;' + : ' -T) echo "0000 DF *UND* 0000 openpty" ;;', + 'esac' + ].join('\n'), + { mode: 0o755 } + ) + return () => verifyLinuxGlibcFloor(app, { objdumpPath, targetArch: 'arm64' }) +} + +describe.skipIf(process.platform === 'win32')('static ELF and remote musl payloads', () => { + it.each(['', '\nDynamic Section:\n'])( + 'accepts static LOAD segments with no dynamic imports (section suffix %j)', + async (suffix) => { + const verify = await writeObjdumpFixture(STATIC_HEADERS + suffix, { symbolTableError: true }) + expect(verify).not.toThrow() + } + ) + + it.each([ + ['missing program headers', 'Dynamic Section:\n'], + ['dynamic segment', `${STATIC_HEADERS} DYNAMIC off 0x0000000000001000\n`], + ['interpreter segment', `${STATIC_HEADERS} INTERP off 0x0000000000001000\n`], + ['dependency without a dynamic segment', `${STATIC_HEADERS} NEEDED libc.so.6\n`] + ])('preserves symbol-table failures for %s', async (_label, headers) => { + const verify = await writeObjdumpFixture(headers, { symbolTableError: true }) + expect(verify).toThrow(/objdump -T failed/) + }) + + it.each(['libc.so', 'libc.musl-aarch64.so.1'])( + 'does not apply Ubuntu C++ or libutil requirements to a remote payload linked to %s', + async (libc) => { + const verify = await writeObjdumpFixture( + `Dynamic Section:\n NEEDED ${libc}\n NEEDED libstdc++.so.6\n` + + 'Version References:\n required from libstdc++.so.6:\n 0x0 0x00 02 GLIBCXX_3.4.29\n', + { filename: MUSL_TARGET } + ) + expect(verify).not.toThrow() + } + ) + + it.each([ + ['desktop addon', 'watcher.node', 'libc.so'], + ['glibc target', 'orcad-template/targets/linux-arm64-glibc/watcher.node', 'libc.so'], + ['mislabeled glibc target', MUSL_TARGET, 'libc.so.6'], + ['mixed libc dependencies', MUSL_TARGET, 'libc.so\n NEEDED libc.so.6'] + ])('retains Ubuntu floor and provider checks for %s', async (_label, filename, libc) => { + const verify = await writeObjdumpFixture( + `Dynamic Section:\n NEEDED ${libc}\n NEEDED libstdc++.so.6\n` + + 'Version References:\n required from libstdc++.so.6:\n 0x0 0x00 02 GLIBCXX_3.4.29\n', + { filename } + ) + expect(verify).toThrow( + /needs GLIBCXX_3.4.29.*imports openpty but libutil.so.1 is not in DT_NEEDED/ + ) + }) + + it('still rejects too-new glibc version needs in a musl payload', async () => { + const verify = await writeObjdumpFixture( + 'Dynamic Section:\n NEEDED libc.so\nVersion References:\n' + + ' required from libc.so:\n 0x0 0x00 02 GLIBC_2.34\n', + { filename: MUSL_TARGET } + ) + expect(verify).toThrow(/needs GLIBC_2.34/) + }) + + it('retains the provider check when version references disclose a glibc dependency', async () => { + const verify = await writeObjdumpFixture( + 'Dynamic Section:\n NEEDED libc.so\nVersion References:\n' + + ' required from libc.so.6:\n 0x0 0x00 02 GLIBC_2.17\n', + { filename: MUSL_TARGET } + ) + expect(verify).toThrow(/imports openpty but libutil.so.1 is not in DT_NEEDED/) + }) + + it('preserves objdump failures for a musl-labeled file without proven musl dependencies', async () => { + const verify = await writeObjdumpFixture('Dynamic Section:\n', { + filename: MUSL_TARGET, + symbolTableError: true + }) + expect(verify).toThrow(/objdump -T failed/) + }) +}) diff --git a/config/scripts/verify-packaged-orcad-template.cjs b/config/scripts/verify-packaged-orcad-template.cjs new file mode 100644 index 00000000000..ad0e3fe485a --- /dev/null +++ b/config/scripts/verify-packaged-orcad-template.cjs @@ -0,0 +1,150 @@ +const { createHash } = require('node:crypto') +const { lstatSync, readFileSync, readdirSync } = require('node:fs') +const { basename, join } = require('node:path') +const { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_TEMPLATE_MANIFEST_FILENAME, + ORCAD_TEMPLATE_TARGETS_DIR, + orcadTemplateCommonFilenames +} = require('../../src/shared/orcad-artifacts.ts') +const { ORCAD_TEMPLATE_TARGETS } = require('../../src/shared/orcad-bun-runtime.ts') + +const SHA256_PATTERN = /^[a-f0-9]{64}$/ +const BROWSER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ + +function sha256(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +function readManifest(templateDir) { + const path = join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + try { + return JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error( + `[verify-packaged-orcad-template] invalid manifest at ${path}: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function requireRecord(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`[verify-packaged-orcad-template] ${label} must be an object`) + } + return value +} + +function requireSha256(value, label) { + if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) { + throw new Error(`[verify-packaged-orcad-template] ${label} must be a SHA-256 digest`) + } + return value +} + +function requireRegularFile(path, label) { + let metadata + try { + metadata = lstatSync(path) + } catch { + throw new Error(`[verify-packaged-orcad-template] missing ${label} at ${path}`) + } + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error(`[verify-packaged-orcad-template] ${label} is not a regular file at ${path}`) + } +} + +function verifyFile(path, expected, label) { + requireRegularFile(path, label) + const actual = sha256(path) + if (actual !== expected) { + throw new Error( + `[verify-packaged-orcad-template] ${label} checksum mismatch: expected ${expected}, got ${actual}` + ) + } +} + +function requireExactNames(actual, expected, label) { + const actualNames = [...actual].sort() + const expectedNames = [...expected].sort() + if ( + actualNames.length !== expectedNames.length || + actualNames.some((name, index) => name !== expectedNames[index]) + ) { + throw new Error( + `[verify-packaged-orcad-template] ${label} mismatch: expected=${expectedNames.join(',')} actual=${actualNames.join(',')}` + ) + } +} + +function verifyTarget(templateDir, target, value) { + const targetManifest = requireRecord(value, `${target} manifest`) + const targetSha256 = requireSha256(targetManifest.targetSha256, `${target} targetSha256`) + const watcherSha256 = requireSha256(targetManifest.watcherSha256, `${target} watcherSha256`) + const hasBrowserName = Object.hasOwn(targetManifest, 'browserName') + const hasBrowserSha256 = Object.hasOwn(targetManifest, 'browserSha256') + if (hasBrowserName !== hasBrowserSha256) { + throw new Error( + `[verify-packaged-orcad-template] ${target} browserName and browserSha256 must both be present` + ) + } + const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) + const targetIdentity = join(targetDir, ORCAD_BUILD_TARGET_FILENAME) + verifyFile(targetIdentity, targetSha256, `${target} build target`) + if (readFileSync(targetIdentity, 'utf8').trim() !== target) { + throw new Error(`[verify-packaged-orcad-template] ${target} build target identity disagrees`) + } + verifyFile(join(targetDir, 'watcher.node'), watcherSha256, `${target} watcher`) + + const expectedFiles = [ORCAD_BUILD_TARGET_FILENAME, 'watcher.node'] + if (hasBrowserName) { + const browserName = targetManifest.browserName + if ( + typeof browserName !== 'string' || + !BROWSER_NAME_PATTERN.test(browserName) || + basename(browserName) !== browserName + ) { + throw new Error(`[verify-packaged-orcad-template] ${target} browserName is invalid`) + } + verifyFile( + join(targetDir, browserName), + requireSha256(targetManifest.browserSha256, `${target} browserSha256`), + `${target} browser` + ) + expectedFiles.push(browserName) + } + requireExactNames(readdirSync(targetDir), expectedFiles, `${target} file inventory`) +} + +function verifyPackagedOrcadTemplate(resourcesDir) { + const templateDir = join(resourcesDir, 'orcad-template') + const manifest = requireRecord(readManifest(templateDir), 'manifest') + if (manifest.schemaVersion !== 2) { + throw new Error('[verify-packaged-orcad-template] manifest schemaVersion must be 2') + } + const commonSha256 = requireRecord(manifest.commonSha256, 'commonSha256') + const commonFilenames = orcadTemplateCommonFilenames() + requireExactNames(Object.keys(commonSha256), commonFilenames, 'common manifest inventory') + for (const filename of commonFilenames) { + verifyFile( + join(templateDir, ...filename.split('/')), + requireSha256(commonSha256[filename], `${filename} checksum`), + filename + ) + } + + const targets = requireRecord(manifest.targets, 'targets') + requireExactNames(Object.keys(targets), ORCAD_TEMPLATE_TARGETS, 'target manifest inventory') + requireExactNames( + readdirSync(join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR)), + ORCAD_TEMPLATE_TARGETS, + 'target directory inventory' + ) + for (const target of ORCAD_TEMPLATE_TARGETS) { + verifyTarget(templateDir, target, targets[target]) + } + console.log( + `[verify-packaged-orcad-template] OK — verified ${ORCAD_TEMPLATE_TARGETS.length} Bun targets` + ) +} + +module.exports = { verifyPackagedOrcadTemplate } diff --git a/config/scripts/verify-packaged-orcad-template.test.mjs b/config/scripts/verify-packaged-orcad-template.test.mjs new file mode 100644 index 00000000000..9be9b456439 --- /dev/null +++ b/config/scripts/verify-packaged-orcad-template.test.mjs @@ -0,0 +1,93 @@ +import { createRequire } from 'node:module' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + ORCAD_TEMPLATE_MANIFEST_FILENAME, + ORCAD_TEMPLATE_TARGETS_DIR +} from '../../src/shared/orcad-artifacts.ts' +import { writeOrcadTemplateTestFixture } from './orcad-template-test-fixture.mjs' + +const require = createRequire(import.meta.url) +const { verifyPackagedOrcadTemplate } = require('./verify-packaged-orcad-template.cjs') +const builderConfig = require('../electron-builder.config.cjs') +const roots = [] + +async function createFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-packaged-orcad-template-')) + roots.push(root) + const templateDir = await writeOrcadTemplateTestFixture(root) + return { root, templateDir } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('verifyPackagedOrcadTemplate', () => { + it('accepts the exact six-target packaged template', async () => { + const fixture = await createFixture() + + expect(() => verifyPackagedOrcadTemplate(fixture.root)).not.toThrow() + }) + + it('rejects target-native bytes changed after manifest generation', async () => { + const fixture = await createFixture() + await writeFile( + join(fixture.templateDir, ORCAD_TEMPLATE_TARGETS_DIR, 'linux-x64-glibc', 'watcher.node'), + 'mutated' + ) + + expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow( + 'linux-x64-glibc watcher checksum mismatch' + ) + }) + + it('rejects a missing Windows PTY gate worker', async () => { + const fixture = await createFixture() + await rm(join(fixture.templateDir, 'windows-bun-pty-gate-entry.js')) + + expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow('windows-bun-pty-gate-entry.js') + }) + + it.each(['writer', 'backup'])( + 'requires the profile %s worker and its exact bytes', + async (role) => { + const fixture = await createFixture() + const filename = `profile-state-${role}-worker-entry.js` + await writeFile(join(fixture.templateDir, filename), 'stale-worker') + expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow( + `${filename} checksum mismatch` + ) + await rm(join(fixture.templateDir, filename)) + expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow(`missing ${filename}`) + } + ) + + it('rejects a missing target before the package reaches deployment', async () => { + const fixture = await createFixture() + const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + delete manifest.targets['linux-arm64-musl'] + await writeFile(manifestPath, JSON.stringify(manifest)) + + expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow( + 'target manifest inventory mismatch' + ) + }) + + it('does not ship the unused deployment template in desktop packages', async () => { + for (const platform of ['win', 'mac', 'linux']) { + expect( + builderConfig[platform].extraResources.some( + (resource) => typeof resource === 'object' && resource.to.startsWith('orcad-template') + ) + ).toBe(false) + } + const { scripts } = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')) + for (const name of ['build:desktop', 'build:release', 'build:release:parallel']) { + expect(scripts[name]).not.toContain('build:orcad-template') + } + }) +}) diff --git a/config/scripts/zip-extractor-command.mjs b/config/scripts/zip-extractor-command.mjs new file mode 100644 index 00000000000..fbd4c696748 --- /dev/null +++ b/config/scripts/zip-extractor-command.mjs @@ -0,0 +1 @@ +export { getZipExtractorCommand } from '../../src/shared/zip-extractor-command.ts' diff --git a/config/scripts/zip-extractor-command.test.mjs b/config/scripts/zip-extractor-command.test.mjs new file mode 100644 index 00000000000..9cc574f87ec --- /dev/null +++ b/config/scripts/zip-extractor-command.test.mjs @@ -0,0 +1,62 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcessSync } from './script-child-process.mjs' +import { getZipExtractorCommand } from './zip-extractor-command.mjs' + +const directories = [] +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function extract(bytes) { + const directory = mkdtempSync(join(tmpdir(), "orca archive '$ ")) + directories.push(directory) + const archive = join(directory, "source '$.zip") + const destination = join(directory, "output '$") + writeFileSync(archive, bytes) + mkdirSync(destination) + const command = getZipExtractorCommand(archive, destination) + const result = runProcessSync({ program: command.file, args: command.args, timeoutMs: 120_000 }) + return { result, destination } +} + +describe('native archive extraction', () => { + it('uses the system archive reader on Windows unless an override is configured', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('SystemRoot', 'C:\\Windows') + vi.stubEnv('ORCA_UNZIP_BIN', '') + expect(getZipExtractorCommand('source.zip', 'output')).toEqual({ + file: join('C:\\Windows', 'System32', 'tar.exe'), + args: ['-xf', 'source.zip', '-C', 'output'], + label: 'tar' + }) + vi.stubEnv('ORCA_UNZIP_BIN', 'C:\\tools\\unzip.exe') + expect(getZipExtractorCommand("source '$.zip", "output '$")).toEqual({ + file: 'C:\\tools\\unzip.exe', + args: ['-q', "source '$.zip", '-d', "output '$"], + label: 'unzip' + }) + }) + + it('extracts through paths containing spaces, apostrophes and shell characters', () => { + const { result, destination } = extract( + Buffer.from( + 'UEsDBBQAAAAAAI1iOF16rk6zGAAAABgAAAALAAAAcGF5bG9hZC50eHR2ZXJpZmllZCBhcmNoaXZlIHBheWxvYWRQSwECFAMUAAAAAACNYjhdeq5OsxgAAAAYAAAACwAAAAAAAAAAAAAAgAEAAAAAcGF5bG9hZC50eHRQSwUGAAAAAAEAAQA5AAAAQQAAAAAA', + 'base64' + ) + ) + expect(result.code, result.stderr).toBe(0) + expect(readFileSync(join(destination, 'payload.txt'), 'utf8')).toBe('verified archive payload') + }) + + it('fails on a malformed archive', () => { + const { result } = extract('invalid archive') + expect(result.code).not.toBe(0) + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index e8d48d62980..21f96d5c90d 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -213,6 +213,12 @@ "../src/main/startup/cli-command-names.ts", "../src/main/runtime/runtime-metadata.ts", "../src/main/sqlite/sync-database.ts", + "../src/main/sqlite/bun-readonly-wal.ts", + "../src/main/sqlite/sqlite-statement.ts", + "../src/main/sqlite/sqlite-integer-reader.ts", + "../src/main/sqlite/node-sqlite-statement.ts", + "../src/main/sqlite/bun-sqlite-statement.ts", + "../src/main/sqlite/bun-sqlite-database.ts", "../src/main/win32-utils.ts" ], "compilerOptions": { diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 6713372ec87..5c85c3e6980 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -16,6 +16,8 @@ export default defineConfig({ }, test: { environment: 'node', + // Bun's external-module cache otherwise loses Zod named exports across mocked graphs. + ...(process.versions.bun ? { server: { deps: { inline: ['zod'] } } } : {}), ...(process.env.ORCA_BALANCE_UNIT_SHARDS === '1' ? { sequence: { sequencer: TimingSequencer }, diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index 0fafdb1feed..63f03777c17 100644 --- a/docs/reference/orcad-operations.md +++ b/docs/reference/orcad-operations.md @@ -145,11 +145,11 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to after the listener is bound and the daemon verdict is in. There is no separate readiness socket; the line is the signal. Set the supervisor's start timeout generously — the daemon launch has its own retries and can take tens of seconds on a cold host. -- **Shutdown.** `SIGTERM` or `SIGINT` starts a graceful stop. A **second** signal exits - immediately with code 1 rather than being swallowed — a supervisor's second signal means - its first deadline elapsed, and waiting silently is what turns a stop into a `SIGKILL`, - the one teardown that skips the daemon handoff. orcad also imposes its own 15s deadline - and exits 1, so the failure stays attributable instead of arriving as an unlogged kill. +- **Shutdown.** `SIGTERM` or `SIGINT` starts one graceful stop. Repeated signals share + that stop because a supervisor may signal both the launcher and its child. A 15s deadline + exits with code 1 if teardown stalls. The bundled runtime also stops gracefully if its + launcher's IPC channel closes. On POSIX, both the launcher and runtime ignore `SIGHUP`, + so terminal hangups do not stop a headless host. Use `SIGTERM` or `SIGINT` to stop it. - **Exit codes.** | Code | Meaning | Supervisor should | diff --git a/package.json b/package.json index 96a0d2f2ec5..c75470b0e89 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "prepare": "husky", "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", + "test:bun:profile": "node config/scripts/run-bun-profile-tests.mjs", "test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "capture:agent-transcript": "node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/capture-agent-pty-transcript.mjs", @@ -38,7 +39,8 @@ "check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", "check:readme-local-links": "node config/scripts/check-readme-local-links.mjs", - "build:orcad": "node config/scripts/build-orcad.mjs", + "build:orcad": "node config/scripts/build-orcad-bun.mjs", + "build:orcad-template": "node config/scripts/build-orcad-template.mjs", "build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs", "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad", "smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs", @@ -299,6 +301,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", + "tar": "7.5.22", "tw-animate-css": "^1.4.0", "typescript": "^7.0.2", "typescript-api": "npm:typescript@6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c3feeb3337..c5c11c4490b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -505,6 +505,9 @@ importers: tailwindcss: specifier: ^4.2.4 version: 4.2.4 + tar: + specifier: 7.5.22 + version: 7.5.22 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 diff --git a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts new file mode 100644 index 00000000000..94e352ce695 --- /dev/null +++ b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts @@ -0,0 +1,280 @@ +import type { IPty } from 'node-pty' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' +import type * as SnapshotReader from '../../shared/process-table-snapshot-reader' +import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' +import { resolveSpawnFileForegroundFromRows } from './pty-subprocess/spawn-file-foreground-process' +import { inspectTerminalHostProcess } from './terminal-host-process-inspection' +import { Session } from './session' +const { readSnapshot, readFresh, readStrict, members, readWindows, resolveWindows } = vi.hoisted( + () => ({ + readSnapshot: vi.fn(), + readFresh: vi.fn(), + readStrict: vi.fn(), + members: vi.fn(), + readWindows: vi.fn(), + resolveWindows: vi.fn() + }) +) +vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getProcessTableSnapshot: readSnapshot, + getFreshProcessTableSnapshot: readFresh, + getStrictProcessTableSnapshotWithAge: readStrict +})) +vi.mock('../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: members, + isWindowsPtyJobReadable: () => true +})) +vi.mock('../windows/windows-process-table', () => ({ + readWindowsProcessIdentityTable: readWindows, + readWindowsProcessIdentityTableFresh: readWindows +})) +vi.mock('../providers/windows-agent-foreground-process', () => ({ + shouldInspectWindowsAgentForeground: () => true, + resolveWindowsAgentForegroundProcessWithAvailability: resolveWindows +})) + +function table(command: string | null, loginWrapper = false): ProcessTableRow[] { + const tpgid = command === null ? (loginWrapper ? 101 : 100) : 102 + const root: ProcessTableRow = { + pid: 100, + ppid: 1, + pgid: 100, + tpgid, + tty: 'ttys004', + startTime: 'Thu Sep 3 16:02:01 2026', + stat: tpgid === 100 ? 'Ss+' : 'Ss', + command: loginWrapper ? '"/Applications/Orca shell login" -fp user' : '/bin/zsh' + } + return [ + root, + ...(loginWrapper + ? [ + { + ...root, + pid: 101, + ppid: 100, + pgid: 101, + stat: command === null ? 'S+' : 'S', + command: '-zsh' + } + ] + : []), + ...(command === null + ? [] + : [{ ...root, pid: 102, ppid: loginWrapper ? 101 : 100, pgid: 102, stat: 'S+', command }]) + ] +} + +function createHandle(loginWrapper = false) { + const proc: IPty & { processNameIsSpawnFile: true } = { + pid: 100, + cols: 80, + rows: 24, + handleFlowControl: false, + process: loginWrapper ? '/Applications/Orca shell login' : '/bin/zsh', + processNameIsSpawnFile: true, + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + write() {}, + resize() {}, + clear() {}, + kill() {}, + pause() {}, + resume() {} + } + return createDaemonPtySubprocessHandle({ + process: proc, + shellPath: '/bin/zsh', + spawnCwd: '/tmp', + env: {}, + startupCommandDeliveredInShellArgs: false, + reportsChildExitStatus: true, + sessionId: 'static-name', + startupAgentRecognition: null + }) +} + +async function inspect(handle: ReturnType) { + const session = new Session({ + sessionId: 'static-name', + subprocess: handle, + shellReadySupported: false, + cols: 80, + rows: 24, + scrollback: 10 + }) + try { + return await inspectTerminalHostProcess({ + sessionId: session.sessionId, + session, + authorityGeneration: 'generation', + nextObservationEpoch: () => 1 + }) + } finally { + session.dispose() + } +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.resetAllMocks() +}) + +describe.each(['linux', 'darwin'] as const)('static spawn-file foreground on %s', (platform) => { + it.each(['vim', 'sleep', 'node', 'npm', 'node /usr/bin/claude'])( + 'resolves %s in both the synchronous tracker and host inspection', + async (command) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const rows = table(command, platform === 'darwin') + readSnapshot.mockResolvedValue(rows) + readFresh.mockResolvedValue(rows) + readStrict.mockResolvedValue({ rows, capturedAgeMs: 0 }) + const handle = createHandle(platform === 'darwin') + const expected = command.includes('claude') ? 'claude' : command + expect(handle.processNameIsSpawnFile).toBe(true) + expect(await handle.confirmForegroundProcess?.()).toBe(expected) + expect(handle.getForegroundProcess()).toBe(expected) + expect(await inspect(createHandle(platform === 'darwin'))).toMatchObject({ + foregroundProcess: expected, + hasChildProcesses: true + }) + expect(readStrict).toHaveBeenCalledTimes(1) + } + ) + + it('observes a command ending and returns to an idle login shell', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const handle = createHandle(true) + readFresh.mockResolvedValue(table('vim', true)) + expect(await handle.confirmForegroundProcess?.()).toBe('vim') + const rows = table(null, true) + readFresh.mockResolvedValue(rows) + readSnapshot.mockResolvedValue(rows) + readStrict.mockResolvedValue({ rows, capturedAgeMs: 0 }) + expect(await handle.confirmForegroundProcess?.()).toBe('zsh') + const inspection = await inspect(handle) + expect(inspection).toMatchObject({ + foregroundProcess: null, + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + }) + + it('does not interpret a failed process read as a childless shell', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + readFresh.mockRejectedValue(new Error('unreadable')) + readSnapshot.mockRejectedValue(new Error('unreadable')) + readStrict.mockRejectedValue(new Error('unreadable')) + const handle = createHandle() + expect(await handle.confirmForegroundProcess?.()).toBeNull() + const inspection = await inspect(handle) + expect(inspection).toMatchObject({ + hasChildProcesses: true, + childProcessEvidence: 'unverifiable', + foregroundProcessEvidence: { verdict: 'unverifiable' } + }) + }) + + it('retains ordinary foreground names across a failed background refresh', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(100_000) + readSnapshot.mockResolvedValue(table('vim')) + const handle = createHandle() + expect(handle.getForegroundProcess()).toBe('zsh') + await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('vim')) + readSnapshot.mockRejectedValue(new Error('unreadable')) + vi.setSystemTime(102_000) + expect(handle.getForegroundProcess()).toBe('vim') + await vi.waitFor(() => expect(readSnapshot).toHaveBeenCalledTimes(2)) + expect(handle.getForegroundProcess()).toBe('vim') + handle.dispose() + }) + + it.each(['T', 'S'])( + 'keeps the close guard live when the shell is foreground and a child has state %s', + async (stat) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const rows = table(null, platform === 'darwin') + rows.push({ ...rows[0], pid: 102, ppid: rows.at(-1)!.pid, pgid: 102, stat, command: 'vim' }) + readSnapshot.mockResolvedValue(rows) + readStrict.mockResolvedValue({ rows, capturedAgeMs: 0 }) + const inspection = await inspect(createHandle(platform === 'darwin')) + expect(inspection).toMatchObject({ + foregroundProcess: null, + hasChildProcesses: true, + childProcessEvidence: 'children' + }) + } + ) +}) + +it('ignores stopped/background children and another terminal beneath the same root', () => { + const rows = table(null) + rows.push({ ...rows[0], pid: 102, ppid: 100, pgid: 102, stat: 'T', command: 'vim' }) + rows.push({ ...rows[0], pid: 103, ppid: 100, tty: 'ttys009', command: 'claude' }) + expect(resolveSpawnFileForegroundFromRows(rows, 100)).toEqual({ + available: true, + processName: 'zsh' + }) + expect(resolveSpawnFileForegroundFromRows(rows, 999)).toEqual({ + available: false, + processName: null + }) +}) + +it('uses Windows job membership and the native process table for ordinary children', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resolveWindows.mockResolvedValue({ available: true, processName: null }) + members.mockReturnValue(new Set([100, 102])) + readWindows.mockResolvedValue([ + { pid: 100, ppid: 1, name: 'pwsh.exe' }, + { pid: 102, ppid: 100, name: 'vim.exe' } + ]) + readStrict.mockRejectedValue(new Error('POSIX evidence unavailable')) + const handle = createHandle() + expect(await handle.confirmForegroundProcess?.()).toBe('vim.exe') + expect(await inspect(handle)).toMatchObject({ + foregroundProcess: 'vim.exe', + hasChildProcesses: true + }) +}) + +it('keeps missing Windows job membership unverifiable', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resolveWindows.mockResolvedValue({ available: true, processName: null }) + members.mockReturnValue(null) + readStrict.mockRejectedValue(new Error('POSIX evidence unavailable')) + expect(await inspect(createHandle())).toMatchObject({ + foregroundProcess: null, + hasChildProcesses: true, + childProcessEvidence: 'unverifiable' + }) +}) + +it('reports an idle Windows shell only when the owned job contains the shell alone', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resolveWindows.mockResolvedValue({ available: true, processName: null }) + members.mockReturnValue(new Set([100])) + readStrict.mockRejectedValue(new Error('POSIX evidence unavailable')) + expect(await inspect(createHandle())).toMatchObject({ hasChildProcesses: false }) + expect(readWindows).not.toHaveBeenCalled() +}) + +it('keeps the Windows close guard live when a shell descendant is selected above another job', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + resolveWindows.mockResolvedValue({ available: true, processName: null }) + members.mockReturnValue(new Set([100, 102, 103, 104])) + readWindows.mockResolvedValue([ + { pid: 100, ppid: 1, name: 'pwsh.exe' }, + { pid: 102, ppid: 100, name: 'vim.exe' }, + { pid: 103, ppid: 100, name: 'cmd.exe' }, + { pid: 104, ppid: 103, name: 'pwsh.exe' } + ]) + readStrict.mockRejectedValue(new Error('POSIX evidence unavailable')) + const inspection = await inspect(createHandle()) + expect(inspection).toMatchObject({ hasChildProcesses: true, childProcessEvidence: 'children' }) +}) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 329d1ce5d6b..1b5bc3d1102 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -84,7 +84,7 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { + // Bash re-raises SIGHUP; Zsh exits with the signal number. + for (const [shell, expectedExitCode] of [ + ['/bin/bash', 129], + ['/bin/zsh', 1] + ] as const) { + it.skipIf(!existsSync(shell))( + `gracefully closes an interactive ${shell} before the daemon force-kill deadline`, + async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-bun-shell-hangup-')) + try { + const entry = join(directory, 'shell-hangup.cjs') + writeFileSync( + entry, + ` +const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))}) +const {createDaemonPtySubprocessHandle} = require(${JSON.stringify(join(__dirname, 'subprocess-handle.ts'))}) +const {SessionTerminationController} = require(${JSON.stringify(join(__dirname, '../session-termination-controller.ts'))}) +const {existsSync} = require('node:fs') +const {join} = require('node:path') +const cwd = ${JSON.stringify(directory)} +const ready = join(cwd, 'ready'), cleanup = join(cwd, 'hangup-cleanup') +const shell = ${JSON.stringify(shell)} +const env = {...process.env,PS1:'',ORCA_TEST_READY:ready,ORCA_TEST_CLEANUP:cleanup} +const proc = spawnBunPty({file:shell,args:shell.endsWith('/bash')?['--noprofile','--norc','-i']:['-f','-i'],cwd,env,cols:80,rows:24}) +const subprocess = createDaemonPtySubprocessHandle({process:proc,shellPath:shell,spawnCwd:cwd,env,startupCommandDeliveredInShellArgs:false,reportsChildExitStatus:true,sessionId:'shell-hangup',startupAgentRecognition:null}) +let exited = false, forced = false, exitCode, elapsedMs, startedAt +const forceKill = subprocess.forceKill +subprocess.forceKill = () => {forced = true;forceKill()} +const controller = new SessionTerminationController({sessionId:'shell-hangup',subprocess,launchAgent:null,isExited:()=>exited,releaseProducerPause:()=>proc.resume()}) +subprocess.onExit(code => { + exited = true + exitCode = code + elapsedMs = Date.now() - startedAt + controller.markPhysicalExit() + controller.cancelForceKillFallback() +}) +const waitFor = async predicate => { + const deadline = Date.now() + 8000 + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for shell hangup') + await Bun.sleep(10) + } +} +;(async()=>{ + try { + // Observe normal hangup cleanup without replacing the shell's SIGHUP handler. + proc.write(${JSON.stringify('trap \'printf cleaned > "$ORCA_TEST_CLEANUP"\' EXIT; printf ready > "$ORCA_TEST_READY"\r')}) + await waitFor(() => existsSync(ready)) + startedAt = Date.now() + controller.kill() + await waitFor(() => exited) + let reaped = false + try {process.kill(proc.pid, 0)} catch (error) {if(error.code==='ESRCH')reaped=true;else throw error} + console.log(JSON.stringify({cleaned:existsSync(cleanup),forced,exitCode,elapsedMs,reaped})) + } finally { + controller.cancelForceKillFallback() + if (!exited) { + subprocess.forceKill() + await waitFor(() => exited) + } + controller.disposeSubprocessHandle() + } +})().catch(error => {console.error(error);process.exitCode=1}) +` + ) + const result = await runProcess({ + program: runtimePath, + args: [entry], + timeoutMs: 25_000 + }) + expect(result.timedOut).toBe(false) + expect(result.code, result.stderr).toBe(0) + const evidence = JSON.parse(result.stdout) + expect(evidence).toEqual({ + cleaned: true, + forced: false, + exitCode: expectedExitCode, + elapsedMs: expect.any(Number), + reaped: true + }) + expect(evidence.elapsedMs).toBeLessThan(5_000) + } finally { + removeTreeSync(directory) + } + } + ) + } + + it('keeps a real Ctrl-Z job suspended while pausing and resuming a background producer', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-bun-job-control-')) + try { + writeFileSync(join(directory, 'producer.cjs'), 'setInterval(()=>console.log("flow-tick"),10)') + const entry = join(directory, 'job-control.cjs') + writeFileSync( + entry, + ` +const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))}) +const {readPosixPtyProcessTable,forceKillPosixPtyProcessGroups} = require(${JSON.stringify(join(__dirname, '../../pty/posix-pty-process-groups.ts'))}) +const signals = [] +const proc = spawnBunPty({ + file:'/bin/bash', args:['--noprofile','--norc','-i'], cwd:${JSON.stringify(directory)}, + env:{...process.env,PS1:'',ORCA_TEST_RUNTIME:process.execPath},cols:80,rows:24 +}, {signalProcessGroup:(pgid,signal)=>{process.kill(-pgid,signal);signals.push([pgid,signal])}}) +let output = '', exited = false +proc.onData(data => output += data) +proc.onExit(() => {exited = true}) +const isAlive = pid => { + try {process.kill(pid, 0);return true} + catch (error) {if(error.code==='ESRCH')return false;throw error} +} +const rows = async () => { + const table = (await readPosixPtyProcessTable(proc.pid)).trim().split(/\\r?\\n/).map(row => { + const [pid,pgid,tty,state] = row.trim().split(/\\s+/) + return {pid:Number(pid),pgid:Number(pgid),tty,state} + }).filter(row => row.pid > 0 && row.state) + const root = table.find(row => row.pid === proc.pid) + // BusyBox discovery returns all processes; this probe owns only its shell's terminal. + return root ? table.filter(row => row.tty === root.tty) : [] +} +const waitFor = async predicate => { + const deadline = Date.now() + 8000 + while (Date.now() < deadline) { + const value = await predicate() + if (value) return value + await Bun.sleep(20) + } + throw new Error('Timed out waiting for terminal process state') +} +;(async()=>{ + try { + proc.write('sleep 30\\r') + const sleeper = await waitFor(async () => (await rows()).find(row => row.pid !== proc.pid)) + proc.write('\\x1a') + await waitFor(async () => (await rows()).some(row => row.pid === sleeper.pid && row.state.startsWith('T'))) + proc.write(${JSON.stringify('"$ORCA_TEST_RUNTIME" producer.cjs &\r')}) + await waitFor(() => output.split('flow-tick').length > 5) + proc.pause() + await waitFor(() => signals.filter(([,signal]) => signal === 'SIGSTOP').length >= 2) + await Bun.sleep(100) + const pausedLength = output.length + await Bun.sleep(100) + const producerPaused = pausedLength === output.length + proc.resume() + await waitFor(() => signals.some(([,signal]) => signal === 'SIGCONT')) + await waitFor(() => output.length > pausedLength) + const sleeperAfter = (await rows()).find(row => row.pid === sleeper.pid) + console.log(JSON.stringify({producerPaused,producerResumed:true,userJobStopped:sleeperAfter?.state.startsWith('T')===true,userJobSignalled:signals.some(([pgid])=>pgid===sleeper.pgid)})) + } finally { + let ownedPids = [] + try { + proc.resume() + const ownedRows = await rows() + ownedPids = ownedRows.map(row => row.pid) + const root = ownedRows.find(row => row.pid === proc.pid) + if (!root) throw new Error('Cleanup could not find the owned shell') + // Keep Bash running until it reaps its jobs; container PID 1 may not reap orphans. + forceKillPosixPtyProcessGroups(proc.pid, () => {throw new Error('Cleanup lost terminal ownership')}, { + signalProcessGroup: pgid => {if (pgid !== root.pgid) process.kill(-pgid, 'SIGKILL')} + }) + process.kill(proc.pid, 'SIGCONT') + await waitFor(() => ownedPids.every(pid => pid === proc.pid || !isAlive(pid))) + } finally { + try { + forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL')) + await waitFor(() => exited && ownedPids.every(pid => !isAlive(pid))) + } finally { + proc.destroy() + } + } + } +})().catch(error => {console.error(error);process.exitCode=1}) +` + ) + const result = await runProcess({ program: runtimePath, args: [entry], timeoutMs: 25_000 }) + expect(result.timedOut).toBe(false) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + producerPaused: true, + producerResumed: true, + userJobStopped: true, + userJobSignalled: false + }) + } finally { + removeTreeSync(directory) + } + }) +}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-capabilities.ts b/src/main/daemon/pty-subprocess/bun-pty-process-capabilities.ts new file mode 100644 index 00000000000..8d236ab5aa8 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-capabilities.ts @@ -0,0 +1,27 @@ +import type { BunRuntime } from './bun-pty-process-contract' + +function currentRuntime(): unknown { + return 'Bun' in globalThis ? globalThis.Bun : undefined +} + +function isBunRuntime(runtime: unknown): runtime is BunRuntime { + return ( + typeof runtime === 'object' && + runtime !== null && + 'spawn' in runtime && + typeof runtime.spawn === 'function' && + 'Terminal' in runtime && + typeof runtime.Terminal === 'function' + ) +} + +export function canUseBunPty(runtime: unknown = currentRuntime()): boolean { + return isBunRuntime(runtime) +} + +export function resolveBunRuntime(runtime: unknown = currentRuntime()): BunRuntime { + if (!isBunRuntime(runtime)) { + throw new Error('Bun terminal runtime is unavailable') + } + return runtime +} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts b/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts new file mode 100644 index 00000000000..b43dd57a659 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-contract.ts @@ -0,0 +1,72 @@ +import type * as pty from 'node-pty' +import type { JobTerminationOutcome } from '../../windows/windows-pty-job' +import type { WindowsBunPtyJob } from './windows-bun-pty-job' +import type { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' + +export type BunTerminal = { + closed: boolean + write(data: string | ArrayBufferView): number + resize(cols: number, rows: number): void + close(): void +} + +export type BunSubprocess = { + pid: number + terminal: BunTerminal + exited: Promise + signalCode?: string | null + kill(signal?: string | number): void +} + +export type BunTerminalOptions = { + cols: number + rows: number + name: string + data(terminal: BunTerminal, data: Uint8Array): void + exit?(terminal: BunTerminal, exitCode: number, signal: string | null): void + drain?(terminal: BunTerminal): void +} + +export type BunRuntime = { + Terminal: new (options: BunTerminalOptions) => BunTerminal + spawn( + command: string[], + options: { + cwd: string + env: Record + terminal: BunTerminal | BunTerminalOptions + windowsVerbatimArguments?: boolean + onExit?(process: BunSubprocess, exitCode: number, signalCode: string | null): void + } + ): BunSubprocess +} + +export type BunPtyProcess = pty.IPty & { + destroy(): void + processNameIsSpawnFile?: true + jobRootProcessIsWrapper?: true + shellProcessId?: number + waitForSpawn?(): Promise + terminateOwnedTree?(): JobTerminationOutcome + listOwnedProcessIds?(): readonly number[] | null + signalProcess?(signal: string): void +} + +export type BunPtySpawnArgs = { + file: string + args: string[] + cwd: string + env: Record + cols: number + rows: number +} + +export type SpawnBunPtyDeps = { + platform?: NodeJS.Platform + runtime?: BunRuntime + assignHostJob?: () => boolean + createJob?: (pid: number) => WindowsBunPtyJob | null + createWindowsLaunch?: typeof createWindowsBunPtyLaunch + readProcessTable?: () => string + signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void +} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.test.ts new file mode 100644 index 00000000000..5c4a438238b --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.test.ts @@ -0,0 +1,387 @@ +import { constants } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' + +const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test' +const settled = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +function createHarness() { + let exited = false + const reads: { resolve: (table: string) => void; signal: AbortSignal }[] = [] + const readProcessTableAsync = vi.fn( + (signal: AbortSignal) => + new Promise((resolve) => { + reads.push({ resolve, signal }) + }) + ) + const signalProcessGroup = vi.fn<(pgid: number, signal: NodeJS.Signals) => void>() + const kill = vi.fn() + const flow = createBunPtyProducerFlowControl({ + platform: 'linux', + processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } }, + windowsJob: null, + isExited: () => exited, + readProcessTable: () => TABLE, + readProcessTableAsync, + signalProcessGroup + }) + return { + flow, + reads, + kill, + readProcessTableAsync, + signalProcessGroup, + exit: () => { + exited = true + } + } +} + +afterEach(() => vi.useRealTimers()) + +describe('asynchronous POSIX producer flow control', () => { + it.each(['S', 'R', ''])( + 'leaves jobs running unless the shell is observed stopped (state %s)', + async (state) => { + const harness = createHarness() + harness.flow.pause() + expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + expect(harness.readProcessTableAsync).not.toHaveBeenCalled() + await settled() + harness.reads[0].resolve(TABLE.replace('pts/test T', `pts/test ${state}`)) + await settled() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + harness.flow.resume() + await settled() + harness.reads[1].resolve(TABLE) + await settled() + expect(harness.kill.mock.calls).toEqual([ + [constants.signals.SIGSTOP], + [constants.signals.SIGCONT] + ]) + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + } + ) + + it('does not attempt a group pause after the owned shell cannot be stopped', async () => { + const harness = createHarness() + harness.kill.mockImplementationOnce(() => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + }) + harness.flow.pause() + await settled() + harness.flow.resume() + await settled() + expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + expect(harness.readProcessTableAsync).not.toHaveBeenCalled() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + }) + + it('retries a failed resume lookup without another caller resume or stale group signals', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.readProcessTableAsync.mockRejectedValueOnce(new Error('temporary ps failure')) + harness.flow.resume() + await settled() + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + + await vi.advanceTimersByTimeAsync(1_000) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(3) + harness.reads[1].resolve('4321 4321 pts/test T\n4322 4322 pts/other\n4323 4323 pts/test') + await settled() + expect(harness.signalProcessGroup.mock.calls.slice(2)).toEqual([[4321, 'SIGCONT']]) + await vi.advanceTimersByTimeAsync(5_000) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(3) + }) + + it.each(['pause', 'shutdown', 'exit'] as const)( + 'cancels a scheduled resume retry after %s', + async (action) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.readProcessTableAsync.mockRejectedValueOnce(new Error('temporary ps failure')) + harness.flow.resume() + await settled() + expect(vi.getTimerCount()).toBe(1) + if (action === 'pause') { + harness.flow.pause() + await settled() + harness.reads[1].resolve(TABLE) + await settled() + } else { + if (action === 'exit') { + harness.exit() + } + harness.flow.resumeForShutdown() + } + expect(vi.getTimerCount()).toBe(0) + const signals = harness.signalProcessGroup.mock.calls.length + await vi.advanceTimersByTimeAsync(5_000) + expect(harness.signalProcessGroup).toHaveBeenCalledTimes(signals) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(action === 'pause' ? 3 : 2) + } + ) + + it('bounds retries while discovery stays unavailable and resumes after it recovers', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable')) + harness.flow.resume() + await settled() + await vi.advanceTimersByTimeAsync(2_000) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(6) + expect(vi.getTimerCount()).toBe(1) + expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + expect(harness.signalProcessGroup).toHaveBeenCalledTimes(2) + harness.readProcessTableAsync.mockResolvedValue(TABLE) + await vi.advanceTimersByTimeAsync(500) + expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') + expect(vi.getTimerCount()).toBe(0) + }) + + it('resumes a root-only suspension when process group discovery is unavailable throughout', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable')) + harness.flow.pause() + await settled() + harness.flow.resume() + await settled() + expect(harness.kill.mock.calls).toEqual([ + [constants.signals.SIGSTOP], + [constants.signals.SIGCONT] + ]) + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('reapplies pause after a partial resume and keeps the shell stopped until all jobs resume', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + const table = `${TABLE}\n4323 4323 pts/test` + let denyOnce = true + harness.signalProcessGroup.mockImplementation((pgid, signal) => { + if (pgid === 4323 && signal === 'SIGCONT' && denyOnce) { + denyOnce = false + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + }) + harness.flow.pause() + await settled() + harness.reads[0].resolve(table) + await settled() + harness.flow.resume() + await settled() + harness.reads[1].resolve(table) + await settled() + expect(harness.signalProcessGroup.mock.calls.slice(3)).toEqual([ + [4322, 'SIGCONT'], + [4323, 'SIGCONT'] + ]) + expect(vi.getTimerCount()).toBe(1) + harness.flow.pause() + await settled() + harness.reads[2].resolve(table) + await settled() + expect(harness.signalProcessGroup.mock.calls.slice(5)).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4323, 'SIGSTOP'] + ]) + expect(vi.getTimerCount()).toBe(0) + harness.flow.resume() + await settled() + harness.reads[3].resolve(table) + await settled() + expect(harness.signalProcessGroup.mock.calls.slice(8)).toEqual([ + [4322, 'SIGCONT'], + [4323, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + }) + + it('does not delay the shell resume for a job group that already exited', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.signalProcessGroup.mockImplementationOnce(() => { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }) + }) + harness.flow.resume() + await settled() + harness.reads[1].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') + expect(vi.getTimerCount()).toBe(0) + }) + + it('coalesces repeated pressure changes while discovery is pending', async () => { + const harness = createHarness() + harness.flow.pause() + await settled() + for (let i = 0; i < 1_000; i += 1) { + harness.flow.resume() + harness.flow.pause() + } + harness.flow.resume() + await settled() + expect(harness.readProcessTableAsync).toHaveBeenCalledOnce() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + + harness.reads[0].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + expect(harness.kill.mock.calls).toEqual([ + [constants.signals.SIGSTOP], + [constants.signals.SIGCONT] + ]) + + for (let i = 0; i < 20; i += 1) { + harness.flow.pause() + await settled() + harness.reads[2 * i + 1].resolve(TABLE) + await settled() + harness.flow.resume() + await settled() + harness.reads[2 * i + 2].resolve(TABLE) + await settled() + } + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(41) + expect(harness.signalProcessGroup).toHaveBeenCalledTimes(80) + expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') + }) + + it('revalidates group ownership when resuming after a process id is reused', async () => { + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.flow.resume() + await settled() + harness.reads[1].resolve('4321 4321 pts/test T\n4322 4322 pts/other\n4323 4323 pts/test') + await settled() + + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4321, 'SIGCONT'] + ]) + }) + + it('does not resume a still-paused session when pressure returns during a resume scan', async () => { + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.flow.resume() + await settled() + harness.flow.pause() + harness.reads[1].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + + harness.flow.resume() + await settled() + harness.reads[2].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') + }) + + it.each(['shutdown', 'exit'] as const)('ignores a late scan after %s', async (action) => { + const harness = createHarness() + harness.flow.pause() + await settled() + if (action === 'shutdown') { + harness.flow.resumeForShutdown() + expect(harness.reads[0].signal.aborted).toBe(true) + } else { + harness.exit() + } + harness.reads[0].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + expect(harness.kill.mock.calls).toEqual( + action === 'shutdown' + ? [[constants.signals.SIGSTOP], [constants.signals.SIGCONT]] + : [[constants.signals.SIGSTOP]] + ) + }) + + it('releases stopped groups before shutdown while an asynchronous resume is pending', async () => { + const harness = createHarness() + harness.flow.pause() + await settled() + harness.reads[0].resolve(TABLE) + await settled() + harness.flow.resume() + await settled() + harness.flow.resumeForShutdown() + expect(harness.reads[1].signal.aborted).toBe(true) + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + harness.reads[1].resolve(TABLE) + await settled() + expect(harness.signalProcessGroup).toHaveBeenCalledTimes(4) + }) + + it('automatically retries a partially failed resume of a partially stopped tree', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + const table = `${TABLE}\n4323 4323 pts/test S` + harness.signalProcessGroup.mockImplementation((pgid, signal) => { + if (pgid === 4323 && signal === 'SIGSTOP') { + throw new Error('temporary stop failure') + } + }) + harness.flow.pause() + await settled() + harness.reads[0].resolve(table) + await settled() + harness.signalProcessGroup.mockImplementationOnce(() => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + }) + harness.flow.resume() + await settled() + harness.reads[1].resolve(table) + await settled() + await vi.advanceTimersByTimeAsync(500) + harness.reads[2].resolve(table) + await settled() + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4323, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + }) +}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts new file mode 100644 index 00000000000..5b7dfd7a313 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-flow-control.ts @@ -0,0 +1,185 @@ +import { constants } from 'node:os' +import type { WindowsBunPtyJob } from './windows-bun-pty-job' +import { isPosixPtyRootStopped, readPosixPtyProcessTable } from '../../pty/posix-pty-process-groups' + +import { createBunPtyProcessSuspension } from './bun-pty-process-suspension' + +const TRANSITION_RETRY_MS = 500 + +type BunPtyProcessHandle = Readonly<{ + pid: number + kill(signal?: string | number): void + terminal: Readonly<{ closed: boolean; close(): void }> +}> + +export type BunPtyProducerFlowControl = Readonly<{ + pause(): void + resume(): void + resumeForShutdown(): void +}> + +export function createBunPtyProducerFlowControl( + options: Readonly<{ + platform: NodeJS.Platform + processHandle: BunPtyProcessHandle + windowsJob: WindowsBunPtyJob | null + isExited: () => boolean + readProcessTable?: () => string + readProcessTableAsync?: (signal: AbortSignal) => Promise + signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void + }> +): BunPtyProducerFlowControl { + let state: 'running' | 'paused' | 'uncertain' = 'running' + let pauseRequested = false + let shuttingDown = false + let pendingRead: AbortController | undefined + let transitionRetry: ReturnType | undefined + let pauseDenied = false + const signalRoot = (signal: 'SIGSTOP' | 'SIGCONT'): void => { + // The runtime's named STOP/CONT signals are not portable across POSIX platforms. + options.processHandle.kill(constants.signals[signal]) + } + + const suspension = createBunPtyProcessSuspension({ + pid: options.processHandle.pid, + platform: options.platform, + signalRoot, + readProcessTable: options.readProcessTable, + signalProcessGroup: options.signalProcessGroup + }) + const pausePermanentlyDenied = (error: unknown): boolean => + error instanceof Error && 'code' in error && (error.code === 'EPERM' || error.code === 'EACCES') + + const clearTransitionRetry = (): void => { + clearTimeout(transitionRetry) + transitionRetry = undefined + } + + const needsTransition = (): boolean => + !shuttingDown && !options.isExited() && state !== (pauseRequested ? 'paused' : 'running') + + const retryTransition = (): void => { + if (!needsTransition() || transitionRetry) { + return + } + // Callers send transitions once; retain the obligation until fresh ownership confirms every group. + transitionRetry = setTimeout(() => { + transitionRetry = undefined + reconcile() + }, TRANSITION_RETRY_MS) + transitionRetry.unref?.() + } + + const reconcile = (): void => { + if (!needsTransition() || pendingRead) { + return + } + if (options.platform === 'win32') { + const succeeded = pauseRequested ? options.windowsJob?.pause() : options.windowsJob?.resume() + state = succeeded ? (pauseRequested ? 'paused' : 'running') : 'uncertain' + if (pauseRequested && !succeeded) { + pauseRequested = false + } + retryTransition() + return + } + if (pauseRequested && state === 'running') { + try { + signalRoot('SIGSTOP') + state = 'uncertain' + } catch (error) { + if (pausePermanentlyDenied(error)) { + pauseDenied = true + pauseRequested = false + } + retryTransition() + return + } + } + const controller = new AbortController() + pendingRead = controller + // Process groups change as the shell runs jobs; revalidate them without blocking PTY output. + void Promise.resolve() + .then(() => + options.readProcessTableAsync + ? options.readProcessTableAsync(controller.signal) + : options.readProcessTable + ? options.readProcessTable() + : readPosixPtyProcessTable(options.processHandle.pid, controller.signal) + ) + .catch(() => '') + .then((table) => { + pendingRead = undefined + if (!needsTransition()) { + return + } + const nextPaused = pauseRequested + // Partial signals require a fresh transition even if the requested state changes again. + state = 'uncertain' + if (nextPaused) { + // Signal delivery is asynchronous; prove the shell stopped before suspending its jobs. + if (!isPosixPtyRootStopped(table, options.processHandle.pid)) { + retryTransition() + return + } + suspension.signal('SIGSTOP', table, true) + } else if (suspension.hasStoppedGroups()) { + suspension.signal('SIGCONT', table, true) + } else { + signalRoot('SIGCONT') + } + state = nextPaused ? 'paused' : 'running' + }) + .catch((error) => { + if (pauseRequested && pausePermanentlyDenied(error)) { + pauseDenied = true + pauseRequested = false + reconcile() + } else { + retryTransition() + } + }) + } + + return { + pause() { + if (shuttingDown || options.isExited() || pauseDenied) { + return + } + clearTransitionRetry() + pauseRequested = true + reconcile() + }, + resume() { + clearTransitionRetry() + pauseDenied = false + pauseRequested = false + reconcile() + }, + resumeForShutdown() { + clearTransitionRetry() + shuttingDown = true + if (options.platform === 'win32') { + if (!options.isExited()) { + options.windowsJob?.resume() + } + state = 'running' + return + } + pendingRead?.abort() + try { + if (!options.isExited() && state !== 'running') { + // Teardown must release stopped jobs before the root receives its exit signal. + if (suspension.hasStoppedGroups()) { + suspension.signal('SIGCONT') + } else { + signalRoot('SIGCONT') + } + } + } catch { + // A failed resume must not prevent the caller from terminating the PTY. + } + state = 'running' + } + } +} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-pause-retry.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process-pause-retry.test.ts new file mode 100644 index 00000000000..cbda02305ea --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-pause-retry.test.ts @@ -0,0 +1,128 @@ +import { constants } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' + +const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test S' +const settled = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +function createHarness() { + let exited = false + const kill = vi.fn() + const signalProcessGroup = vi.fn() + const readProcessTableAsync = vi.fn<(signal: AbortSignal) => Promise>() + const flow = createBunPtyProducerFlowControl({ + platform: 'linux', + processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } }, + windowsJob: null, + isExited: () => exited, + readProcessTableAsync, + signalProcessGroup + }) + return { flow, kill, readProcessTableAsync, signalProcessGroup, exit: () => (exited = true) } +} + +afterEach(() => vi.useRealTimers()) + +describe('Bun producer pause retry', () => { + it('retries a failed root suspension before attempting any group signals', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.kill.mockImplementationOnce(() => { + throw new Error('temporary signal rejection') + }) + harness.readProcessTableAsync.mockResolvedValue(TABLE) + harness.flow.pause() + await settled() + expect(harness.readProcessTableAsync).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(500) + expect(harness.kill.mock.calls).toEqual([ + [constants.signals.SIGSTOP], + [constants.signals.SIGSTOP] + ]) + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps ownership discovery pending when the stopped root has no controlling tty', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.readProcessTableAsync.mockResolvedValueOnce('4321 4321 ? T').mockResolvedValue(TABLE) + harness.flow.pause() + await settled() + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(500) + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['lookup failure', 'shell still running'])( + 'eventually stops jobs after a transient %s without another pause request', + async (failure) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + if (failure === 'lookup failure') { + harness.readProcessTableAsync.mockRejectedValueOnce(new Error('ps timed out')) + } else { + harness.readProcessTableAsync.mockResolvedValueOnce(TABLE.replace('test T', 'test S')) + } + harness.readProcessTableAsync.mockResolvedValue(TABLE) + harness.flow.pause() + await settled() + expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(499) + expect(harness.readProcessTableAsync).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(harness.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + expect(vi.getTimerCount()).toBe(0) + harness.flow.resume() + await settled() + expect(harness.signalProcessGroup.mock.calls.slice(2)).toEqual([ + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + } + ) + + it.each(['resume', 'shutdown', 'exit'] as const)( + 'bounds failed pause probes and cancels them after %s', + async (action) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createHarness() + harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable')) + harness.flow.pause() + await settled() + await vi.advanceTimersByTimeAsync(2_000) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(5) + expect(vi.getTimerCount()).toBe(1) + expect(harness.signalProcessGroup).not.toHaveBeenCalled() + if (action === 'resume') { + harness.flow.resume() + } else { + if (action === 'exit') { + harness.exit() + } + harness.flow.resumeForShutdown() + } + await settled() + const reads = harness.readProcessTableAsync.mock.calls.length + await vi.advanceTimersByTimeAsync(5_000) + expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(reads) + expect(vi.getTimerCount()).toBe(0) + expect(harness.kill.mock.calls).toEqual( + action === 'exit' + ? [[constants.signals.SIGSTOP]] + : [[constants.signals.SIGSTOP], [constants.signals.SIGCONT]] + ) + } + ) +}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts b/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts new file mode 100644 index 00000000000..b6409795043 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-runtime.ts @@ -0,0 +1,311 @@ +import { constants } from 'node:os' +import { + assignCurrentProcessToBunPtyHostJob, + createWindowsBunPtyJob, + type WindowsBunPtyJob +} from './windows-bun-pty-job' +import { createWindowsBunPtyLaunch, type WindowsBunPtyLaunch } from './windows-bun-pty-launch' +import type { + BunPtyProcess, + BunPtySpawnArgs, + BunSubprocess, + BunTerminal, + BunTerminalOptions, + SpawnBunPtyDeps +} from './bun-pty-process-contract' +import { resolveBunRuntime } from './bun-pty-process-capabilities' +import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' + +export function spawnBunPty(args: BunPtySpawnArgs, deps: SpawnBunPtyDeps = {}): BunPtyProcess { + const runtime = resolveBunRuntime(deps.runtime) + + const platform = deps.platform ?? process.platform + let processHandle: BunSubprocess + let windowsLaunch: WindowsBunPtyLaunch | null = null + let windowsJob: WindowsBunPtyJob | null = null + let windowsTerminal: BunTerminal | null = null + let processExitCode: number | undefined + let terminalFinished = false + let clearInFlight: Promise | null = null + const dataListeners = new Set<(data: string) => void>() + const exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>() + const decoder = new TextDecoder() + let pendingData = '' + let exited = false + let exitCode = 0 + let exitSignal: number | undefined + // Keep a closed Bun native handle from escaping as a daemon RPC failure. + let terminalUnavailable = false + let appliedCols = args.cols + let appliedRows = args.rows + + const emitData = (data: string): void => { + if (dataListeners.size === 0) { + pendingData = (pendingData + data).slice(-512 * 1024) + return + } + for (const listener of dataListeners) { + listener(data) + } + } + const onProcessExit = (code: number): void => { + processExitCode = code + windowsLaunch?.dispose() + if (!windowsTerminal || terminalFinished) { + emitExit(code) + return + } + // ConPTY closes off-thread; retain listeners until its final frame reaches EOF. + if (!windowsTerminal.closed) { + windowsTerminal.close() + } + } + + const emitExit = (code: number): void => { + if (exited) { + return + } + exited = true + producerFlowControl.resumeForShutdown() + windowsLaunch?.readShellProcessId() + exitCode = code + exitSignal = Object.entries(constants.signals).find( + ([name]) => name === processHandle.signalCode + )?.[1] + const pending = decoder.decode() + if (pending) { + emitData(pending) + } + for (const dispose of [ + () => (processHandle.terminal.closed ? undefined : processHandle.terminal.close()), + () => windowsJob?.close() + ]) { + try { + dispose() + } catch (error) { + console.warn('[daemon/pty] PTY cleanup failed:', error) + } + } + for (const listener of exitListeners) { + listener({ exitCode: code, ...(exitSignal === undefined ? {} : { signal: exitSignal }) }) + } + dataListeners.clear() + exitListeners.clear() + } + + if (platform === 'win32') { + if (!(deps.assignHostJob ?? assignCurrentProcessToBunPtyHostJob)()) { + throw new Error('Windows Bun PTY host crash ownership is unavailable') + } + windowsLaunch = (deps.createWindowsLaunch ?? createWindowsBunPtyLaunch)(args) + } + try { + const terminalOptions: BunTerminalOptions = { + cols: args.cols, + rows: args.rows, + name: args.env.TERM ?? 'xterm-256color', + data: (_terminal, data) => { + const decoded = decoder.decode(data, { stream: true }) + if (decoded) { + emitData(decoded) + } + }, + exit() { + terminalFinished = true + if (processExitCode !== undefined) { + emitExit(processExitCode) + } + } + } + // Inline Bun terminals cannot be reused by the Windows clear command. + if (windowsLaunch) { + windowsTerminal = new runtime.Terminal(terminalOptions) + } + processHandle = runtime.spawn(windowsLaunch?.command ?? [args.file, ...args.args], { + cwd: args.cwd, + env: windowsLaunch?.env ?? args.env, + ...(windowsLaunch + ? { + windowsVerbatimArguments: windowsLaunch.windowsVerbatimArguments + } + : {}), + terminal: windowsTerminal ?? terminalOptions + }) + } catch (error) { + windowsTerminal?.close() + windowsLaunch?.dispose() + throw error + } + if (windowsLaunch) { + try { + windowsJob = (deps.createJob ?? createWindowsBunPtyJob)(processHandle.pid) + if (!windowsJob) { + throw new Error('Windows Bun PTY job ownership is unavailable') + } + windowsLaunch.release() + } catch (error) { + windowsJob?.terminate() + try { + processHandle.kill('SIGTERM') + } catch { + // The failed gate release still owns cleanup through the job when available. + } + if (!processHandle.terminal.closed) { + processHandle.terminal.close() + } + windowsJob?.close() + windowsLaunch.dispose() + // A running gate can temporarily lock its private working directory on Windows. + const disposeLaunch = (): void => windowsLaunch?.dispose() + void processHandle.exited.then(disposeLaunch, disposeLaunch) + throw error + } + } + void processHandle.exited.then(onProcessExit, () => onProcessExit(1)) + + const producerFlowControl = createBunPtyProducerFlowControl({ + platform, + processHandle, + windowsJob, + isExited: () => exited, + ...(deps.readProcessTable ? { readProcessTable: deps.readProcessTable } : {}), + ...(deps.signalProcessGroup ? { signalProcessGroup: deps.signalProcessGroup } : {}) + }) + + const windowsCapabilities = windowsJob + ? { + waitForSpawn: () => windowsLaunch?.waitForSpawn(processHandle.exited) ?? Promise.resolve(), + terminateOwnedTree: () => windowsJob?.terminate() ?? 'unavailable', + listOwnedProcessIds: () => windowsJob?.listProcessIds() ?? null, + jobRootProcessIsWrapper: true as const, + signalProcess(signal: string) { + if (signal === 'SIGWINCH') { + return + } + if (windowsJob?.terminate() === 'terminated') { + return + } + try { + processHandle.kill(signal) + } finally { + if (!processHandle.terminal.closed) { + processHandle.terminal.close() + } + } + } + } + : {} + + const clearCapability = windowsLaunch + ? { + clear() { + if (exited || clearInFlight) { + return + } + try { + const clearProcess = runtime.spawn(windowsLaunch.clearCommand, { + cwd: args.cwd, + env: args.env, + terminal: processHandle.terminal, + windowsVerbatimArguments: true + }) + clearInFlight = clearProcess.exited + const settled = (): void => { + clearInFlight = null + } + void clearInFlight.then(settled, settled) + } catch { + clearInFlight = null + } + } + } + : {} + + const terminate = (signal: string): void => { + producerFlowControl.resumeForShutdown() + const treeTerminated = windowsJob?.terminate() === 'terminated' + try { + processHandle.kill(signal) + } catch (error) { + if (!treeTerminated) { + throw error + } + } + } + + return { + pid: processHandle.pid, + get shellProcessId() { + return windowsLaunch?.readShellProcessId() + }, + handleFlowControl: false, + processNameIsSpawnFile: true, + clear() {}, + process: args.file, + get cols() { + return appliedCols + }, + get rows() { + return appliedRows + }, + onData(listener) { + if (pendingData) { + const data = pendingData + pendingData = '' + listener(data) + } + if (exited) { + return { dispose() {} } + } + dataListeners.add(listener) + return { dispose: () => dataListeners.delete(listener) } + }, + onExit(listener) { + if (exited) { + listener({ exitCode, ...(exitSignal === undefined ? {} : { signal: exitSignal }) }) + return { dispose() {} } + } + exitListeners.add(listener) + return { dispose: () => exitListeners.delete(listener) } + }, + write(data) { + if (exited || terminalUnavailable || processHandle.terminal.closed) { + return + } + try { + processHandle.terminal.write(data) + } catch { + terminalUnavailable = true + } + }, + resize(cols, rows) { + if (exited || terminalUnavailable || processHandle.terminal.closed) { + return + } + try { + processHandle.terminal.resize(cols, rows) + appliedCols = cols + appliedRows = rows + } catch { + terminalUnavailable = true + } + }, + ...clearCapability, + ...producerFlowControl, + ...windowsCapabilities, + // Interactive POSIX shells ignore SIGTERM. + kill(signal = platform === 'win32' ? 'SIGTERM' : 'SIGHUP') { + if (!exited) { + terminate(signal) + } + }, + destroy() { + if (!exited) { + terminate(platform === 'win32' ? 'SIGTERM' : 'SIGHUP') + } + if (!processHandle.terminal.closed) { + processHandle.terminal.close() + } + } + } +} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts new file mode 100644 index 00000000000..da7bf0c09e3 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts @@ -0,0 +1,214 @@ +import { constants } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control' + +const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test S\n4323 4323 pts/test T' +const settled = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +function harness(platform: NodeJS.Platform = 'linux') { + let exited = false + const kill = vi.fn() + const signalProcessGroup = vi.fn() + const readProcessTableAsync = vi.fn(async () => TABLE) + const windowsJob = { + listProcessIds: () => [], + pause: vi.fn(() => true), + resume: vi.fn(() => true), + terminate: () => 'terminated' as const, + close() {} + } + const flow = createBunPtyProducerFlowControl({ + platform, + processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } }, + windowsJob, + isExited: () => exited, + readProcessTable: () => TABLE, + readProcessTableAsync, + signalProcessGroup + }) + return { + flow, + kill, + signalProcessGroup, + readProcessTableAsync, + windowsJob, + exit: () => (exited = true) + } +} + +afterEach(() => vi.useRealTimers()) + +describe('flow-control suspension ownership', () => { + it.each(['resume', 'shutdown'] as const)( + 'preserves a Ctrl-Z stopped job during %s', + async (action) => { + const h = harness() + h.flow.pause() + await settled() + h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4322 pts/test S', '4322 pts/test T')) + if (action === 'resume') { + h.flow.resume() + } else { + h.flow.resumeForShutdown() + } + await settled() + expect(h.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + } + ) + + it('does not resume a group it already released when another group needs a retry', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness() + h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4323 pts/test T', '4323 pts/test S')) + let failed = false + h.signalProcessGroup.mockImplementation((pgid, signal) => { + if (pgid === 4323 && signal === 'SIGCONT' && !failed) { + failed = true + throw new Error('temporary resume failure') + } + }) + h.flow.pause() + await settled() + h.flow.resume() + await settled() + // The user can suspend a job again after its first successful resume. + h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4322 pts/test S', '4322 pts/test T')) + await vi.advanceTimersByTimeAsync(500) + expect( + h.signalProcessGroup.mock.calls.filter( + ([pid, signal]) => pid === 4322 && signal === 'SIGCONT' + ) + ).toHaveLength(1) + expect(h.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT') + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['EPERM', 'EACCES'])('does not retry a root pause denied with %s', async (code) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness() + h.kill.mockImplementationOnce(() => { + throw Object.assign(new Error('denied'), { code }) + }) + h.flow.pause() + await settled() + h.flow.pause() + await vi.advanceTimersByTimeAsync(30_000) + expect(h.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + expect(h.readProcessTableAsync).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + h.flow.resume() + h.flow.pause() + await settled() + expect(h.kill).toHaveBeenCalledTimes(2) + h.flow.resumeForShutdown() + }) + + it('rolls back acquired stops after a denied job pause without repeatedly scanning or resuming the denied job', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness() + h.readProcessTableAsync.mockResolvedValue(`${TABLE}\n4324 4324 pts/test S`) + h.signalProcessGroup.mockImplementation((pgid, signal) => { + if (pgid === 4324 && signal === 'SIGSTOP') { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + }) + h.flow.pause() + await settled() + await vi.advanceTimersByTimeAsync(30_000) + expect(h.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4324, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + expect(h.readProcessTableAsync).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) + + it('retains the resume obligation when rollback after a denied pause also fails', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness() + let failed = false + h.signalProcessGroup.mockImplementation((pgid, signal) => { + if (pgid === 4322 && signal === 'SIGSTOP') { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + if (pgid === 4321 && signal === 'SIGCONT' && !failed) { + failed = true + throw Object.assign(new Error('resume denied'), { code: 'EPERM' }) + } + }) + h.flow.pause() + await settled() + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(500) + expect(h.signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4321, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + expect(vi.getTimerCount()).toBe(0) + }) +}) + +describe('Windows resume retries', () => { + it('releases a failed partial pause without repeatedly attempting the denied pause', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness('win32') + h.windowsJob.pause.mockReturnValueOnce(false) + h.windowsJob.resume.mockReturnValueOnce(false) + h.flow.pause() + await vi.advanceTimersByTimeAsync(500) + expect(h.windowsJob.resume).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(500) + expect(h.windowsJob.resume).toHaveBeenCalledTimes(2) + expect(h.windowsJob.pause).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + h.flow.pause() + expect(h.windowsJob.pause).toHaveBeenCalledTimes(2) + h.flow.resumeForShutdown() + }) + + it('retries a failed resume without another caller transition', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness('win32') + h.windowsJob.resume.mockReturnValueOnce(false) + h.flow.pause() + h.flow.resume() + expect(h.windowsJob.resume).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(500) + expect(h.windowsJob.resume).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + expect(h.readProcessTableAsync).not.toHaveBeenCalled() + }) + + it.each(['pause', 'shutdown', 'exit'] as const)( + 'cancels stale resume retries after %s', + async (action) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const h = harness('win32') + h.windowsJob.resume.mockReturnValue(false) + h.flow.pause() + h.flow.resume() + if (action === 'pause') { + h.flow.pause() + } else { + if (action === 'exit') { + h.exit() + } + h.flow.resumeForShutdown() + } + const resumes = h.windowsJob.resume.mock.calls.length + await vi.advanceTimersByTimeAsync(5_000) + expect(h.windowsJob.resume).toHaveBeenCalledTimes(resumes) + expect(vi.getTimerCount()).toBe(0) + } + ) +}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process-suspension.ts b/src/main/daemon/pty-subprocess/bun-pty-process-suspension.ts new file mode 100644 index 00000000000..3fc20931d5c --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process-suspension.ts @@ -0,0 +1,78 @@ +import { + getPosixPtyStoppedJobGroups, + signalPosixPtyProcessGroups +} from '../../pty/posix-pty-process-groups' + +export function createBunPtyProcessSuspension(options: { + pid: number + platform: NodeJS.Platform + signalRoot: (signal: 'SIGSTOP' | 'SIGCONT') => void + readProcessTable?: () => string + signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void +}) { + const stoppedGroups = new Set() + return { + hasStoppedGroups: () => stoppedGroups.size > 0, + signal(signal: 'SIGSTOP' | 'SIGCONT', table?: string, requireGroups = false): void { + const alreadyStopped = + signal === 'SIGSTOP' && table !== undefined + ? getPosixPtyStoppedJobGroups(table, options.pid) + : new Set() + let resumeFailed = false + signalPosixPtyProcessGroups( + options.pid, + signal, + () => { + if (requireGroups) { + throw new Error('Paused PTY group ownership is unavailable') + } + options.signalRoot(signal) + }, + { + platform: options.platform, + ...(table !== undefined + ? { readProcessTable: () => table } + : options.readProcessTable + ? { readProcessTable: options.readProcessTable } + : {}), + signalProcessGroup(pgid) { + if ( + signal === 'SIGSTOP' + ? alreadyStopped.has(pgid) && !stoppedGroups.has(pgid) + : !stoppedGroups.has(pgid) + ) { + return + } + // Keep the shell stopped until every preceding job group has resumed. + if (signal === 'SIGCONT' && requireGroups && resumeFailed) { + throw new Error('An earlier PTY group could not be resumed') + } + try { + if (options.signalProcessGroup) { + options.signalProcessGroup(pgid, signal) + } else { + process.kill(-pgid, signal) + } + } catch (error) { + const gone = error instanceof Error && 'code' in error && error.code === 'ESRCH' + if (gone) { + stoppedGroups.delete(pgid) + } + resumeFailed = !gone + throw error + } + if (signal === 'SIGSTOP') { + stoppedGroups.add(pgid) + } else { + stoppedGroups.delete(pgid) + } + } + } + ) + if (signal === 'SIGCONT') { + // A fresh successful scan also retires groups that no longer belong to this terminal. + stoppedGroups.clear() + } + } + } +} diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts new file mode 100644 index 00000000000..1c4f0436d6f --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts @@ -0,0 +1,298 @@ +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess, runProcessSync } from '../../../shared/child-process/run-process' +import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../../shared/orcad-bun-runtime' +import { removeTreeSync } from '../../../shared/windows-transient-lock-removal' + +const runtimePath = + process.env.BUN_EXECUTABLE ?? + resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform)) + +async function runTerminalScript(script: string): Promise { + expect(runProcessSync({ program: runtimePath, args: ['--version'] }).stdout.trim()).toBe( + ORCAD_BUN_VERSION + ) + const directory = mkdtempSync(join(tmpdir(), 'orca-bun-terminal-')) + try { + const entry = join(directory, 'terminal.cjs') + writeFileSync( + entry, + [ + `const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))})`, + `const args = {file: process.execPath, cwd: ${JSON.stringify(directory)}, env: process.env, cols: 80, rows: 24}`, + script + ].join('\n') + ) + const result = await runProcess({ program: runtimePath, args: [entry], timeoutMs: 30_000 }) + expect(result.timedOut).toBe(false) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout) + } finally { + removeTreeSync(directory) + } +} + +describe.skipIf(!existsSync(runtimePath) || process.platform === 'win32')( + 'real Bun terminal', + () => { + it('drains multi-byte output before publishing process exit and applies resize', async () => { + const result = await runTerminalScript(` + const expected = '⌘状態'.repeat(200_000) + const proc = spawnBunPty({...args, args: ['-e', 'process.stdout.write("⌘状態".repeat(200000));process.exitCode=17']}) + let output = '' + proc.resize(103, 37) + proc.onData(data => output += data) + proc.onExit(event => { + console.log(JSON.stringify({event, exact: output === expected, cols:proc.cols, rows:proc.rows})) + proc.destroy() + }) + `) + expect(result).toEqual({ event: { exitCode: 17 }, exact: true, cols: 103, rows: 37 }) + }) + + it('receives the real shell identity from a gated Bun subprocess', async () => { + const result = await runTerminalScript(` + const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) + const proc = spawnBunPty({...args,args:['-e','setTimeout(()=>{process.exitCode=17},100)']}, { + platform:'win32', assignHostJob:()=>true, + createJob:()=>({listProcessIds:()=>[], pause:()=>true,resume:()=>true,terminate:()=> 'terminated',close(){}}), + createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, { + runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} + }) + }) + proc.onExit(event => { + console.log(JSON.stringify({event, shellIdentified:proc.shellProcessId>0 && proc.shellProcessId!==proc.pid})) + proc.destroy() + }) + `) + expect(result).toEqual({ event: { exitCode: 17 }, shellIdentified: true }) + }) + + it('reports signal termination distinctly from an ordinary exit', async () => { + const result = await runTerminalScript(` + const proc = spawnBunPty({...args,args:['-e', 'console.log("ready");setInterval(()=>{},1000)']}) + proc.onData(() => proc.kill('SIGTERM')) + proc.onExit(event => { console.log(JSON.stringify(event));proc.destroy() }) + `) + expect(result).toEqual({ exitCode: 143, signal: 15 }) + }) + + it('pauses and resumes the owned process when process discovery is unavailable', async () => { + const result = await runTerminalScript(` + const expected = 'ready' + 'x'.repeat(1024 * 1024) + const continueOutput = require('node:path').join(args.cwd, 'continue-output') + const proc = spawnBunPty({...args,env:{...args.env,ORCA_TEST_CONTINUE:continueOutput},args:['-e','process.stdout.write("ready");const timer=setInterval(()=>{if(!require("node:fs").existsSync(process.env.ORCA_TEST_CONTINUE))return;clearInterval(timer);process.stdout.write("x".repeat(1024*1024))},1)']},{readProcessTable:()=>''}) + let output = '', paused = false, stable = false + proc.onData(data => { + output += data + if (paused) return + paused = true + proc.pause() + setTimeout(() => { + const settled = output.length + require('node:fs').writeFileSync(continueOutput, 'continue') + setTimeout(() => { stable = output.length === settled;proc.resume() }, 150) + }, 150) + }) + proc.onExit(event => { + console.log(JSON.stringify({event,stable,exact:output===expected})) + proc.destroy() + }) + `) + expect(result).toEqual({ event: { exitCode: 0 }, stable: true, exact: true }) + }) + + it.skipIf(!existsSync('/bin/bash')).each([ + [false, 0], + [false, 100], + [true, 0], + [true, 100] + ] as const)( + 'stops foreground and background floods without losing output (resume failure: %s, signal gap: %sms)', + async (rejectFirstResume, stopSignalGapMs) => { + const result = await runTerminalScript(` + const expected = 16 * 1024 * 1024 + const {join} = require('node:path') + const {writeFileSync,existsSync} = require('node:fs') + const producer = join(args.cwd, 'producer.cjs') + const backgroundReady = join(args.cwd, 'background-ready') + const foregroundReady = join(args.cwd, 'foreground-ready') + const go = join(args.cwd, 'go') + const continueOutput = join(args.cwd, 'continue-output') + writeFileSync(producer, [ + 'const {writeFileSync,existsSync}=require("node:fs")', + 'writeFileSync(process.argv[2],"ready")', + 'const deadline=setTimeout(()=>process.exit(97),10000)', + 'const ready=setInterval(()=>{if(!existsSync(process.argv[3]))return;clearInterval(ready);process.stdout.write("x".repeat(65536));const continued=setInterval(()=>{if(!existsSync(process.argv[4]))return;clearInterval(continued);clearTimeout(deadline);let count=1;const timer=setInterval(()=>{process.stdout.write("x".repeat(65536));if(++count===128)clearInterval(timer)},1)},1)},1)' + ].join(';')) + const groups = new Set() + let bytes = 0, paused = false, settledBytes = 0, stable = false, verifying = false, rejectedResume = false + const proc = spawnBunPty({ + ...args, file:'/bin/bash', + args:['--noprofile','--norc','-i','-c','exec 2>/dev/null; "$ORCA_TEST_RUNTIME" "$ORCA_TEST_PRODUCER" "$ORCA_TEST_BACKGROUND_READY" "$ORCA_TEST_GO" "$ORCA_TEST_CONTINUE" & "$ORCA_TEST_RUNTIME" "$ORCA_TEST_PRODUCER" "$ORCA_TEST_FOREGROUND_READY" "$ORCA_TEST_GO" "$ORCA_TEST_CONTINUE"; wait'], + env:{...args.env,ORCA_TEST_RUNTIME:process.execPath,ORCA_TEST_PRODUCER:producer,ORCA_TEST_BACKGROUND_READY:backgroundReady,ORCA_TEST_FOREGROUND_READY:foregroundReady,ORCA_TEST_GO:go,ORCA_TEST_CONTINUE:continueOutput} + },{signalProcessGroup:(pgid,signal)=>{ + if (signal === 'SIGCONT' && ${rejectFirstResume} && !rejectedResume) { + rejectedResume = true + throw Object.assign(new Error('transient resume failure'), {code:'EPERM'}) + } + process.kill(-pgid,signal) + if (signal === 'SIGSTOP') { + groups.add(pgid) + // Give Bash time to react between signals; stopping its jobs first can end its wait. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${stopSignalGapMs}) + } + if (groups.size < 3 || verifying) return + verifying = true + writeFileSync(continueOutput, 'continue') + setTimeout(() => { + settledBytes = bytes + setTimeout(() => { stable = bytes === settledBytes;proc.resume() }, 150) + },150) + }}) + const ready = setInterval(() => { + if (!existsSync(backgroundReady) || !existsSync(foregroundReady)) return + clearInterval(ready) + writeFileSync(go, 'go') + }, 5) + let beats = 0 + const heartbeat = setInterval(() => beats++, 5) + proc.onData(data => { + bytes += data.length + // Drain both initial writes before measuring whether stopped producers emit more. + if (!paused && bytes === 2 * 65536) { + paused = true + proc.pause() + } + }) + proc.onExit(event => { + clearInterval(ready) + clearInterval(heartbeat) + console.log(JSON.stringify({event,stable,exact:bytes===expected,pausedBeforeExit:settledBytes10,jobControlGroups:groups.size>=3,rejectedResume})) + proc.destroy() + }) + `) + expect(result).toEqual({ + event: { exitCode: 0 }, + stable: true, + exact: true, + pausedBeforeExit: true, + responsive: true, + jobControlGroups: true, + rejectedResume: rejectFirstResume + }) + } + ) + } +) + +describe.skipIf(!existsSync(runtimePath) || process.platform !== 'win32')( + 'native Windows Bun terminal', + () => { + it('falls back after actual shell spawn rejection and cleans each private launch directory', async () => { + const result = await runTerminalScript(` + const {spawnNativeDaemonPty} = require(${JSON.stringify(join(__dirname, 'native-pty-spawn.ts'))}) + const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) + const {existsSync} = require('node:fs') + const {dirname,join} = require('node:path') + const directories = [] + const attempts = [join(args.cwd,'missing-pwsh.exe'),join(args.cwd,'missing-powershell.exe'),process.execPath].map(shellPath=>({ + shellPath,shellArgs:['-e','process.exitCode=17'],effectiveCwd:args.cwd,validationCwd:args.cwd,startupCommandDeliveredInShellArgs:true + })) + spawnNativeDaemonPty({ + shellPath:attempts[0].shellPath,shellArgs:attempts[0].shellArgs,spawnCwd:args.cwd, + env:args.env,cols:80,rows:24,windowsFallbackAttempts:attempts + }, {canUseBunPty:()=>true, spawnBunPty:options=>spawnBunPty(options, { + createWindowsLaunch:launchArgs=>{ + const launch = createWindowsBunPtyLaunch(launchArgs, { + runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} + }) + directories.push(dirname(launch.command.at(-1))) + return launch + } + })}).then(({process:proc,shellPath})=>{ + proc.onExit(event=>{ + console.log(JSON.stringify({event,fallback:shellPath===process.execPath,attempts:directories.length,cleaned:directories.every(path=>!existsSync(path))})) + proc.destroy() + }) + }).catch(error=>{console.error(error);process.exitCode=1}) + `) + expect(result).toEqual({ + event: { exitCode: 17 }, + fallback: true, + attempts: 3, + cleaned: true + }) + }, 35_000) + + it('enumerates and suspends a native job with more than 64 processes', async () => { + const result = await runTerminalScript(` + const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) + const script = 'for(let i=0;i<65;i++)Bun.spawn([process.execPath,"-e","setInterval(()=>{},1000)"],{stdin:"ignore",stdout:"ignore",stderr:"ignore"});setInterval(()=>console.log("tick"),10)' + const proc = spawnBunPty({...args,args:['-e',script]}, { + createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, { + runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} + }) + }) + let bytes=0,started=false,evidence + proc.onData(data=>{ + bytes+=data.length + if(started || !data.includes('tick'))return + const members=proc.listOwnedProcessIds() + if(!members || members.length<67)return + started=true + proc.pause() + setTimeout(()=>{ + const pausedBytes=bytes + setTimeout(()=>{ + const stopped=bytes===pausedBytes + proc.resume() + setTimeout(()=>{ + evidence={members:members.length,stopped,resumed:bytes>pausedBytes} + proc.kill() + },150) + },150) + },150) + }) + proc.onExit(()=>{ + console.log(JSON.stringify(evidence)) + proc.destroy() + }) + `) + expect(result).toEqual({ members: 67, stopped: true, resumed: true }) + }, 35_000) + + it('opens ConPTY without IPC and identifies the shell inside its job', async () => { + const result = await runTerminalScript(` + const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))}) + const proc = spawnBunPty({...args,args:['-e','console.log("ready");setInterval(()=>{},1000)']}, { + createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, { + runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))} + }) + }) + let output = '', evidence + proc.onData(data => output += data) + const timer = setInterval(() => { + const shell = proc.shellProcessId + const members = proc.listOwnedProcessIds() + if (!output.includes('ready') || !shell || !members?.includes(shell)) return + clearInterval(timer) + evidence = {distinctShell:shell!==proc.pid, gateOwned:members.includes(proc.pid), shellOwned:true} + proc.kill() + }, 10) + proc.onExit(event => { + clearInterval(timer) + console.log(JSON.stringify({evidence, exited:event.exitCode!==undefined})) + proc.destroy() + }) + `) + expect(result).toEqual({ + evidence: { distinctShell: true, gateOwned: true, shellOwned: true }, + exited: true + }) + }) + } +) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.test.ts b/src/main/daemon/pty-subprocess/bun-pty-process.test.ts new file mode 100644 index 00000000000..6726524f53d --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process.test.ts @@ -0,0 +1,652 @@ +import { constants } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { canUseBunPty, spawnBunPty } from './bun-pty-process' +import type { BunRuntime, BunTerminalOptions } from './bun-pty-process-contract' +import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' +import * as posixPtyGroups from '../../pty/posix-pty-process-groups' + +type FakeTerminal = { + closed: boolean + write(data: string | ArrayBufferView): number + resize(cols: number, rows: number): void + close(): void +} + +let testRuntime: NonNullable[1]>['runtime'] + +function createBunHarness({ closeImmediately = true } = {}) { + let resolveExit: (code: number) => void = () => {} + let windowsTerminalOptions: BunTerminalOptions | undefined + const terminal: FakeTerminal = { + closed: false, + write: vi.fn(() => 1), + resize: vi.fn(), + close: vi.fn(function (this: FakeTerminal) { + this.closed = true + if (closeImmediately) { + windowsTerminalOptions?.exit?.(terminal, 0, null) + } + }) + } + const processHandle = { + pid: 4321, + terminal, + kill: vi.fn(), + exited: new Promise((resolve) => { + resolveExit = resolve + }) + } + const spawn = vi.fn( + (_command: string[], _options: Parameters[1]) => processHandle + ) + testRuntime = { + Terminal: class { + closed = false + write = terminal.write + resize = terminal.resize + close = terminal.close + constructor(options: BunTerminalOptions) { + windowsTerminalOptions = options + return terminal + } + }, + spawn + } + const emitData = (data: Uint8Array): void => { + const options = spawn.mock.calls[0]?.[1] + const callbacks = + windowsTerminalOptions ?? + (options && 'data' in options.terminal ? options.terminal : undefined) + if (!callbacks) { + throw new Error('missing terminal callbacks') + } + callbacks.data(terminal, data) + } + return { + processHandle, + resolveExit, + spawn, + terminal, + emitData, + finishTerminal: () => windowsTerminalOptions?.exit?.(terminal, 0, null) + } +} + +function spawn(deps?: Parameters[1]) { + return spawnBunPty( + { + file: '/bin/sh', + args: ['-l'], + cwd: '/tmp', + env: { TERM: 'xterm-256color' }, + cols: 80, + rows: 24 + }, + { platform: 'linux', runtime: testRuntime, ...deps } + ) +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + testRuntime = undefined +}) + +describe('Bun.Terminal PTY adapter', () => { + it('cancels a pending ownership lookup on natural exit without delivering a late stop', async () => { + const harness = createBunHarness() + let finishRead: (table: string) => void = () => {} + const read = vi.spyOn(posixPtyGroups, 'readPosixPtyProcessTable').mockImplementation( + () => + new Promise((resolve) => { + finishRead = resolve + }) + ) + const signalProcessGroup = vi.fn() + const proc = spawn({ signalProcessGroup }) + proc.pause() + await new Promise((resolve) => setImmediate(resolve)) + const signal = read.mock.calls[0][1] + expect(signal?.aborted).toBe(false) + harness.resolveExit(0) + await harness.processHandle.exited + expect(signal?.aborted).toBe(true) + finishRead('4321 4321 pts/test T\n4322 4322 pts/test') + await new Promise((resolve) => setImmediate(resolve)) + expect(signalProcessGroup).not.toHaveBeenCalled() + expect(harness.processHandle.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + }) + + it('cancels a queued resume retry immediately on natural exit', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const harness = createBunHarness() + const read = vi + .spyOn(posixPtyGroups, 'readPosixPtyProcessTable') + .mockResolvedValueOnce('4321 4321 pts/test T\n4322 4322 pts/test') + .mockRejectedValueOnce(new Error('temporary ps failure')) + const signalProcessGroup = vi.fn() + const proc = spawn({ signalProcessGroup }) + proc.pause() + await new Promise((resolve) => setImmediate(resolve)) + proc.resume() + await new Promise((resolve) => setImmediate(resolve)) + expect(vi.getTimerCount()).toBe(1) + harness.resolveExit(0) + await harness.processHandle.exited + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(5_000) + expect(read).toHaveBeenCalledTimes(2) + expect(signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'] + ]) + expect(harness.processHandle.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]]) + }) + + it('exposes initial and successfully applied dimensions for terminal inspection', () => { + const harness = createBunHarness() + const proc = spawn() + expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 80, rows: 24 }) + proc.resize(103, 37) + expect(harness.terminal.resize).toHaveBeenCalledWith(103, 37) + expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 103, rows: 37 }) + }) + + it.each(['closed', 'exited', 'failed'] as const)( + 'retains last applied dimensions when resize is %s', + async (reason) => { + const harness = createBunHarness() + const proc = spawn() + proc.resize(103, 37) + if (reason === 'closed') { + harness.terminal.closed = true + } + if (reason === 'exited') { + harness.resolveExit(0) + await harness.processHandle.exited + } + if (reason === 'failed') { + vi.mocked(harness.terminal.resize).mockImplementationOnce(() => { + throw new Error('closed') + }) + } + proc.resize(120, 40) + expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 103, rows: 37 }) + } + ) + + it('requires Bun.Terminal as well as Bun.spawn', () => { + const spawn = vi.fn() + expect(canUseBunPty({ spawn })).toBe(false) + expect(canUseBunPty({ Terminal: class {}, spawn })).toBe(true) + }) + + it('streams split UTF-8 and reports exit to current and late listeners', async () => { + const harness = createBunHarness() + const proc = spawn() + const onData = vi.fn() + const onExit = vi.fn() + proc.onData(onData) + proc.onExit(onExit) + + const bytes = new TextEncoder().encode('⌘状') + harness.emitData(bytes.slice(0, 2)) + expect(onData).not.toHaveBeenCalled() + harness.emitData(bytes.slice(2)) + expect(onData).toHaveBeenCalledWith('⌘状') + + harness.resolveExit(7) + await harness.processHandle.exited + await Promise.resolve() + expect(onExit).toHaveBeenCalledWith({ exitCode: 7 }) + + const lateExit = vi.fn() + proc.onExit(lateExit) + expect(lateExit).toHaveBeenCalledWith({ exitCode: 7 }) + }) + + it('preserves output arriving before the first data listener', () => { + const harness = createBunHarness() + const proc = spawn() + harness.emitData(new TextEncoder().encode('startup output')) + const listener = vi.fn() + proc.onData(listener) + expect(listener).toHaveBeenCalledWith('startup output') + }) + + it('preserves signal termination and never signals the exited handle during disposal', async () => { + const harness = createBunHarness() + const proc = spawn() + Object.assign(harness.processHandle, { signalCode: 'SIGTERM' }) + harness.resolveExit(143) + await harness.processHandle.exited + await Promise.resolve() + const listener = vi.fn() + proc.onExit(listener) + proc.destroy() + expect(listener).toHaveBeenCalledWith({ exitCode: 143, signal: 15 }) + expect(harness.processHandle.kill).not.toHaveBeenCalled() + expect(harness.terminal.close).toHaveBeenCalledOnce() + }) + + it('disposes data and exit listeners without retaining them', async () => { + const harness = createBunHarness() + const proc = spawn() + const onData = vi.fn() + const onExit = vi.fn() + const dataSubscription = proc.onData(onData) + const exitSubscription = proc.onExit(onExit) + + dataSubscription.dispose() + exitSubscription.dispose() + harness.emitData(new TextEncoder().encode('ignored')) + harness.resolveExit(0) + await harness.processHandle.exited + await Promise.resolve() + + expect(onData).not.toHaveBeenCalled() + expect(onExit).not.toHaveBeenCalled() + }) + + it.each(['darwin', 'linux'] as const)( + 'forwards input, resize, hangup, explicit signals, and destroy on %s', + (platform) => { + const harness = createBunHarness() + const proc = spawn({ platform }) + + proc.write('hello') + proc.resize(120, 40) + proc.kill() + proc.kill('SIGTERM') + proc.kill('SIGINT') + proc.kill('SIGKILL') + proc.destroy() + + expect(harness.terminal.write).toHaveBeenCalledWith('hello') + expect(harness.terminal.resize).toHaveBeenCalledWith(120, 40) + expect(harness.processHandle.kill.mock.calls).toEqual([ + ['SIGHUP'], + ['SIGTERM'], + ['SIGINT'], + ['SIGKILL'], + ['SIGHUP'] + ]) + expect(harness.terminal.close).toHaveBeenCalledOnce() + } + ) + + it('destroys a still-running process even if its terminal has already closed', () => { + const harness = createBunHarness() + const proc = spawn() + harness.terminal.closed = true + proc.destroy() + expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGHUP') + expect(harness.terminal.close).not.toHaveBeenCalled() + }) + + it('contains a native terminal write failure and suppresses later writes', () => { + const harness = createBunHarness() + harness.terminal.write = vi.fn(() => { + throw new Error('terminal closed') + }) + const proc = spawn() + + expect(() => proc.write('first')).not.toThrow() + proc.write('second') + + expect(harness.terminal.write).toHaveBeenCalledOnce() + }) + + it('contains a native terminal resize failure and suppresses later resizes', () => { + const harness = createBunHarness() + harness.terminal.resize = vi.fn(() => { + throw new Error('terminal closed') + }) + const proc = spawn() + + expect(() => proc.resize(120, 40)).not.toThrow() + proc.resize(100, 30) + + expect(harness.terminal.resize).toHaveBeenCalledOnce() + }) + + it('pauses and resumes the POSIX producer process group once per transition', async () => { + createBunHarness() + const signalProcessGroup = vi.fn() + const proc = spawn({ + readProcessTable: () => ' 4321 4321 pts/test T\n 4322 4322 pts/test', + signalProcessGroup + }) + + proc.pause() + proc.pause() + await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(2)) + proc.resume() + proc.resume() + await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(4)) + + expect(signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + }) + + it('resumes a paused process group before graceful shutdown', async () => { + const harness = createBunHarness() + const signalProcessGroup = vi.fn() + const proc = spawn({ + readProcessTable: () => ' 4321 4321 pts/test T\n 4322 4322 pts/test', + signalProcessGroup + }) + + proc.pause() + await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(2)) + proc.kill() + + expect(signalProcessGroup.mock.calls).toEqual([ + [4321, 'SIGSTOP'], + [4322, 'SIGSTOP'], + [4322, 'SIGCONT'], + [4321, 'SIGCONT'] + ]) + expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGHUP') + }) + + it('falls back to Bun process signals when group signaling is unavailable', async () => { + const harness = createBunHarness() + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('not supported'), { code: 'EINVAL' }) + }) + const proc = spawn({ readProcessTable: () => '' }) + + proc.pause() + await vi.waitFor(() => + expect(harness.processHandle.kill).toHaveBeenCalledWith(constants.signals.SIGSTOP) + ) + proc.resume() + await vi.waitFor(() => + expect(harness.processHandle.kill).toHaveBeenCalledWith(constants.signals.SIGCONT) + ) + + expect(harness.processHandle.kill.mock.calls).toEqual([ + [constants.signals.SIGSTOP], + [constants.signals.SIGCONT] + ]) + }) + + it('gates a Windows shell behind exact job ownership and exposes owned capabilities', async () => { + const harness = createBunHarness({ closeImmediately: false }) + const assignHostJob = vi.fn(() => true) + const release = vi.fn() + const dispose = vi.fn() + const waitForSpawn = vi.fn(async () => {}) + let reportedShellPid: number | undefined + const job = { + listProcessIds: vi.fn(() => [4321, 4322]), + pause: vi.fn(() => true), + resume: vi.fn(() => true), + terminate: vi.fn(() => 'terminated' as const), + close: vi.fn() + } + const createJob = vi.fn(() => job) + const createWindowsLaunch = vi.fn(() => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: { TERM: 'xterm-256color', ORCA_BUN_PTY_JOB_GATE: 'gate' }, + windowsVerbatimArguments: true as const, + release, + dispose, + waitForSpawn, + readShellProcessId: () => reportedShellPid + })) + const proc = spawn({ + platform: 'win32', + assignHostJob, + createJob, + createWindowsLaunch + }) + + expect(harness.spawn.mock.calls[0]?.[0]).toEqual(['cmd.exe', '/d /c launch.cmd']) + expect(harness.spawn.mock.calls[0]?.[1]).toMatchObject({ + windowsVerbatimArguments: true, + env: { ORCA_BUN_PTY_JOB_GATE: 'gate' }, + terminal: harness.terminal + }) + expect(assignHostJob.mock.invocationCallOrder[0]).toBeLessThan( + harness.spawn.mock.invocationCallOrder[0] + ) + expect(createJob).toHaveBeenCalledWith(4321) + expect(createJob.mock.invocationCallOrder[0]).toBeLessThan(release.mock.invocationCallOrder[0]) + await proc.waitForSpawn?.() + expect(waitForSpawn).toHaveBeenCalledWith(harness.processHandle.exited) + + proc.pause() + proc.pause() + proc.resume() + proc.resume() + expect(job.pause).toHaveBeenCalledOnce() + expect(job.resume).toHaveBeenCalledOnce() + expect(proc.jobRootProcessIsWrapper).toBe(true) + expect(readWindowsPtyJobProcessIds(proc)).toBeNull() + expect(harness.spawn.mock.calls[0]?.[1]).not.toHaveProperty('ipc') + reportedShellPid = 4322 + expect(proc.shellProcessId).toBe(4322) + expect(readWindowsPtyJobProcessIds(proc)).toEqual(new Set([4322])) + job.listProcessIds.mockReturnValueOnce([4321, 4323]) + expect(readWindowsPtyJobProcessIds(proc)).toBeNull() + expect(proc.shellProcessId).toBe(4322) + expect(proc.listOwnedProcessIds?.()).toEqual([4321, 4322]) + expect(proc.terminateOwnedTree?.()).toBe('terminated') + + job.terminate.mockClear() + proc.signalProcess?.('SIGINT') + expect(job.terminate).toHaveBeenCalledOnce() + expect(harness.processHandle.kill).not.toHaveBeenCalled() + + proc.clear() + proc.clear() + expect(harness.spawn.mock.calls[1]?.[0]).toEqual(['cmd.exe', '/d /c clear.cmd']) + expect(harness.spawn.mock.calls[1]?.[1]).toMatchObject({ + terminal: harness.terminal, + windowsVerbatimArguments: true + }) + expect(harness.spawn).toHaveBeenCalledTimes(2) + + const lastOutput = vi.fn() + const onExit = vi.fn() + proc.onData(lastOutput) + proc.onExit(onExit) + harness.resolveExit(0) + await harness.processHandle.exited + await Promise.resolve() + expect(onExit).not.toHaveBeenCalled() + expect(job.close).not.toHaveBeenCalled() + harness.emitData(new TextEncoder().encode('final ConPTY frame')) + harness.finishTerminal() + expect(lastOutput).toHaveBeenCalledWith('final ConPTY frame') + expect(onExit).toHaveBeenCalledOnce() + expect(job.close).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + expect(job.resume).toHaveBeenCalledOnce() + job.resume.mockImplementation(() => { + throw new Error('job already closed') + }) + expect(() => { + proc.pause() + proc.resume() + proc.kill() + proc.destroy() + }).not.toThrow() + expect(job.resume).toHaveBeenCalledOnce() + }) + + it('does not release a Windows gate without exact job ownership', async () => { + const harness = createBunHarness() + const release = vi.fn() + const dispose = vi.fn() + + expect(() => + spawn({ + platform: 'win32', + assignHostJob: () => true, + createJob: () => null, + createWindowsLaunch: () => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: {}, + windowsVerbatimArguments: true, + waitForSpawn: async () => {}, + readShellProcessId: () => undefined, + release, + dispose + }) + }) + ).toThrow('Windows Bun PTY job ownership is unavailable') + + expect(release).not.toHaveBeenCalled() + expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGTERM') + expect(harness.terminal.close).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + harness.resolveExit(1) + await harness.processHandle.exited + expect(dispose).toHaveBeenCalledTimes(2) + }) + + it('does not spawn a Windows PTY without host crash ownership', () => { + const harness = createBunHarness() + const createWindowsLaunch = vi.fn() + + expect(() => + spawn({ + platform: 'win32', + assignHostJob: () => false, + createWindowsLaunch + }) + ).toThrow('Windows Bun PTY host crash ownership is unavailable') + + expect(createWindowsLaunch).not.toHaveBeenCalled() + expect(harness.spawn).not.toHaveBeenCalled() + }) + + it('preserves a Windows PTY after a failed suspension and allows a retry', () => { + const harness = createBunHarness() + const job = { + listProcessIds: vi.fn(() => [4321]), + pause: vi.fn(() => true).mockReturnValueOnce(false), + resume: vi.fn(() => true), + terminate: vi.fn(() => 'terminated' as const), + close: vi.fn() + } + const proc = spawn({ + platform: 'win32', + assignHostJob: () => true, + createJob: () => job, + createWindowsLaunch: () => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: {}, + windowsVerbatimArguments: true, + waitForSpawn: async () => {}, + readShellProcessId: () => undefined, + release: vi.fn(), + dispose: vi.fn() + }) + }) + + proc.pause() + proc.write('still usable') + proc.pause() + proc.resume() + + expect(job.pause).toHaveBeenCalledTimes(2) + expect(job.resume).toHaveBeenCalledOnce() + expect(job.terminate).not.toHaveBeenCalled() + expect(harness.terminal.close).not.toHaveBeenCalled() + expect(harness.terminal.write).toHaveBeenCalledWith('still usable') + + proc.kill() + proc.kill('SIGKILL') + proc.destroy() + expect(harness.processHandle.kill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGTERM']]) + expect(job.terminate).toHaveBeenCalledTimes(3) + expect(harness.terminal.close).toHaveBeenCalledOnce() + }) + + it('delivers Windows exit after cleanup failures', async () => { + const harness = createBunHarness() + const cleanupError = new Error('job close failed') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const proc = spawn({ + platform: 'win32', + assignHostJob: () => true, + createJob: () => ({ + listProcessIds: vi.fn(() => []), + pause: vi.fn(() => true), + resume: vi.fn(() => true), + terminate: vi.fn(() => 'terminated' as const), + close: vi.fn(() => { + throw cleanupError + }) + }), + createWindowsLaunch: () => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: {}, + windowsVerbatimArguments: true, + waitForSpawn: async () => {}, + readShellProcessId: () => undefined, + release: vi.fn(), + dispose: vi.fn() + }) + }) + const onExit = vi.fn() + proc.onExit(onExit) + + harness.resolveExit(9) + await harness.processHandle.exited + await Promise.resolve() + + expect(onExit).toHaveBeenCalledWith({ exitCode: 9 }) + expect(warn).toHaveBeenCalledWith('[daemon/pty] PTY cleanup failed:', cleanupError) + }) + + it('terminates and closes Windows job state when gate release fails', () => { + const harness = createBunHarness() + const dispose = vi.fn() + const job = { + listProcessIds: vi.fn(() => [4321]), + pause: vi.fn(() => true), + resume: vi.fn(() => true), + terminate: vi.fn(() => 'terminated' as const), + close: vi.fn() + } + + expect(() => + spawn({ + platform: 'win32', + assignHostJob: () => true, + createJob: () => job, + createWindowsLaunch: () => ({ + command: ['cmd.exe', '/d /c launch.cmd'], + clearCommand: ['cmd.exe', '/d /c clear.cmd'], + env: {}, + windowsVerbatimArguments: true, + waitForSpawn: async () => {}, + readShellProcessId: () => undefined, + release() { + throw new Error('gate release failed') + }, + dispose + }) + }) + ).toThrow('gate release failed') + + expect(job.terminate).toHaveBeenCalledOnce() + expect(job.close).toHaveBeenCalledOnce() + expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGTERM') + expect(harness.terminal.close).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/daemon/pty-subprocess/bun-pty-process.ts b/src/main/daemon/pty-subprocess/bun-pty-process.ts new file mode 100644 index 00000000000..b8d68b65ad4 --- /dev/null +++ b/src/main/daemon/pty-subprocess/bun-pty-process.ts @@ -0,0 +1,2 @@ +export { canUseBunPty } from './bun-pty-process-capabilities' +export { spawnBunPty } from './bun-pty-process-runtime' diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts index 8726dc87281..7a96877c204 100644 --- a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -1,6 +1,6 @@ import type * as pty from 'node-pty' +import { ptyShellProcessId } from '../../windows/windows-pty-job' import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' -import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process' import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation' import { judgeCachedAgentJobEvidence, @@ -24,6 +24,11 @@ import { import { isShellProcess } from '../../../shared/shell-process-detection' import { resolveFallbackForegroundProcess } from './foreground-fallback-process' import { parsePtySessionId } from '../pty-session-id' +import { + ptyProcessNameIsSpawnFile, + createPtyForegroundResolver, + shouldCachePtyForeground +} from './spawn-file-foreground-process' const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 @@ -52,6 +57,8 @@ export function createPtyForegroundProcessTracker(args: { isDead: () => boolean }): PtyForegroundProcessTracker { const proc = args.process + const staticName = ptyProcessNameIsSpawnFile(proc) + const resolveForeground = createPtyForegroundResolver(proc) let lastOutputAt = 0 // `pid` anchors the identity to the row that proved it (null when ambiguous). let cachedAgentForeground: CachedAgentForeground | null = null @@ -69,16 +76,12 @@ export function createPtyForegroundProcessTracker(args: { let foregroundRefreshInFlight = false let lastForegroundRefreshStartedAt = 0 const getFallbackProcess = (): string | null => - resolveFallbackForegroundProcess(proc.process, args.shellPath) + resolveFallbackForegroundProcess(staticName ? args.shellPath : proc.process, args.shellPath) const getActiveStartupAgent = ( now = Date.now() ): { processName: string; expiresAt: number } | null => { - if (!startupAgentForeground) { - return null - } - if (now > startupAgentForeground.expiresAt) { + if (startupAgentForeground && now > startupAgentForeground.expiresAt) { startupAgentForeground = null - return null } return startupAgentForeground } @@ -138,7 +141,7 @@ export function createPtyForegroundProcessTracker(args: { } } const anchor = cachedAgentForeground - void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { + void resolveForeground(proc.pid, fallbackProcess, { contextPaths, ...(anchor?.pid != null ? { anchorProcessId: anchor.pid, anchorProcessName: anchor.processName } @@ -148,13 +151,13 @@ export function createPtyForegroundProcessTracker(args: { if (args.isDead() || !available) { return } - if (!processName || !recognizeAgentProcess(processName)) { + if (!shouldCachePtyForeground(processName, staticName)) { if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { // Job, not console: needs no console attachment, so no fork (#10857). const verdict = judgeCachedAgentJobEvidence({ jobProcessIds: readWindowsPtyJobProcessIds(proc), jobSupported: isWindowsPtyJobReadable(), - shellPid: proc.pid, + shellPid: ptyShellProcessId(proc) ?? proc.pid, anchorProcessId: cachedAgentForeground.pid, identityAgeMs: Date.now() - cachedAgentForeground.refreshedAt }) @@ -248,7 +251,8 @@ export function createPtyForegroundProcessTracker(args: { if ( cachedAgentForeground && fallbackProcess !== null && - (isAgentForegroundWrapperProcess(fallbackProcess) || + (staticName || + isAgentForegroundWrapperProcess(fallbackProcess) || inspectOuterWrapper || (process.platform === 'win32' && isShellProcess(fallbackProcess))) ) { @@ -279,33 +283,30 @@ export function createPtyForegroundProcessTracker(args: { ) { return fallbackProcess } - const resolution = await resolveAgentForegroundProcessWithAvailability( - proc.pid, - fallbackProcess, - { - contextPaths, - fresh: true, - ...(process.platform === 'win32' - ? { - forceProcessScan: true, - readWindowsConsoleAttachedProcessIds: () => - readWindowsConsoleAttachedProcessIds(proc.pid) - } - : {}) - } - ) + const resolution = await resolveForeground(proc.pid, fallbackProcess, { + contextPaths, + fresh: true, + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConsoleAttachedProcessIds: () => + readWindowsConsoleAttachedProcessIds(proc.pid) + } + : {}) + }) if (args.isDead() || !resolution.available) { return null } - const recognized = recognizeAgentProcess(resolution.processName) - if (recognized) { + const processName = + recognizeAgentProcess(resolution.processName)?.processName ?? resolution.processName + if (shouldCachePtyForeground(processName, staticName)) { cachedAgentForeground = { - processName: recognized.processName, + processName, pid: resolution.processId ?? null, refreshedAt: Date.now() } startupAgentForeground = null - return recognized.processName + return cachedAgentForeground.processName } cachedAgentForeground = null startupAgentForeground = null diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts new file mode 100644 index 00000000000..26a3fcda7dc --- /dev/null +++ b/src/main/daemon/pty-subprocess/native-pty-spawn-bun.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest' + +const { nodePtyFactory, wrapShellSpawnMock } = vi.hoisted(() => ({ + nodePtyFactory: vi.fn(() => ({ spawn: vi.fn() })), + wrapShellSpawnMock: vi.fn((file: string, args: string[]) => ({ file, args })) +})) + +vi.mock('node-pty', nodePtyFactory) +vi.mock('../../providers/macos-tcc-login-shell', () => ({ + hostReportsChildExitStatus: (file: string) => file !== '/usr/bin/login', + wrapShellSpawnForMacosTccAttribution: wrapShellSpawnMock +})) + +import { spawnNativeDaemonPty } from './native-pty-spawn' + +describe('native PTY runtime selection', () => { + it('spawns with Bun.Terminal without loading node-pty', async () => { + const dispose = vi.fn() + const spawnBunPty = vi.fn(() => ({ + pid: 9876, + cols: 80, + rows: 24, + process: '/bin/zsh', + handleFlowControl: false, + onData: vi.fn(() => ({ dispose })), + onExit: vi.fn(() => ({ dispose })), + write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + kill: vi.fn(), + destroy: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + })) + + const result = await spawnNativeDaemonPty( + { + shellPath: '/bin/zsh', + shellArgs: ['-l'], + spawnCwd: '/tmp', + env: { TERM: 'xterm-256color' }, + cols: 80, + rows: 24, + windowsFallbackAttempts: [] + }, + { canUseBunPty: () => true, spawnBunPty } + ) + + expect(result.process.pid).toBe(9876) + expect(spawnBunPty).toHaveBeenCalledOnce() + expect(nodePtyFactory).not.toHaveBeenCalled() + }) + + it('applies the macOS login wrapper before selecting the Bun PTY runtime', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const spawnBunPty = vi.fn(() => ({ + pid: 9877, + cols: 80, + rows: 24, + process: '/usr/bin/login', + handleFlowControl: false, + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })), + write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + kill: vi.fn(), + destroy: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + })) + const onMacosTccSpawnStrategy = vi.fn() + wrapShellSpawnMock.mockReturnValueOnce({ + file: '/usr/bin/login', + args: ['-flpq', 'tester', '/bin/zsh', '-l'] + }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + + try { + const result = await spawnNativeDaemonPty( + { + shellPath: '/bin/zsh', + shellArgs: ['-l'], + spawnCwd: '/tmp', + env: { TERM: 'xterm-256color' }, + cols: 80, + rows: 24, + windowsFallbackAttempts: [], + onMacosTccSpawnStrategy + }, + { canUseBunPty: () => true, spawnBunPty } + ) + + expect(result.process.pid).toBe(9877) + expect(spawnBunPty).toHaveBeenCalledWith( + expect.objectContaining({ + file: '/usr/bin/login', + args: ['-flpq', 'tester', '/bin/zsh', '-l'] + }) + ) + expect(result.reportsChildExitStatus).toBe(false) + expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith('wrapped') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) +}) diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts new file mode 100644 index 00000000000..8d6f7117d08 --- /dev/null +++ b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnNativeDaemonPty } from './native-pty-spawn' +import { WindowsBunPtySpawnUnconfirmedError } from './windows-bun-pty-spawn-receipt' + +const attempts = ['pwsh.exe', 'powershell.exe', 'cmd.exe'].map((shellPath) => ({ + shellPath, + shellArgs: [shellPath === 'cmd.exe' ? '/K' : '-NoExit'], + effectiveCwd: 'C:\\work', + validationCwd: 'C:\\work', + startupCommandDeliveredInShellArgs: true +})) +const args = { + shellPath: attempts[0]!.shellPath, + shellArgs: attempts[0]!.shellArgs, + spawnCwd: 'C:\\work', + env: {}, + cols: 80, + rows: 24, + windowsFallbackAttempts: attempts +} + +function createProcess(waitForSpawn: () => Promise) { + return { + pid: 9876, + cols: 80, + rows: 24, + process: 'gate', + handleFlowControl: false, + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })), + write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + kill: vi.fn(), + destroy: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + waitForSpawn + } +} + +describe('Windows Bun shell fallback after gated spawn', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() + }) + + it('walks both fallback shells when gate wrappers start but their actual shells fail', async () => { + const spawnBunPty = vi.fn(({ file }: { file: string }) => + createProcess(async () => { + await Promise.resolve() + if (file !== 'cmd.exe') { + throw new Error(`spawn ${file} EACCES`) + } + }) + ) + const result = await spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty }) + expect(spawnBunPty.mock.calls.map(([args]) => args.file)).toEqual([ + 'pwsh.exe', + 'powershell.exe', + 'cmd.exe' + ]) + expect(result.shellPath).toBe('cmd.exe') + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + expect(spawnBunPty.mock.results[0]!.value.destroy).toHaveBeenCalledOnce() + expect(spawnBunPty.mock.results[1]!.value.destroy).toHaveBeenCalledOnce() + expect(spawnBunPty.mock.results[2]!.value.destroy).not.toHaveBeenCalled() + }) + + it('does not report a wrapper as a working shell before its actual spawn is confirmed', async () => { + let confirm!: () => void + const confirmation = new Promise((resolve) => { + confirm = resolve + }) + const finished = vi.fn() + const spawnBunPty = vi.fn(() => createProcess(() => confirmation)) + const result = spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty }).then( + finished + ) + await Promise.resolve() + expect(finished).not.toHaveBeenCalled() + confirm() + await result + expect(finished).toHaveBeenCalledOnce() + }) + + it('destroys an unconfirmed gate on cancellation without starting a fallback shell', async () => { + const controller = new AbortController() + const proc = createProcess(() => new Promise(() => {})) + const spawnBunPty = vi.fn(() => proc) + const result = spawnNativeDaemonPty( + { ...args, signal: controller.signal }, + { canUseBunPty: () => true, spawnBunPty } + ) + controller.abort(new Error('spawn canceled')) + await expect(result).rejects.toThrow('spawn canceled') + expect(proc.destroy).toHaveBeenCalledOnce() + expect(spawnBunPty).toHaveBeenCalledOnce() + }) + + it.each([0, 1])( + 'stops at an ambiguous attempt %s to avoid running its startup command twice', + async (ambiguousIndex) => { + const spawnBunPty = vi.fn(({ file }: { file: string }) => + createProcess(async () => { + if (file === attempts[ambiguousIndex]!.shellPath) { + throw new WindowsBunPtySpawnUnconfirmedError('missing receipt') + } + throw new Error('spawn ENOENT') + }) + ) + await expect( + spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty }) + ).rejects.toBeInstanceOf(WindowsBunPtySpawnUnconfirmedError) + expect(spawnBunPty).toHaveBeenCalledTimes(ambiguousIndex + 1) + expect(spawnBunPty.mock.results.at(-1)!.value.destroy).toHaveBeenCalledOnce() + } + ) +}) diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts index e0332ba9921..e89e8a5aac4 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -1,4 +1,5 @@ -import * as pty from 'node-pty' +import type * as pty from 'node-pty' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { hostReportsChildExitStatus, wrapShellSpawnForMacosTccAttribution @@ -6,6 +7,12 @@ import { import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain' import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job' +import { canUseBunPty, spawnBunPty } from './bun-pty-process' +import { WindowsBunPtySpawnUnconfirmedError } from './windows-bun-pty-spawn-receipt' + +async function loadNodePty(): Promise { + return import('node-pty') +} export type SpawnedDaemonPty = { process: pty.IPty shellPath: string @@ -15,25 +22,66 @@ export type SpawnedDaemonPty = { reportsChildExitStatus: boolean } +type NativePtyRuntime = { + canUseBunPty: typeof canUseBunPty + spawnBunPty: typeof spawnBunPty +} + /** Walks the Windows PowerShell -> cmd.exe fallback chain when ConPTY rejects the primary shell. */ -export function spawnNativeDaemonPty(args: { - shellPath: string - shellArgs: string[] - spawnCwd: string - env: Record - cols: number - rows: number - windowsFallbackAttempts: WindowsShellSpawnAttempt[] - onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void -}): SpawnedDaemonPty { +export async function spawnNativeDaemonPty( + args: { + shellPath: string + shellArgs: string[] + spawnCwd: string + env: Record + cols: number + rows: number + windowsFallbackAttempts: WindowsShellSpawnAttempt[] + signal?: AbortSignal + onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void + }, + runtime: NativePtyRuntime = { canUseBunPty, spawnBunPty } +): Promise { let reportsChildExitStatus = true - const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { + const spawnAt = async ( + shellPath: string, + shellArgs: string[], + cwd: string + ): Promise => { + args.signal?.throwIfAborted() const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) + reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) + if (runtime.canUseBunPty()) { + const proc = runtime.spawnBunPty({ + file: wrapped.file, + args: wrapped.args, + cwd, + env: args.env, + cols: args.cols, + rows: args.rows + }) + try { + if (proc.waitForSpawn) { + await waitForPromiseWithSignal(proc.waitForSpawn(), args.signal) + } + args.signal?.throwIfAborted() + } catch (error) { + try { + proc.destroy() + } catch (cleanupError) { + console.warn('[daemon/pty] Failed shell launch cleanup failed:', cleanupError) + } + throw error + } + args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') + return proc + } + const nodePty = await loadNodePty() // Why: children inherit job membership, so the host job must exist before the first Windows PTY. if (process.platform === 'win32') { assignHostProcessToKillOnCloseJob() } - const proc = pty.spawn(wrapped.file, wrapped.args, { + const proc = nodePty.spawn(wrapped.file, wrapped.args, { name: args.env.TERM ?? 'xterm-256color', cols: args.cols, rows: args.rows, @@ -48,7 +96,7 @@ export function spawnNativeDaemonPty(args: { } try { - const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) + const process_ = await spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) return { process: process_, shellPath: args.shellPath, @@ -56,12 +104,13 @@ export function spawnNativeDaemonPty(args: { reportsChildExitStatus } } catch (primaryErr) { - if (process.platform !== 'win32') { + args.signal?.throwIfAborted() + if (process.platform !== 'win32' || primaryErr instanceof WindowsBunPtySpawnUnconfirmedError) { throw primaryErr } for (const attempt of args.windowsFallbackAttempts.slice(1)) { try { - const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd) + const process = await spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd) const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr) console.warn( `[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"` @@ -73,7 +122,11 @@ export function spawnNativeDaemonPty(args: { startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, reportsChildExitStatus } - } catch { + } catch (error) { + args.signal?.throwIfAborted() + if (error instanceof WindowsBunPtySpawnUnconfirmedError) { + throw error + } // This fallback shell also failed -- try the next link in the chain. } } diff --git a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts index 2fdd75e3518..b4f6aa502f8 100644 --- a/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts +++ b/src/main/daemon/pty-subprocess/pty-shell-foreground-confirmation.ts @@ -1,4 +1,5 @@ import type * as pty from 'node-pty' +import { ptyShellProcessId } from '../../windows/windows-pty-job' import { confirmShellForegroundProcess } from '../../providers/agent-foreground-process' import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' @@ -14,7 +15,7 @@ export async function confirmPtyShellForeground(args: { return false } const confirmed = await confirmShellForegroundProcess( - args.process.pid, + ptyShellProcessId(args.process), args.shellPath, process.platform === 'win32' ? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(args.process) } diff --git a/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts b/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts new file mode 100644 index 00000000000..be70254dc12 --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts @@ -0,0 +1,50 @@ +import type { IPty } from 'node-pty' +import { + getCommandTokenPathBasename, + getFirstCommandToken +} from '../../../shared/command-token-scanner' +import { + collectDescendantsFromIndex, + getProcessTableIndex +} from '../../../shared/process-table-index' +import type { ProcessTableRow } from '../../../shared/process-table-snapshot' +import type { PtyChildProcessVerdict } from '../../../shared/terminal-process-inspection' +import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' + +function executableName(command: string): string { + return getCommandTokenPathBasename(getFirstCommandToken(command)).replace(/^-/, '') +} + +export function inspectSpawnFileChildProcessesFromRows( + rows: readonly ProcessTableRow[], + rootPid: number, + shellName: string | null +): PtyChildProcessVerdict { + const index = getProcessTableIndex(rows) + const root = index.byPid.get(rootPid) + if (!root || !shellName || !root.tty || root.tty === '?') { + return 'unverifiable' + } + const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, rootPid)] + const shell = tree + .filter((row) => executableName(row.command) === shellName && !row.stat.includes('Z')) + .sort((left, right) => left.depth - right.depth)[0] + if (!shell) { + return 'unverifiable' + } + // The macOS login wrapper and its spawned shell are launch plumbing, not user jobs. + const launchChain = new Set([rootPid]) + let ancestor: ProcessTableRow | undefined = shell + while (ancestor && !launchChain.has(ancestor.pid)) { + launchChain.add(ancestor.pid) + ancestor = index.byPid.get(ancestor.ppid) + } + return tree.some((row) => !launchChain.has(row.pid) && !row.stat.includes('Z')) + ? 'children' + : 'no-children' +} + +export function inspectSpawnFileWindowsChildProcesses(proc: IPty): PtyChildProcessVerdict { + const members = readWindowsPtyJobProcessIds(proc) + return members === null ? 'unverifiable' : members.size > 1 ? 'children' : 'no-children' +} diff --git a/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts new file mode 100644 index 00000000000..b5763f238b1 --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts @@ -0,0 +1,125 @@ +import type { IPty } from 'node-pty' +import { isShellProcess } from '../../../shared/shell-process-detection' +import { + getCommandTokenPathBasename, + getFirstCommandToken +} from '../../../shared/command-token-scanner' +import { + collectDescendantsFromIndex, + getProcessTableIndex +} from '../../../shared/process-table-index' +import type { ProcessTableRow } from '../../../shared/process-table-snapshot' +import { + getFreshProcessTableSnapshot, + getProcessTableSnapshot +} from '../../../shared/process-table-snapshot-reader' +import { selectForegroundProcessCandidate } from '../../../shared/foreground-process-selection' +import { resolveOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent' +import { recognizeAgentProcess } from '../../../shared/agent-process-recognition' +import { + resolveAgentForegroundProcessWithAvailability, + type AgentForegroundProcessResolution, + type AgentForegroundResolutionOptions +} from '../../providers/agent-foreground-process' +import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership' +import { ptyShellProcessId } from '../../windows/windows-pty-job' +import { + readWindowsProcessIdentityTable, + readWindowsProcessIdentityTableFresh +} from '../../windows/windows-process-table' + +export function ptyProcessNameIsSpawnFile(proc: IPty): boolean { + return 'processNameIsSpawnFile' in proc && proc.processNameIsSpawnFile === true +} + +export function createPtyForegroundResolver( + proc: IPty +): typeof resolveAgentForegroundProcessWithAvailability { + return ptyProcessNameIsSpawnFile(proc) + ? (_pid, fallback, options) => resolveSpawnFileForegroundProcess(proc, fallback, options) + : resolveAgentForegroundProcessWithAvailability +} + +export function shouldCachePtyForeground(name: string | null, staticName: boolean): name is string { + return ( + name !== null && (recognizeAgentProcess(name) !== null || (staticName && !isShellProcess(name))) + ) +} + +export function resolveSpawnFileForegroundFromRows( + rows: readonly ProcessTableRow[], + rootPid: number +): AgentForegroundProcessResolution { + const index = getProcessTableIndex(rows) + const root = index.byPid.get(rootPid) + if (!root || !root.tpgid || root.tpgid < 0 || !root.tty || root.tty === '?') { + return { available: false, processName: null } + } + const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, rootPid)] + const candidates = tree + .filter((row) => row.pgid === root.tpgid && row.tty === root.tty && !/[TZ]/.test(row.stat)) + .sort((left, right) => right.depth - left.depth) + const foreground = candidates[0] + if (!foreground) { + return { available: false, processName: null } + } + const name = getCommandTokenPathBasename(getFirstCommandToken(foreground.command)).replace( + /^-/, + '' + ) + const selected = selectForegroundProcessCandidate(candidates, tree) + return { + available: name.length > 0, + processName: selected + ? resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, tree) + : recognizeAgentProcess(name) + ? null + : name || null + } +} + +export async function resolveSpawnFileForegroundProcess( + proc: IPty, + fallbackProcess: string | null, + options: AgentForegroundResolutionOptions = {} +): Promise { + try { + if (process.platform !== 'win32') { + const rows = options.fresh + ? await getFreshProcessTableSnapshot() + : await getProcessTableSnapshot() + return resolveSpawnFileForegroundFromRows(rows, proc.pid) + } + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + fallbackProcess, + options + ) + if (!resolution.available || recognizeAgentProcess(resolution.processName)) { + return resolution + } + const members = readWindowsPtyJobProcessIds(proc) + const shellPid = ptyShellProcessId(proc) + if (!members || shellPid === undefined) { + return { available: false, processName: null } + } + if (members.size === 1) { + return { available: true, processName: fallbackProcess } + } + const rows = options.fresh + ? await readWindowsProcessIdentityTableFresh() + : await readWindowsProcessIdentityTable() + const candidate = collectDescendantsFromIndex(getProcessTableIndex(rows), shellPid) + .filter((row) => members.has(row.pid)) + .sort((left, right) => right.depth - left.depth)[0] + // Only the agent resolver can grant an identity after ambiguity and console checks. + if (candidate && recognizeAgentProcess(candidate.name)) { + return resolution + } + return candidate + ? { available: true, processName: candidate.name, processId: candidate.pid } + : { available: false, processName: null } + } catch { + return { available: false, processName: null } + } +} diff --git a/src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts b/src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts new file mode 100644 index 00000000000..d79dc806e5a --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts @@ -0,0 +1,134 @@ +import type { IPty } from 'node-pty' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessTableRow } from '../../../shared/process-table-snapshot' +import { __setWindowsProcessTreeLoaderForTests } from '../../windows/windows-process-table' +import { + resolveSpawnFileForegroundFromRows, + resolveSpawnFileForegroundProcess +} from './spawn-file-foreground-process' + +const { members } = vi.hoisted(() => ({ members: vi.fn() })) +vi.mock('../../providers/windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: members +})) + +const proc: IPty = { + pid: 100, + cols: 80, + rows: 24, + handleFlowControl: false, + process: 'powershell.exe', + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + write() {}, + resize() {}, + clear() {}, + kill() {}, + pause() {}, + resume() {} +} + +const root: ProcessTableRow = { + pid: 100, + ppid: 1, + pgid: 100, + tpgid: 101, + tty: 'pts/test', + stat: 'S', + startTime: 'shell-start', + command: '/bin/zsh' +} + +beforeEach(() => members.mockReturnValue(new Set([100, 101, 102]))) +afterEach(() => { + __setWindowsProcessTreeLoaderForTests() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('POSIX static-name agent selection', () => { + it('does not pick a rejected sibling agent by its executable basename', () => { + expect( + resolveSpawnFileForegroundFromRows( + [ + root, + { ...root, pid: 101, ppid: 100, pgid: 101, stat: 'S+', command: 'claude' }, + { ...root, pid: 102, ppid: 100, pgid: 101, stat: 'S+', command: 'codex' } + ], + 100 + ) + ).toEqual({ available: true, processName: null }) + }) + + it('does not promote a headless one-shot agent from its executable basename', () => { + expect( + resolveSpawnFileForegroundFromRows( + [ + root, + { ...root, pid: 101, ppid: 100, pgid: 101, stat: 'S+', command: 'claude -p "review"' } + ], + 100 + ) + ).toEqual({ available: true, processName: null }) + }) + + it.each(['vim', 'npm', 'sleep'])('retains the ordinary %s name', (command) => { + expect( + resolveSpawnFileForegroundFromRows( + [root, { ...root, pid: 101, ppid: 100, pgid: 101, stat: 'S+', command }], + 100 + ) + ).toEqual({ available: true, processName: command }) + }) +}) + +describe('Windows static-name agent selection', () => { + function installRows(names: string[]): void { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const rows = [ + { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' }, + { pid: 100, ppid: 1, name: 'powershell.exe', commandLine: 'powershell.exe' }, + ...names.map((name, index) => ({ pid: 101 + index, ppid: 100, name, commandLine: name })) + ] + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: (callback) => callback(rows) + })) + } + + it('does not re-admit a detached agent through owned job membership', async () => { + installRows(['droid.exe']) + const consoleMembers = vi.fn(async () => new Set([100, 999])) + expect( + await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', { + fresh: true, + readWindowsConsoleAttachedProcessIds: consoleMembers + }) + ).toEqual({ available: true, processName: 'powershell.exe' }) + expect(consoleMembers).toHaveBeenCalledOnce() + }) + + it('does not choose a rejected sibling agent from the identity table', async () => { + installRows(['claude.exe', 'codex.exe']) + expect( + await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', { fresh: true }) + ).toEqual({ available: true, processName: 'powershell.exe' }) + }) + + it('retains a positively authorized agent', async () => { + installRows(['droid.exe']) + expect( + await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', { + fresh: true, + readWindowsConsoleAttachedProcessIds: async () => new Set([100, 101]) + }) + ).toEqual({ available: true, processName: 'droid', processId: 101 }) + }) + + it('retains an ordinary executable from the identity table', async () => { + installRows(['vim.exe']) + expect( + await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', { fresh: true }) + ).toEqual({ available: true, processName: 'vim.exe', processId: 101 }) + }) +}) diff --git a/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts b/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts new file mode 100644 index 00000000000..d40b30e2b32 --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-preflight-bun.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted((): { shellPid?: number } => ({})) +vi.mock('./bun-pty-process', () => ({ + canUseBunPty: () => true, + spawnBunPty: () => ({ + pid: 41, + get shellProcessId() { + return fixture.shellPid + }, + onExit(callback: (event: { exitCode: number }) => void) { + queueMicrotask(() => callback({ exitCode: 0 })) + return { dispose() {} } + } + }) +})) + +import { runPtySpawnHealthProbe } from './spawn-preflight' + +beforeEach(() => + vi.stubGlobal( + 'process', + Object.create(process, { + platform: { value: 'win32' } + }) + ) +) +afterEach(() => vi.unstubAllGlobals()) + +it.each([undefined, 41, 0])( + 'refuses successful gate exit without shell identity %s', + async (pid) => { + fixture.shellPid = pid + await expect(runPtySpawnHealthProbe()).rejects.toThrow('could not identify the Windows shell') + } +) + +it('accepts successful exit with the separate original shell identity', async () => { + fixture.shellPid = 42 + await expect(runPtySpawnHealthProbe()).resolves.toBeUndefined() +}) diff --git a/src/main/daemon/pty-subprocess/spawn-preflight.ts b/src/main/daemon/pty-subprocess/spawn-preflight.ts index facaf0a69bd..404f5b258f0 100644 --- a/src/main/daemon/pty-subprocess/spawn-preflight.ts +++ b/src/main/daemon/pty-subprocess/spawn-preflight.ts @@ -1,6 +1,7 @@ -import * as pty from 'node-pty' +import type * as pty from 'node-pty' import { statSync } from 'node:fs' import { release } from 'node:os' +import { getCmdExePath } from '../../../shared/windows-batch-spawn' import { ensureNodePtySpawnHelperExecutable, getNodePtySpawnHelperCandidates, @@ -10,9 +11,14 @@ import { import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd' import { TerminalAttachCanceledError } from '../daemon-errors' import { DaemonProtocolError } from '../types' +import { canUseBunPty, spawnBunPty } from './bun-pty-process' const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 +async function loadNodePty(): Promise { + return import('node-pty') +} + function daemonEnvironmentDiagSuffix(): string { const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' const systemVersion = @@ -79,7 +85,7 @@ function preflightDaemonCwd(): void { } function preflightMacNodePtySpawnEnvironment(): void { - if (process.platform !== 'darwin') { + if (process.platform !== 'darwin' || canUseBunPty()) { return } let candidates: string[] @@ -119,7 +125,9 @@ export async function preflightPtySpawn(args: { sessionId: string signal?: AbortSignal }): Promise { - ensureNodePtySpawnHelperExecutable() + if (!canUseBunPty()) { + ensureNodePtySpawnHelperExecutable() + } preflightUnixPtySpawnEnvironment() try { if (process.platform === 'win32') { @@ -154,21 +162,34 @@ export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: s return formatted } -export function runPtySpawnHealthProbe(): Promise { +export async function runPtySpawnHealthProbe(): Promise { + const requiresShellIdentity = process.platform === 'win32' && canUseBunPty() const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) ? process.env.ORCA_USER_DATA_PATH : resolveSafePtyDefaultCwd() + const command = + process.platform === 'win32' + ? { file: getCmdExePath(), args: ['/d', '/c', 'exit', '0'] } + : { file: '/bin/sh', args: ['-c', 'exit 0'] } let proc: pty.IPty try { - proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { - name: 'xterm-256color', - cols: 2, - rows: 1, - cwd, - env: { ...process.env, TERM: 'xterm-256color' } - }) + const env: Record = { TERM: 'xterm-256color' } + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + env[key] = value + } + } + proc = canUseBunPty() + ? spawnBunPty({ ...command, cols: 2, rows: 1, cwd, env }) + : (await loadNodePty()).spawn(command.file, command.args, { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd, + env + }) } catch (err) { - throw formatPtySpawnError(err, '/bin/sh', cwd) + throw formatPtySpawnError(err, command.file, cwd) } return new Promise((resolve, reject) => { @@ -201,7 +222,18 @@ export function runPtySpawnHealthProbe(): Promise { }, PTY_SPAWN_HEALTH_TIMEOUT_MS) exitDisposable = proc.onExit(({ exitCode }) => { if (exitCode === 0) { - finish() + const shellPid = 'shellProcessId' in proc ? proc.shellProcessId : undefined + if ( + requiresShellIdentity && + (typeof shellPid !== 'number' || + !Number.isSafeInteger(shellPid) || + shellPid <= 0 || + shellPid === proc.pid) + ) { + finish(new Error('PTY spawn health check could not identify the Windows shell')) + } else { + finish() + } } else { finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) } @@ -210,10 +242,10 @@ export function runPtySpawnHealthProbe(): Promise { } export function preflightPtySpawnHealth(): boolean { - if (process.platform === 'win32') { + if (process.platform === 'win32' && !canUseBunPty()) { return false } - if (process.platform === 'darwin') { + if (!canUseBunPty()) { ensureNodePtySpawnHelperExecutable() } preflightUnixPtySpawnEnvironment() diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts index 5d7ef163424..56b739bfccc 100644 --- a/src/main/daemon/pty-subprocess/subprocess-handle.ts +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -9,8 +9,13 @@ import { isValidPtySize } from '../daemon-pty-size' import type { SubprocessHandle } from '../session-subprocess-handle' import { createPtyForegroundProcessTracker } from './foreground-process-tracker' import { PtyPreListenerEvents } from './pre-listener-events' +import { ptyProcessNameIsSpawnFile } from './spawn-file-foreground-process' +import { inspectSpawnFileWindowsChildProcesses } from './spawn-file-child-processes' -type DisposableNativePty = pty.IPty & { destroy?: () => void } +type DisposableNativePty = pty.IPty & { + destroy?: () => void + signalProcess?: (signal: string) => void +} export function createDaemonPtySubprocessHandle(args: { process: pty.IPty @@ -26,7 +31,7 @@ export function createDaemonPtySubprocessHandle(args: { const reportsChildExitStatus = args.reportsChildExitStatus const proc = args.process // node-pty exposes destroy at runtime but omits it from IPty. - const nativeProc = proc as DisposableNativePty + const nativeProc: DisposableNativePty = proc const events = new PtyPreListenerEvents() let dead = false // I/O failure is not exit evidence; keep termination and producer flow control available. @@ -64,6 +69,10 @@ export function createDaemonPtySubprocessHandle(args: { const slavePath = readPtySlavePath(proc) return { pid: proc.pid, + processNameIsSpawnFile: ptyProcessNameIsSpawnFile(proc), + ...(process.platform === 'win32' + ? { inspectChildProcesses: () => inspectSpawnFileWindowsChildProcesses(proc) } + : {}), shellPath: args.shellPath, shellCwd: args.spawnCwd, shellPathEnv: args.env.PATH, @@ -166,6 +175,14 @@ export function createDaemonPtySubprocessHandle(args: { if (dead) { return } + if (nativeProc.signalProcess) { + try { + nativeProc.signalProcess(sig) + } catch { + /* The process may have exited. */ + } + return + } const signalRootPid = (): void => { try { process.kill(proc.pid, sig) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts new file mode 100644 index 00000000000..9c17da7fa18 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts @@ -0,0 +1,21 @@ +import { unlinkSync } from 'node:fs' +import { readWindowsBunPtyGateRequest, runWindowsBunPtyGate } from './windows-bun-pty-gate' + +async function main(): Promise { + const requestPath = process.argv[2] + if (!requestPath) { + throw new Error('Windows PTY gate request path is required') + } + const request = readWindowsBunPtyGateRequest(requestPath) + // Arguments can contain agent prompts; do not retain them for the shell's lifetime. + unlinkSync(requestPath) + process.exitCode = await runWindowsBunPtyGate(request) +} + +void main().catch((error: unknown) => { + console.error( + '[pty] Windows job gate failed:', + error instanceof Error ? error.message : String(error) + ) + process.exitCode = 1 +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts new file mode 100644 index 00000000000..bb1c3d88a47 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts @@ -0,0 +1,89 @@ +import { build } from 'esbuild' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess, runProcessSync } from '../../../shared/child-process/run-process' +import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../../shared/orcad-bun-runtime' +import { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' + +const runtimePath = + process.env.BUN_EXECUTABLE ?? + resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform)) +const available = existsSync(runtimePath) + +describe.skipIf(!available)('bundled Windows job gate under Bun', () => { + it('executes the worker with real Bun flags and preserves long executable argv', async () => { + expect(runProcessSync({ program: runtimePath, args: ['--version'] }).stdout.trim()).toBe( + ORCAD_BUN_VERSION + ) + const directory = mkdtempSync(join(tmpdir(), 'orca-gate-contract-')) + const workerPath = join(directory, 'windows-bun-pty-gate-entry.js') + try { + await build({ + entryPoints: [join(__dirname, 'windows-bun-pty-gate-entry.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: workerPath, + logLevel: 'silent' + }) + const argv = ['x'.repeat(16000), 'a b', 'quote"', '%value%&!', '状態', ''] + const env = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ) + const launch = createWindowsBunPtyLaunch( + { + file: runtimePath, + args: ['-e', 'console.log(JSON.stringify(process.argv.slice(1)))', ...argv], + cwd: directory, + env + }, + { runtimePath, workerPath } + ) + try { + launch.release() + const result = await runProcess({ + program: launch.command[0]!, + args: launch.command.slice(1), + cwd: directory, + env: launch.env, + timeoutMs: 10_000 + }) + expect(result.timedOut).toBe(false) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual(argv) + await expect(launch.waitForSpawn(Promise.resolve(result.code!))).resolves.toBeUndefined() + expect(existsSync(launch.command.at(-1)!)).toBe(false) + } finally { + launch.dispose() + } + const failedLaunch = createWindowsBunPtyLaunch( + { file: join(directory, 'missing-shell.exe'), args: [], cwd: directory, env }, + { runtimePath, workerPath } + ) + try { + failedLaunch.release() + const result = await runProcess({ + program: failedLaunch.command[0]!, + args: failedLaunch.command.slice(1), + cwd: directory, + env: failedLaunch.env, + timeoutMs: 10_000 + }) + expect(result.timedOut).toBe(false) + expect(result.code).toBe(1) + await expect(failedLaunch.waitForSpawn(Promise.resolve(1))).rejects.toThrow( + /missing-shell|ENOENT|not found/ + ) + } finally { + failedLaunch.dispose() + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }, 15_000) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.test.ts new file mode 100644 index 00000000000..a87870f80d5 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.test.ts @@ -0,0 +1,150 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../../shared/child-process/run-process' +import { runWindowsBunPtyGate, type WindowsBunPtyGateRequest } from './windows-bun-pty-gate' + +const request: WindowsBunPtyGateRequest = { + file: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + args: ['-NoLogo', '-NoExit', '-Command', 'A'.repeat(16000)], + cwd: 'C:\\work', + gatePath: 'gate', + shellPidPath: 'shell.pid', + runtimeOptions: {} +} + +describe('Windows Bun PTY job gate worker', () => { + it.each(['exit', 'error'] as const)( + 'ignores Windows console interrupts only while supervising a child (%s)', + async (outcome) => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const previousListeners = process.listeners('SIGINT') + const child = new EventEmitter() + try { + const result = runWindowsBunPtyGate(request, { + waitForGate: async () => {}, + reportSpawnError: vi.fn(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes only the child events the gate consumes. + spawn: () => child as ReturnType + }) + expect(process.listeners('SIGINT')).toHaveLength(previousListeners.length + 1) + await Promise.resolve() + if (outcome === 'exit') { + child.emit('exit', 17) + await expect(result).resolves.toBe(17) + } else { + child.emit('error', new Error('spawn denied')) + await expect(result).rejects.toThrow('spawn denied') + } + expect(process.listeners('SIGINT')).toEqual(previousListeners) + } finally { + platform.mockRestore() + } + } + ) + + it('does not spawn before assignment and propagates the child exit code', async () => { + let release!: () => void + const waitForGate = vi.fn( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const child = Object.assign(new EventEmitter(), { pid: 1234 }) + const reportShellPid = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes only the child events and pid the gate consumes. + const spawn = vi.fn(() => child as ReturnType) + const result = runWindowsBunPtyGate(request, { + waitForGate, + spawn, + reportShellPid, + env: { TERM: 'xterm-256color' } + }) + await Promise.resolve() + expect(spawn).not.toHaveBeenCalled() + release() + await Promise.resolve() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + program: request.file, + args: request.args, + cwd: request.cwd, + stdio: 'inherit' + }) + ) + expect(reportShellPid).not.toHaveBeenCalled() + child.emit('spawn') + expect(reportShellPid).toHaveBeenCalledWith(1234) + child.emit('exit', 17) + await expect(result).resolves.toBe(17) + }) + + it('never starts a child after a failed job gate', async () => { + const spawn = vi.fn() + await expect( + runWindowsBunPtyGate(request, { + waitForGate: async () => { + throw new Error('gate missing') + }, + reportSpawnError: vi.fn(), + spawn + }) + ).rejects.toThrow('gate missing') + expect(spawn).not.toHaveBeenCalled() + }) + + it('keeps supervising the shell when its identity receipt cannot be published', async () => { + const child = Object.assign(new EventEmitter(), { pid: 1234 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const reportSpawnError = vi.fn() + const result = runWindowsBunPtyGate(request, { + waitForGate: async () => {}, + reportSpawnError, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture exposes only the child events and pid consumed by the gate. + spawn: () => child as ReturnType, + reportShellPid() { + throw new Error('receipt denied') + } + }) + await Promise.resolve() + child.emit('spawn') + expect(warn).toHaveBeenCalledOnce() + child.emit('exit', 17) + await expect(result).resolves.toBe(17) + expect(reportSpawnError).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('reports a child spawn error instead of a successful wrapper exit', async () => { + const child = new EventEmitter() + const reportSpawnError = vi.fn() + const result = runWindowsBunPtyGate(request, { + waitForGate: async () => {}, + reportSpawnError, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes the error/exit events the gate consumes. + spawn: () => child as ReturnType + }) + await Promise.resolve() + child.emit('error', new Error('spawn denied')) + await expect(result).rejects.toThrow('spawn denied') + expect(reportSpawnError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'spawn denied' }) + ) + }) + + it('reports synchronous native spawn rejection without requiring a child event', async () => { + const reportSpawnError = vi.fn() + await expect( + runWindowsBunPtyGate(request, { + waitForGate: async () => {}, + spawn: () => { + throw new Error('invalid executable') + }, + reportSpawnError + }) + ).rejects.toThrow('invalid executable') + expect(reportSpawnError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'invalid executable' }) + ) + }) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-gate.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.ts new file mode 100644 index 00000000000..384038c98f7 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-gate.ts @@ -0,0 +1,172 @@ +import { readFileSync, statSync, unlinkSync } from 'node:fs' +import { setTimeout as delay } from 'node:timers/promises' +import { win32 } from 'node:path' +import { spawnProcess, type ProcessSpec } from '../../../shared/child-process/run-process' +import { + publishWindowsBunPtyShellPid, + publishWindowsBunPtySpawnError +} from './windows-bun-pty-spawn-receipt' + +export const WINDOWS_BUN_PTY_GATE_ENV = 'ORCA_BUN_PTY_JOB_GATE' +export const WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS = ['NODE_OPTIONS', 'BUN_OPTIONS'] as const + +export type WindowsBunPtyGateRequest = { + file: string + args: string[] + cwd: string + gatePath: string + shellPidPath: string + runtimeOptions: Partial> +} + +export function readWindowsBunPtyGateRequest(path: string): WindowsBunPtyGateRequest { + if (statSync(path).size > 1024 * 1024) { + throw new Error('Windows PTY gate request exceeds its size limit') + } + let value: unknown + try { + value = JSON.parse(readFileSync(path, 'utf8')) + } catch { + throw new Error('Invalid Windows PTY gate request') + } + const request = value + if ( + typeof request !== 'object' || + request === null || + !('file' in request) || + typeof request.file !== 'string' || + !request.file || + !('args' in request) || + !Array.isArray(request.args) || + !request.args.every((arg): arg is string => typeof arg === 'string') || + !('cwd' in request) || + typeof request.cwd !== 'string' || + !request.cwd || + !('gatePath' in request) || + typeof request.gatePath !== 'string' || + !request.gatePath || + !('shellPidPath' in request) || + typeof request.shellPidPath !== 'string' || + !request.shellPidPath || + !('runtimeOptions' in request) || + typeof request.runtimeOptions !== 'object' || + request.runtimeOptions === null || + Array.isArray(request.runtimeOptions) + ) { + throw new Error('Invalid Windows PTY gate request') + } + const runtimeOptions: WindowsBunPtyGateRequest['runtimeOptions'] = {} + for (const [key, value] of Object.entries(request.runtimeOptions)) { + if ((key !== 'NODE_OPTIONS' && key !== 'BUN_OPTIONS') || typeof value !== 'string') { + throw new Error('Invalid Windows PTY gate request') + } + runtimeOptions[key] = value + } + return { + file: request.file, + args: request.args, + cwd: request.cwd, + gatePath: request.gatePath, + shellPidPath: request.shellPidPath, + runtimeOptions + } +} + +export async function waitForWindowsBunPtyJobGate(gatePath: string): Promise { + const deadline = Date.now() + 30_000 + while (true) { + try { + unlinkSync(gatePath) + return + } catch (error) { + if ( + typeof error !== 'object' || + error === null || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error + } + } + if (Date.now() >= deadline) { + throw new Error('Windows PTY job assignment timed out') + } + await delay(5) + } +} + +export function windowsBunPtyChildSpec( + request: WindowsBunPtyGateRequest, + inheritedEnv: NodeJS.ProcessEnv +): ProcessSpec { + const env: NodeJS.ProcessEnv = { ...inheritedEnv, ...request.runtimeOptions } + delete env[WINDOWS_BUN_PTY_GATE_ENV] + delete env.ORCA_BUN_PTY_CHILD_COMMAND + return { + program: request.file, + args: request.args, + cwd: request.cwd, + env, + stdio: 'inherit', + // cmd owns the command text following /K or /C; it must not receive CRT argv escaping. + ...(win32.basename(request.file).toLowerCase() === 'cmd.exe' + ? { windowsVerbatimArguments: true } + : {}) + } +} + +export async function runWindowsBunPtyGate( + request: WindowsBunPtyGateRequest, + deps: { + waitForGate?: (gatePath: string) => Promise + spawn?: typeof spawnProcess + env?: NodeJS.ProcessEnv + reportShellPid?: (pid: number) => void + reportSpawnError?: (error: unknown) => void + } = {} +): Promise { + let spawned = false + // Preserve supervision when Ctrl-C reaches the entire Windows console. + const ignoreInterrupt = (): void => {} + if (process.platform === 'win32') { + process.on('SIGINT', ignoreInterrupt) + } + try { + await (deps.waitForGate ?? waitForWindowsBunPtyJobGate)(request.gatePath) + return await new Promise((resolve, reject) => { + const child = (deps.spawn ?? spawnProcess)( + windowsBunPtyChildSpec(request, deps.env ?? process.env) + ) + child.once('spawn', () => { + spawned = true + if (child.pid !== undefined) { + const report = + deps.reportShellPid ?? + ((pid) => publishWindowsBunPtyShellPid(request.shellPidPath, pid)) + try { + report(child.pid) + } catch (error) { + // Keep supervising the shell; absent identity must remain unverifiable. + console.warn('[pty] Failed to publish Windows shell identity:', error) + } + } + }) + child.once('error', reject) + child.once('exit', (code) => resolve(code ?? 1)) + }) + } catch (error) { + if (!spawned) { + try { + const report = + deps.reportSpawnError ?? + ((error) => publishWindowsBunPtySpawnError(request.shellPidPath, error)) + report(error) + } catch (receiptError) { + console.warn('[pty] Failed to publish Windows shell spawn error:', receiptError) + } + } + throw error + } finally { + process.off('SIGINT', ignoreInterrupt) + } +} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts new file mode 100644 index 00000000000..ccc3ebc6d1a --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-job.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __resetWindowsBunPtyJobForTests, + assignCurrentProcessToBunPtyHostJob, + createWindowsBunPtyJob, + type WindowsBunPtyJobNative +} from './windows-bun-pty-job' + +function createNative(overrides: Partial = {}): WindowsBunPtyJobNative { + return { + createJob: vi.fn(() => 7), + configureJob: vi.fn(() => true), + currentProcess: vi.fn(() => 99), + openProcess: vi.fn((_access, pid) => 1_000 + pid), + assignProcess: vi.fn(() => true), + isProcessInJob: vi.fn(() => true), + queryProcessIds: vi.fn(() => [11]), + suspendProcess: vi.fn(() => true), + resumeProcess: vi.fn(() => true), + terminateJob: vi.fn(() => true), + closeHandle: vi.fn(), + ...overrides + } +} + +afterEach(() => { + vi.restoreAllMocks() + __resetWindowsBunPtyJobForTests() +}) + +describe('Windows Bun PTY job ownership', () => { + it('assigns the daemon to one kill-on-close host job', () => { + const native = createNative() + + expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(true) + expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(true) + + expect(native.createJob).toHaveBeenCalledOnce() + expect(native.configureJob).toHaveBeenCalledWith(7, 0x2800) + expect(native.assignProcess).toHaveBeenCalledWith(7, 99) + }) + + it('closes a rejected host job and caches the unavailable result', () => { + const native = createNative({ assignProcess: vi.fn(() => false) }) + + expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(false) + expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(false) + + expect(native.createJob).toHaveBeenCalledOnce() + expect(native.closeHandle).toHaveBeenCalledWith(7) + }) + + it('assigns the gated PTY root before exposing the job', () => { + const native = createNative() + + const job = createWindowsBunPtyJob(11, native) + + expect(job).not.toBeNull() + expect(native.configureJob).toHaveBeenCalledWith(7, 0) + expect(native.openProcess).toHaveBeenCalledWith(0x1901, 11) + expect(native.assignProcess).toHaveBeenCalledWith(7, 1011) + expect(native.closeHandle).toHaveBeenCalledWith(1011) + }) + + it('suspends children that appear during the ownership fence and resumes exact handles', () => { + const queryProcessIds = vi + .fn<() => readonly number[] | null>() + .mockReturnValueOnce([11, 12]) + .mockReturnValueOnce([11, 12, 13]) + .mockReturnValueOnce([11, 12, 13]) + .mockReturnValue([11, 12, 13]) + const native = createNative({ queryProcessIds }) + const job = createWindowsBunPtyJob(11, native)! + vi.mocked(native.closeHandle).mockClear() + + expect(job.pause()).toBe(true) + expect(native.suspendProcess).toHaveBeenCalledWith(1011) + expect(native.suspendProcess).toHaveBeenCalledWith(1012) + expect(native.suspendProcess).toHaveBeenCalledWith(1013) + expect(job.resume()).toBe(true) + + expect(native.resumeProcess).toHaveBeenCalledWith(1011) + expect(native.resumeProcess).toHaveBeenCalledWith(1012) + expect(native.resumeProcess).toHaveBeenCalledWith(1013) + expect(native.closeHandle).toHaveBeenCalledWith(1011) + expect(native.closeHandle).toHaveBeenCalledWith(1012) + expect(native.closeHandle).toHaveBeenCalledWith(1013) + }) + + it('never suspends a PID whose opened handle is outside the owned job', () => { + const native = createNative({ + queryProcessIds: vi.fn(() => [11, 12]), + isProcessInJob: vi.fn((process) => process !== 1012) + }) + const job = createWindowsBunPtyJob(11, native)! + + expect(job.pause()).toBe(false) + + expect(native.suspendProcess).toHaveBeenCalledWith(1011) + expect(native.suspendProcess).not.toHaveBeenCalledWith(1012) + expect(native.resumeProcess).toHaveBeenCalledWith(1011) + expect(native.closeHandle).toHaveBeenCalledWith(1012) + }) + + it('terminates a paused tree without resuming it first', () => { + const native = createNative({ queryProcessIds: vi.fn(() => [11]) }) + const job = createWindowsBunPtyJob(11, native)! + + expect(job.pause()).toBe(true) + expect(job.terminate()).toBe('terminated') + job.close() + + expect(native.terminateJob).toHaveBeenCalledWith(7) + expect(native.resumeProcess).not.toHaveBeenCalled() + expect(native.closeHandle).toHaveBeenCalledWith(1011) + expect(native.closeHandle).toHaveBeenCalledWith(7) + }) + + it('retains an exact handle when resume fails so a later retry can recover it', () => { + const resumeProcess = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) + const native = createNative({ resumeProcess }) + const job = createWindowsBunPtyJob(11, native)! + vi.mocked(native.closeHandle).mockClear() + + expect(job.pause()).toBe(true) + expect(job.resume()).toBe(false) + expect(native.closeHandle).not.toHaveBeenCalledWith(1011) + expect(job.resume()).toBe(true) + expect(native.closeHandle).toHaveBeenCalledWith(1011) + }) + + it('keeps breakaway denied when forced termination needs kill-on-close', () => { + const native = createNative({ + resumeProcess: vi.fn(() => false), + terminateJob: vi.fn(() => false) + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const job = createWindowsBunPtyJob(11, native)! + expect(job.pause()).toBe(true) + job.close() + expect(native.configureJob).toHaveBeenLastCalledWith(7, 0x2000) + }) + + it('terminates a still-suspended tree instead of abandoning it during close', () => { + const native = createNative({ resumeProcess: vi.fn(() => false) }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const job = createWindowsBunPtyJob(11, native)! + vi.mocked(native.closeHandle).mockClear() + + expect(job.pause()).toBe(true) + job.close() + + expect(native.terminateJob).toHaveBeenCalledWith(7) + expect(native.closeHandle).toHaveBeenCalledWith(1011) + expect(native.closeHandle).toHaveBeenCalledWith(7) + expect(warn).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts new file mode 100644 index 00000000000..5f0a7065c85 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-job.ts @@ -0,0 +1,218 @@ +import type { JobTerminationOutcome } from '../../windows/windows-pty-job' +import { + __resetWindowsBunPtyNativeForTests, + loadWindowsBunPtyJobNative, + type WindowsBunPtyJobNative, + type WindowsNativeHandle +} from './windows-bun-pty-native' + +export type { WindowsBunPtyJobNative } from './windows-bun-pty-native' + +export type WindowsBunPtyJob = { + listProcessIds(): readonly number[] | null + pause(): boolean + resume(): boolean + terminate(): JobTerminationOutcome + close(): void +} + +const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0000_0800 +const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x0000_2000 +const PROCESS_TERMINATE = 0x0001 +const PROCESS_SET_QUOTA = 0x0100 +const PROCESS_SUSPEND_RESUME = 0x0800 +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +const MAX_SUSPEND_PASSES = 8 + +let hostJobAssigned: boolean | null = null + +export function assignCurrentProcessToBunPtyHostJob( + native: WindowsBunPtyJobNative | null = loadWindowsBunPtyJobNative() +): boolean { + if (hostJobAssigned !== null) { + return hostJobAssigned + } + if (!native) { + hostJobAssigned = false + return false + } + const job = native.createJob() + if ( + job === null || + !native.configureJob(job, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK) || + !native.assignProcess(job, native.currentProcess()) + ) { + if (job !== null) { + native.closeHandle(job) + } + hostJobAssigned = false + return false + } + // The host job deliberately lives until Windows closes it during process teardown. + hostJobAssigned = true + return true +} + +class BunPtyJob implements WindowsBunPtyJob { + private readonly suspended = new Map() + private closed = false + private fullySuspended = false + private terminated = false + + constructor( + private readonly rootPid: number, + private readonly handle: WindowsNativeHandle, + private readonly native: WindowsBunPtyJobNative + ) {} + + listProcessIds(): readonly number[] | null { + return this.closed ? null : this.native.queryProcessIds(this.handle) + } + + pause(): boolean { + if (this.closed || this.terminated) { + return false + } + if (this.fullySuspended) { + return true + } + if (this.suspended.size > 0 && !this.resume()) { + return false + } + for (let pass = 0; pass < MAX_SUSPEND_PASSES; pass += 1) { + const pids = this.listProcessIds() + if (!pids) { + this.resume() + return false + } + const ordered = [...pids].sort((left, right) => { + if (left === this.rootPid) { + return -1 + } + if (right === this.rootPid) { + return 1 + } + return left - right + }) + let progressed = false + for (const pid of ordered) { + if (this.suspended.has(pid)) { + continue + } + const process = this.native.openProcess( + PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED_INFORMATION, + pid + ) + if (process === null) { + continue + } + if (!this.native.isProcessInJob(process, this.handle)) { + this.native.closeHandle(process) + continue + } + if (!this.native.suspendProcess(process)) { + this.native.closeHandle(process) + continue + } + this.suspended.set(pid, process) + progressed = true + } + const remaining = this.listProcessIds() + if (remaining && remaining.every((pid) => this.suspended.has(pid))) { + this.fullySuspended = true + return true + } + if (!remaining || !progressed) { + this.resume() + return false + } + } + this.resume() + return false + } + + resume(): boolean { + this.fullySuspended = false + const ownedPids = this.terminated ? [] : this.listProcessIds() + for (const [pid, process] of this.suspended) { + const processExited = ownedPids !== null && !ownedPids.includes(pid) + if (!this.terminated && !processExited && !this.native.resumeProcess(process)) { + continue + } + this.native.closeHandle(process) + this.suspended.delete(pid) + } + return this.suspended.size === 0 + } + + terminate(): JobTerminationOutcome { + if (this.closed) { + return this.terminated ? 'terminated' : 'unavailable' + } + if (!this.terminated) { + this.terminated = this.native.terminateJob(this.handle) + } + if (this.terminated) { + this.resume() + return 'terminated' + } + return 'unavailable' + } + + close(): void { + if (this.closed) { + return + } + if (!this.resume()) { + console.warn( + '[daemon/pty] Could not resume a Windows PTY tree during cleanup; terminating it' + ) + this.terminated = this.native.terminateJob(this.handle) + if (!this.terminated) { + this.terminated = this.native.configureJob(this.handle, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) + } + this.resume() + } + this.native.closeHandle(this.handle) + this.closed = true + } +} + +export function createWindowsBunPtyJob( + rootPid: number, + native: WindowsBunPtyJobNative | null = loadWindowsBunPtyJobNative() +): WindowsBunPtyJob | null { + if (!native || !Number.isInteger(rootPid) || rootPid <= 0) { + return null + } + const job = native.createJob() + if (job === null || !native.configureJob(job, 0)) { + if (job !== null) { + native.closeHandle(job) + } + return null + } + const process = native.openProcess( + PROCESS_SET_QUOTA | + PROCESS_TERMINATE | + PROCESS_SUSPEND_RESUME | + PROCESS_QUERY_LIMITED_INFORMATION, + rootPid + ) + if (process === null) { + native.closeHandle(job) + return null + } + const assigned = native.assignProcess(job, process) + native.closeHandle(process) + if (!assigned) { + native.closeHandle(job) + return null + } + return new BunPtyJob(rootPid, job, native) +} + +export function __resetWindowsBunPtyJobForTests(): void { + __resetWindowsBunPtyNativeForTests() + hostJobAssigned = null +} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts new file mode 100644 index 00000000000..1f4df4ef749 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.test.ts @@ -0,0 +1,157 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createWindowsBunPtyLaunch, resolveWindowsBunPtyGateEntry } from './windows-bun-pty-launch' +import { readWindowsBunPtyGateRequest, windowsBunPtyChildSpec } from './windows-bun-pty-gate' +import { publishWindowsBunPtyShellPid } from './windows-bun-pty-spawn-receipt' + +const workerPath = join(__dirname, 'windows-bun-pty-launch.test.ts') + +describe('Windows Bun PTY gated launch', () => { + it('reads only the atomic shell receipt and retains its first identity through cleanup', () => { + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) + try { + expect(launch.readShellProcessId()).toBeUndefined() + writeFileSync(`${shellPidPath}.pending`, '12') + expect(launch.readShellProcessId()).toBeUndefined() + writeFileSync(shellPidPath, 'not a PID') + expect(launch.readShellProcessId()).toBeUndefined() + for (const receipt of ['0', '-123', '4294967296', '123\n456', '1e3', '12.3']) { + writeFileSync(shellPidPath, receipt) + expect(launch.readShellProcessId()).toBeUndefined() + } + writeFileSync(shellPidPath, '1234') + expect(launch.readShellProcessId()).toBe(1234) + writeFileSync(shellPidPath, '5678') + expect(launch.readShellProcessId()).toBe(1234) + } finally { + launch.dispose() + } + expect(launch.readShellProcessId()).toBe(1234) + }) + + it('publishes a complete PID and leaves no intermediate receipt', () => { + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) + try { + publishWindowsBunPtyShellPid(shellPidPath, 1234) + expect(launch.readShellProcessId()).toBe(1234) + expect(existsSync(`${shellPidPath}.pending`)).toBe(false) + } finally { + launch.dispose() + } + }) + + it('preserves long executable argv without cmd interpretation and releases only once', () => { + const file = 'C:\\状 態\\%tool%&shell.exe' + const args = ['a b', 'c"d', 'e%F%g', 'h&i', 'j^k', 'bang!', 'line\nbreak', 'x'.repeat(16000)] + const launch = createWindowsBunPtyLaunch( + { file, args, cwd: 'C:\\work tree', env: { TERM: 'xterm-256color' } }, + { workerPath } + ) + const gate = launch.env.ORCA_BUN_PTY_JOB_GATE + const directory = dirname(gate) + try { + const request = readWindowsBunPtyGateRequest(launch.command.at(-1)!) + expect(request).toMatchObject({ file, args, cwd: 'C:\\work tree', gatePath: gate }) + const child = windowsBunPtyChildSpec(request, launch.env) + expect(child.program).toBe(file) + expect(child.args).toEqual(args) + expect(child.windowsVerbatimArguments).toBeUndefined() + expect(child.stdio).toBe('inherit') + expect(child.env).not.toHaveProperty('ORCA_BUN_PTY_JOB_GATE') + expect(launch.windowsVerbatimArguments).toBe(false) + expect(launch.command).toContain('--no-env-file') + expect(launch.command).toContain(`--config=${join(directory, 'bunfig.toml')}`) + expect(launch.command).toContain(`--cwd=${directory}`) + expect(launch.command.join(' ').length).toBeLessThan(8191) + const clear = readFileSync(join(directory, 'clear.cmd')) + expect(clear.includes(Buffer.from('\x1b[3J\x1b[2J\x1b[H'))).toBe(true) + expect(existsSync(gate)).toBe(false) + launch.release() + launch.release() + expect(existsSync(gate)).toBe(true) + } finally { + launch.dispose() + launch.dispose() + } + expect(existsSync(directory)).toBe(false) + }) + + it.each(['/K', '/k', '/C', '/c'])( + 'preserves direct cmd %s command text without CRT escaping', + (commandSwitch) => { + const file = 'C:\\Windows\\System32\\CMD.EXE' + const args = [commandSwitch, 'chcp 65001 > nul & echo 状態%VALUE%!'] + const launch = createWindowsBunPtyLaunch({ file, args, env: {} }, { workerPath }) + try { + const child = windowsBunPtyChildSpec( + readWindowsBunPtyGateRequest(launch.command.at(-1)!), + launch.env + ) + expect(child.program).toBe(file) + expect(child.args).toEqual(args) + expect(child.windowsVerbatimArguments).toBe(true) + } finally { + launch.dispose() + } + } + ) + + it('withholds runtime preload options from the gate while preserving the shell environment', () => { + const env = { + NODE_OPTIONS: '--require C:\\workspace\\hook.js', + BUN_OPTIONS: '--preload hook.js', + TERM: 'xterm-256color' + } + const launch = createWindowsBunPtyLaunch({ file: 'shell.exe', args: [], env }, { workerPath }) + try { + expect(launch.env).not.toHaveProperty('NODE_OPTIONS') + expect(launch.env).not.toHaveProperty('BUN_OPTIONS') + expect( + windowsBunPtyChildSpec(readWindowsBunPtyGateRequest(launch.command.at(-1)!), launch.env).env + ).toEqual(env) + } finally { + launch.dispose() + } + }) + + it('fails before launch when the gate entry is missing', () => { + expect(() => + createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath: join(workerPath, 'missing') } + ) + ).toThrow('Windows PTY gate entry not found') + }) + + it('rejects a cmd-unsafe line break before creating launch state', () => { + expect(() => + createWindowsBunPtyLaunch( + { file: 'C:\\Windows\\System32\\cmd.exe', args: ['/c', 'first\nsecond'], env: {} }, + { workerPath } + ) + ).toThrow('cmd.exe cannot receive an argument containing a line break') + }) + + it('resolves adjacent, factored-chunk, and unpacked desktop layouts', () => { + const name = 'windows-bun-pty-gate-entry.js' + expect(resolveWindowsBunPtyGateEntry('/orcad', () => true)).toBe(join('/orcad', name)) + expect( + resolveWindowsBunPtyGateEntry( + '/app/out/main/chunks', + (path) => path === join('/app/out/main', name) + ) + ).toBe(join('/app/out/main', name)) + expect(resolveWindowsBunPtyGateEntry('/resources/app.asar/out/main', () => true)).toBe( + join('/resources/app.asar.unpacked/out/main', name) + ) + }) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts new file mode 100644 index 00000000000..dda83ad021a --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts @@ -0,0 +1,159 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, win32 } from 'node:path' +import { + buildWindowsCmdShimCommandLine, + validateWindowsCmdArguments +} from '../../../shared/child-process/windows-command-line' +import { getCmdExePath } from '../../../shared/windows-batch-spawn' +import { + WINDOWS_BUN_PTY_GATE_ENV, + WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS, + type WindowsBunPtyGateRequest +} from './windows-bun-pty-gate' +import { + readWindowsBunPtySpawnReceipt, + waitForWindowsBunPtySpawn, + type WindowsBunPtySpawnReceipt +} from './windows-bun-pty-spawn-receipt' + +const CLEAR_SEQUENCE = '\x1b[3J\x1b[2J\x1b[H' +const CLEANUP_MAX_RETRIES = 5 +const CLEANUP_RETRY_DELAY_MS = 50 + +export function resolveWindowsBunPtyGateEntry( + runtimeDir = __dirname, + pathExists: (path: string) => boolean = existsSync +): string { + const directory = runtimeDir.replace(/app\.asar(?=[\\/]|$)/, 'app.asar.unpacked') + const candidates = [ + join(directory, 'windows-bun-pty-gate-entry.js'), + join(directory, '..', 'windows-bun-pty-gate-entry.js') + ] + return candidates.find(pathExists) ?? candidates[0]! +} + +function removeLaunchDirectory(directory: string): boolean { + try { + rmSync(directory, { + recursive: true, + force: true, + maxRetries: CLEANUP_MAX_RETRIES, + retryDelay: CLEANUP_RETRY_DELAY_MS + }) + return true + } catch (error) { + console.warn(`[pty] failed to remove Windows Bun launch directory ${directory}:`, error) + return false + } +} + +export type WindowsBunPtyLaunch = { + command: string[] + clearCommand: string[] + env: Record + windowsVerbatimArguments: boolean + readShellProcessId(): number | undefined + waitForSpawn(wrapperExited: Promise): Promise + release(): void + dispose(): void +} + +export function createWindowsBunPtyLaunch( + args: { + file: string + args: string[] + env: Record + cwd?: string + }, + deps: { workerPath?: string; runtimePath?: string } = {} +): WindowsBunPtyLaunch { + if (win32.basename(args.file).toLowerCase() === 'cmd.exe') { + validateWindowsCmdArguments([args.file, ...args.args]) + } + const workerPath = deps.workerPath ?? resolveWindowsBunPtyGateEntry() + if (!existsSync(workerPath)) { + throw new Error(`Windows PTY gate entry not found: ${workerPath}`) + } + const directory = mkdtempSync(join(tmpdir(), 'orca-bun-pty-')) + const gatePath = join(directory, 'job-assigned') + const requestPath = join(directory, 'request.json') + const shellPidPath = join(directory, 'shell.pid') + const configPath = join(directory, 'bunfig.toml') + const clearPath = join(directory, 'clear.cmd') + const cmdExe = getCmdExePath() + let released = false + let disposed = false + let spawnReceipt: WindowsBunPtySpawnReceipt | undefined + const readSpawnReceipt = (): WindowsBunPtySpawnReceipt | undefined => { + if (!disposed) { + spawnReceipt ??= readWindowsBunPtySpawnReceipt(shellPidPath) + } + return spawnReceipt + } + const env: Record = { ...args.env, [WINDOWS_BUN_PTY_GATE_ENV]: gatePath } + const runtimeOptions: WindowsBunPtyGateRequest['runtimeOptions'] = {} + for (const key of WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS) { + if (env[key] !== undefined) { + runtimeOptions[key] = env[key] + } + delete env[key] + } + + try { + writeFileSync( + requestPath, + JSON.stringify({ + file: args.file, + args: args.args, + cwd: args.cwd ?? process.cwd(), + gatePath, + shellPidPath, + runtimeOptions + } satisfies WindowsBunPtyGateRequest), + { encoding: 'utf8', flag: 'wx', mode: 0o600 } + ) + writeFileSync(configPath, '', { flag: 'wx', mode: 0o600 }) + writeFileSync(clearPath, `@echo off\r\n waitForWindowsBunPtySpawn(readSpawnReceipt, wrapperExited), + release() { + if (released) { + return + } + writeFileSync(gatePath, '', { flag: 'wx' }) + released = true + }, + dispose() { + if (disposed) { + return + } + readSpawnReceipt() + disposed = removeLaunchDirectory(directory) + } + } +} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-native.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-native.test.ts new file mode 100644 index 00000000000..de6ff4ee264 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-native.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import { queryWindowsBunPtyProcessIds } from './windows-bun-pty-native' + +function writeProcessList(bytes: Uint8Array, pids: number[]): boolean { + const capacity = (bytes.byteLength - 8) / 8 + const view = new DataView(bytes.buffer) + view.setUint32(0, pids.length, true) + view.setUint32(4, Math.min(pids.length, capacity), true) + pids + .slice(0, capacity) + .forEach((pid, index) => view.setBigUint64(8 + index * 8, BigInt(pid), true)) + return pids.length <= capacity +} + +describe('Windows Bun job process enumeration', () => { + it.each([false, true])( + 'grows an incomplete process list when the native call returns %s', + (result) => { + const pids = Array.from({ length: 257 }, (_, index) => index + 1) + const query = vi.fn((bytes: Uint8Array) => writeProcessList(bytes, pids) || result) + expect(queryWindowsBunPtyProcessIds(query)).toEqual(pids) + expect(query.mock.calls.map(([bytes]) => (bytes.byteLength - 8) / 8)).toEqual([64, 256, 1024]) + } + ) + + it('does not mistake a failed native query for an empty job', () => { + const query = vi.fn(() => false) + expect(queryWindowsBunPtyProcessIds(query)).toBeNull() + expect(query).toHaveBeenCalledOnce() + }) + + it('returns an empty list only when the native query succeeds', () => { + expect(queryWindowsBunPtyProcessIds(() => true)).toEqual([]) + }) + + it('bounds growth when a process tree exceeds the inventory limit', () => { + const query = vi.fn((bytes: Uint8Array) => { + new DataView(bytes.buffer).setUint32(0, 20_000, true) + return false + }) + expect(queryWindowsBunPtyProcessIds(query)).toBeNull() + expect(query).toHaveBeenCalledTimes(5) + }) + + it.each([0, 0x1_0000_0000])( + 'refuses invalid PID %s without reporting partial ownership', + (pid) => { + expect( + queryWindowsBunPtyProcessIds((bytes) => writeProcessList(bytes, [1234, pid])) + ).toBeNull() + } + ) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-native.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-native.ts new file mode 100644 index 00000000000..c9d72e9bf9d --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-native.ts @@ -0,0 +1,176 @@ +import { createRequire } from 'node:module' + +export type WindowsNativeHandle = number | bigint +type NativePointer = number | bigint + +export type WindowsBunPtyJobNative = { + createJob(): WindowsNativeHandle | null + configureJob(job: WindowsNativeHandle, flags: number): boolean + currentProcess(): WindowsNativeHandle + openProcess(access: number, pid: number): WindowsNativeHandle | null + assignProcess(job: WindowsNativeHandle, process: WindowsNativeHandle): boolean + isProcessInJob(process: WindowsNativeHandle, job: WindowsNativeHandle): boolean + queryProcessIds(job: WindowsNativeHandle): readonly number[] | null + suspendProcess(process: WindowsNativeHandle): boolean + resumeProcess(process: WindowsNativeHandle): boolean + terminateJob(job: WindowsNativeHandle): boolean + closeHandle(handle: WindowsNativeHandle): void +} + +type FfiFunction = { args: readonly string[]; returns: string } +type FfiLibrary = { symbols: T } +type BunFfi = { + dlopen(name: string, symbols: Record): FfiLibrary + ptr(view: ArrayBufferView): NativePointer +} + +type Kernel32 = { + CreateJobObjectW(attributes: null, name: null): WindowsNativeHandle | null + SetInformationJobObject( + job: WindowsNativeHandle, + infoClass: number, + info: NativePointer, + infoLength: number + ): number + GetCurrentProcess(): WindowsNativeHandle + OpenProcess(access: number, inherit: number, pid: number): WindowsNativeHandle | null + AssignProcessToJobObject(job: WindowsNativeHandle, process: WindowsNativeHandle): number + IsProcessInJob( + process: WindowsNativeHandle, + job: WindowsNativeHandle, + result: NativePointer + ): number + QueryInformationJobObject( + job: WindowsNativeHandle, + infoClass: number, + info: NativePointer, + infoLength: number, + returnLength: null + ): number + TerminateJobObject(job: WindowsNativeHandle, exitCode: number): number + CloseHandle(handle: WindowsNativeHandle): number +} + +type Ntdll = { + NtSuspendProcess(process: WindowsNativeHandle): number + NtResumeProcess(process: WindowsNativeHandle): number +} + +const requireFromMain = createRequire(__filename) +const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 +const JOB_OBJECT_BASIC_PROCESS_ID_LIST = 3 +const JOB_LIMIT_FLAGS_OFFSET = 16 +const JOB_EXTENDED_LIMITS_BYTES = 144 +const MAX_JOB_PROCESS_IDS = 16_384 + +export function queryWindowsBunPtyProcessIds( + query: (buffer: Uint8Array) => boolean +): readonly number[] | null { + for (let capacity = 64; capacity <= MAX_JOB_PROCESS_IDS; capacity *= 4) { + const bytes = new Uint8Array(8 + capacity * 8) + const queried = query(bytes) + const view = new DataView(bytes.buffer) + const assigned = view.getUint32(0, true) + const count = view.getUint32(4, true) + // These output counts survive the FFI boundary; thread-local GetLastError may not. + if (assigned > count) { + continue + } + if (!queried || count > capacity) { + return null + } + const pids: number[] = [] + for (let index = 0; index < count; index += 1) { + const pid = Number(view.getBigUint64(8 + index * 8, true)) + if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) { + return null + } + pids.push(pid) + } + return pids + } + return null +} + +let cachedNative: WindowsBunPtyJobNative | null | undefined + +export function loadWindowsBunPtyJobNative(): WindowsBunPtyJobNative | null { + if (cachedNative !== undefined) { + return cachedNative + } + if (process.platform !== 'win32') { + cachedNative = null + return cachedNative + } + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned Bun runtime supplies these FFI exports; loading failures refuse job ownership. + const ffi = requireFromMain('bun:ffi') as BunFfi + const kernel = ffi.dlopen('kernel32.dll', { + CreateJobObjectW: { args: ['ptr', 'ptr'], returns: 'ptr' }, + SetInformationJobObject: { args: ['ptr', 'u32', 'ptr', 'u32'], returns: 'i32' }, + GetCurrentProcess: { args: [], returns: 'ptr' }, + OpenProcess: { args: ['u32', 'i32', 'u32'], returns: 'ptr' }, + AssignProcessToJobObject: { args: ['ptr', 'ptr'], returns: 'i32' }, + IsProcessInJob: { args: ['ptr', 'ptr', 'ptr'], returns: 'i32' }, + QueryInformationJobObject: { + args: ['ptr', 'u32', 'ptr', 'u32', 'ptr'], + returns: 'i32' + }, + TerminateJobObject: { args: ['ptr', 'u32'], returns: 'i32' }, + CloseHandle: { args: ['ptr'], returns: 'i32' } + }) + const ntdll = ffi.dlopen('ntdll.dll', { + NtSuspendProcess: { args: ['ptr'], returns: 'i32' }, + NtResumeProcess: { args: ['ptr'], returns: 'i32' } + }) + const { symbols } = kernel + cachedNative = { + createJob: () => symbols.CreateJobObjectW(null, null), + configureJob(job, flags) { + const limits = new Uint8Array(JOB_EXTENDED_LIMITS_BYTES) + new DataView(limits.buffer).setUint32(JOB_LIMIT_FLAGS_OFFSET, flags, true) + return ( + symbols.SetInformationJobObject( + job, + JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + ffi.ptr(limits), + limits.byteLength + ) !== 0 + ) + }, + currentProcess: () => symbols.GetCurrentProcess(), + openProcess: (access, pid) => symbols.OpenProcess(access, 0, pid), + assignProcess: (job, process) => symbols.AssignProcessToJobObject(job, process) !== 0, + isProcessInJob(process, job) { + const result = new Uint32Array(1) + return symbols.IsProcessInJob(process, job, ffi.ptr(result)) !== 0 && result[0] !== 0 + }, + queryProcessIds(job) { + return queryWindowsBunPtyProcessIds( + (bytes) => + symbols.QueryInformationJobObject( + job, + JOB_OBJECT_BASIC_PROCESS_ID_LIST, + ffi.ptr(bytes), + bytes.byteLength, + null + ) !== 0 + ) + }, + suspendProcess: (process) => ntdll.symbols.NtSuspendProcess(process) >= 0, + resumeProcess: (process) => ntdll.symbols.NtResumeProcess(process) >= 0, + terminateJob: (job) => symbols.TerminateJobObject(job, 1) !== 0, + closeHandle: (handle) => { + symbols.CloseHandle(handle) + } + } + return cachedNative + } catch { + cachedNative = null + return cachedNative + } +} + +export function __resetWindowsBunPtyNativeForTests(): void { + cachedNative = undefined +} diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.test.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.test.ts new file mode 100644 index 00000000000..56824521921 --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.test.ts @@ -0,0 +1,90 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createWindowsBunPtyLaunch } from './windows-bun-pty-launch' +import { readWindowsBunPtyGateRequest } from './windows-bun-pty-gate' +import { + publishWindowsBunPtyShellPid, + publishWindowsBunPtySpawnError, + WindowsBunPtySpawnUnconfirmedError +} from './windows-bun-pty-spawn-receipt' + +const workerPath = join(__dirname, 'windows-bun-pty-spawn-receipt.test.ts') +const neverExits = new Promise(() => {}) + +describe('Windows Bun shell spawn confirmation', () => { + afterEach(() => vi.useRealTimers()) + + it('waits for the actual shell and preserves successful immediate exit through cleanup', async () => { + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) + const ready = vi.fn() + let exit!: (code: number) => void + const exited = new Promise((resolve) => { + exit = resolve + }) + const waiting = launch.waitForSpawn(exited).then(ready) + try { + await Promise.resolve() + expect(ready).not.toHaveBeenCalled() + publishWindowsBunPtyShellPid(shellPidPath, 1234) + exit(17) + launch.dispose() + await waiting + expect(ready).toHaveBeenCalledOnce() + expect(launch.readShellProcessId()).toBe(1234) + expect(existsSync(dirname(shellPidPath))).toBe(false) + } finally { + launch.dispose() + } + }) + + it('preserves a definite spawn error through cleanup so the caller can retry another shell', async () => { + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!) + try { + publishWindowsBunPtySpawnError(shellPidPath, new Error('spawn ENOENT')) + expect(existsSync(`${shellPidPath}.error.pending`)).toBe(false) + launch.dispose() + await expect(launch.waitForSpawn(Promise.resolve(1))).rejects.toThrow('spawn ENOENT') + } finally { + launch.dispose() + } + }) + + it('refuses to retry an exited gate without a receipt because its shell may have run', async () => { + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + try { + await expect(launch.waitForSpawn(Promise.resolve(0))).rejects.toBeInstanceOf( + WindowsBunPtySpawnUnconfirmedError + ) + } finally { + launch.dispose() + } + }) + + it('bounds the wait for a live gate that never publishes its shell identity', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const launch = createWindowsBunPtyLaunch( + { file: 'shell.exe', args: [], env: {} }, + { workerPath } + ) + try { + const waiting = launch.waitForSpawn(neverExits) + const assertion = expect(waiting).rejects.toBeInstanceOf(WindowsBunPtySpawnUnconfirmedError) + vi.setSystemTime(Date.now() + 30_001) + await assertion + } finally { + launch.dispose() + } + }) +}) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.ts new file mode 100644 index 00000000000..43d193b76af --- /dev/null +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-spawn-receipt.ts @@ -0,0 +1,66 @@ +import { readFileSync, renameSync, writeFileSync } from 'node:fs' +import { setTimeout as delay } from 'node:timers/promises' + +export type WindowsBunPtySpawnReceipt = { pid: number } | { error: string } + +export class WindowsBunPtySpawnUnconfirmedError extends Error {} + +function publishReceipt(path: string, value: string): void { + const pending = `${path}.pending` + writeFileSync(pending, value, { flag: 'wx', mode: 0o600 }) + // ConPTY cannot inherit Bun IPC; publish the receipt atomically. + renameSync(pending, path) +} + +export function publishWindowsBunPtyShellPid(path: string, pid: number): void { + publishReceipt(path, String(pid)) +} + +export function publishWindowsBunPtySpawnError(path: string, error: unknown): void { + publishReceipt(`${path}.error`, error instanceof Error ? error.message : String(error)) +} + +export function readWindowsBunPtySpawnReceipt(path: string): WindowsBunPtySpawnReceipt | undefined { + try { + const receipt = readFileSync(path, 'utf8') + const pid = Number(receipt) + if (/^[1-9][0-9]{0,9}$/.test(receipt) && Number.isSafeInteger(pid) && pid <= 0xffff_ffff) { + return { pid } + } + } catch { + // Missing or unreadable identity never proves the shell failed to spawn. + } + try { + return { error: readFileSync(`${path}.error`, 'utf8') } + } catch { + return undefined + } +} + +export async function waitForWindowsBunPtySpawn( + readReceipt: () => WindowsBunPtySpawnReceipt | undefined, + wrapperExited: Promise +): Promise { + let ended = false + const markEnded = (): void => { + ended = true + } + void wrapperExited.then(markEnded, markEnded) + const deadline = Date.now() + 30_000 + while (true) { + const receipt = readReceipt() + if (receipt) { + if ('pid' in receipt) { + return + } + if (ended) { + throw new Error(receipt.error) + } + } + if (ended || Date.now() >= deadline) { + // An unreported shell may already have run a startup command; never retry it. + throw new WindowsBunPtySpawnUnconfirmedError('Windows shell spawn could not be confirmed') + } + await delay(5) + } +} diff --git a/src/main/daemon/session-subprocess-handle.ts b/src/main/daemon/session-subprocess-handle.ts index 9268686d78e..be336c998f2 100644 --- a/src/main/daemon/session-subprocess-handle.ts +++ b/src/main/daemon/session-subprocess-handle.ts @@ -1,9 +1,12 @@ +import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { JobTerminationOutcome } from '../windows/windows-pty-job' export type SubprocessHandle = { pid: number + processNameIsSpawnFile?: boolean + inspectChildProcesses?(): PtyChildProcessVerdict /** Live foreground process name of the PTY (node-pty's `.process`), e.g. * 'claude' / 'codex' / 'zsh'. Null once the child has exited. */ getForegroundProcess(options?: { rawFallback?: boolean }): string | null diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index a52e40a8678..e69bec7c5b1 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -4,10 +4,7 @@ import { createSessionOutputPipeline } from './session-output-pipeline' import { SessionProducerPause } from './session-producer-pause' import { SessionShellReadyBarrier } from './session-shell-ready-barrier' import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' -import { - SessionTerminationController, - IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS -} from './session-termination-controller' +import { SessionTerminationController } from './session-termination-controller' import type { SubprocessHandle } from './session-subprocess-handle' import type { JobTerminationOutcome } from '../windows/windows-pty-job' import type { SessionOptions } from './session-options' @@ -21,6 +18,7 @@ import type { TakePendingOutputResult, TerminalSnapshot } from './types' +import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' export class Session { @@ -29,6 +27,7 @@ export class Session { readonly terminalHandle: string | null readonly launchAgent: TuiAgent | null readonly wslDistro: string | null + readonly processNameIsSpawnFile: boolean private _state: SessionState = 'running' private _exitCode: number | null = null private _disposed = false @@ -47,6 +46,7 @@ export class Session { this.launchAgent = opts.launchAgent ?? null this.wslDistro = opts.wslDistro ?? null this.subprocess = opts.subprocess + this.processNameIsSpawnFile = opts.subprocess.processNameIsSpawnFile === true this.onSessionExit = opts.onExit const pipeline = createSessionOutputPipeline({ cols: opts.cols, @@ -195,9 +195,7 @@ export class Session { this.termination.scheduleForceDisposeFallback() } - async forceKillAndWaitForExit( - timeoutMs = IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS - ): Promise { + async forceKillAndWaitForExit(timeoutMs?: number): Promise { await this.termination.forceKillAndWaitForExit(timeoutMs) } @@ -253,6 +251,10 @@ export class Session { return this.output.getCwd() } + inspectChildProcesses(): PtyChildProcessVerdict { + return this.subprocess.inspectChildProcesses?.() ?? 'unverifiable' + } + getForegroundProcess(options?: { rawFallback?: boolean }): string | null { return this.subprocess.getForegroundProcess(options) } diff --git a/src/main/daemon/terminal-host-process-inspection.ts b/src/main/daemon/terminal-host-process-inspection.ts index 2f9fb1491d9..e149179c1aa 100644 --- a/src/main/daemon/terminal-host-process-inspection.ts +++ b/src/main/daemon/terminal-host-process-inspection.ts @@ -1,11 +1,14 @@ import { isShellProcess } from '../../shared/agent-detection' import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' import { getCheapProcessTableSnapshot } from '../../shared/cheap-process-table-snapshot-reader' import { getStrictProcessTableSnapshotWithAge } from '../../shared/process-table-snapshot-reader' import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process' import { buildPaneProcessFingerprint } from '../providers/posix-pane-foreground-fingerprint' import type { Session } from './session' +import { resolveSpawnFileForegroundFromRows } from './pty-subprocess/spawn-file-foreground-process' +import { inspectSpawnFileChildProcessesFromRows } from './pty-subprocess/spawn-file-child-processes' import { clearSteadyStateAnchor, getSteadyStateAnchor, @@ -16,6 +19,7 @@ import { SessionNotFoundError } from './types' export type TerminalHostProcessInspection = { foregroundProcess: string | null hasChildProcesses: boolean + childProcessEvidence?: PtyChildProcessVerdict foregroundProcessEvidence?: RemoteForegroundEvidence } @@ -76,13 +80,32 @@ export async function inspectTerminalHostProcess(args: { } args.onTier?.('full') - const foregroundProcess = session.getForegroundProcess() + let foregroundProcess = session.getForegroundProcess() + let childProcessEvidence: PtyChildProcessVerdict | undefined = session.processNameIsSpawnFile + ? 'unverifiable' + : undefined + if (session.processNameIsSpawnFile && process.platform === 'win32' && incarnationMatches) { + foregroundProcess = await session.confirmForegroundProcess() + childProcessEvidence = session.inspectChildProcesses() + } let evidence: RemoteForegroundEvidence if (!incarnationMatches) { evidence = unverifiableEvidence(args, session, 'incarnation_mismatch') } else { try { const snapshot = await getStrictProcessTableSnapshotWithAge() + if (session.processNameIsSpawnFile && process.platform !== 'win32') { + const observed = resolveSpawnFileForegroundFromRows(snapshot.rows, session.pid) + foregroundProcess = observed.available ? observed.processName : foregroundProcess + childProcessEvidence = inspectSpawnFileChildProcessesFromRows( + snapshot.rows, + session.pid, + session.getForegroundProcess({ rawFallback: true }) + ) + if (observed.available && observed.processName && !isShellProcess(observed.processName)) { + childProcessEvidence = 'children' + } + } evidence = resolveRemoteForegroundEvidence( { rootPid: session.pid, fallbackProcess: foregroundProcess }, { @@ -110,7 +133,11 @@ export async function inspectTerminalHostProcess(args: { evidence.verdict === 'live' ? (evidence.processName ?? ordinaryForeground) : foregroundProcess, - hasChildProcesses: nonShellForeground, + hasChildProcesses: + childProcessEvidence === undefined + ? nonShellForeground + : childProcessEvidence !== 'no-children', + ...(childProcessEvidence === undefined ? {} : { childProcessEvidence }), foregroundProcessEvidence: evidence } } diff --git a/src/main/daemon/windows-conpty-warmup.ts b/src/main/daemon/windows-conpty-warmup.ts index 6f39197261f..98c3144380c 100644 --- a/src/main/daemon/windows-conpty-warmup.ts +++ b/src/main/daemon/windows-conpty-warmup.ts @@ -1,8 +1,36 @@ import os from 'node:os' -import * as pty from 'node-pty' +import type * as pty from 'node-pty' +import { createRequire } from 'node:module' +import { canUseBunPty, spawnBunPty } from './pty-subprocess/bun-pty-process' import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job' const WARMUP_KILL_TIMEOUT_MS = 10_000 +const requireFromMain = createRequire(__filename) + +const spawnWarmupPty: typeof pty.spawn = (file, args, options) => { + if (canUseBunPty()) { + if (!Array.isArray(args)) { + throw new Error('Bun PTY requires argument arrays') + } + const env: Record = {} + for (const [key, value] of Object.entries(options.env ?? process.env)) { + if (value !== undefined) { + env[key] = value + } + } + return spawnBunPty({ + file, + args, + cwd: options.cwd ?? os.homedir(), + env, + cols: options.cols ?? 2, + rows: options.rows ?? 1 + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node-pty's installed package implements the declared spawn contract. + const nodePty = requireFromMain('node-pty') as typeof pty + return nodePty.spawn(file, args, options) +} /** * Pays the one-time cost of the first ConPTY spawn (conpty native module @@ -10,7 +38,7 @@ const WARMUP_KILL_TIMEOUT_MS = 10_000 * those binaries) at daemon boot instead of on the user's first terminal. * Measured ~2.7s on a Windows dev profile for the first spawn vs ~70ms after. */ -export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): void { +export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = spawnWarmupPty): void { if (process.platform !== 'win32') { return } @@ -19,7 +47,9 @@ export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): v setImmediate(() => { try { // Warm-up children must die with the daemon, even before its first real terminal. - assignHostProcessToKillOnCloseJob() + if (!canUseBunPty()) { + assignHostProcessToKillOnCloseJob() + } const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], { name: 'xterm-256color', cols: 2, diff --git a/src/main/ipc/parcel-watcher-in-process-fallback.ts b/src/main/ipc/parcel-watcher-in-process-fallback.ts index bcd2812d35d..1807ba603a5 100644 --- a/src/main/ipc/parcel-watcher-in-process-fallback.ts +++ b/src/main/ipc/parcel-watcher-in-process-fallback.ts @@ -1,4 +1,5 @@ import type * as ParcelWatcher from '@parcel/watcher' +import { loadParcelWatcher } from './parcel-watcher-module-loader' import { createWatcherProcessEventDeliveryQueue } from './parcel-watcher-event-delivery' import { WatcherProcessFailure } from './parcel-watcher-process-failure' import type { @@ -64,7 +65,7 @@ export async function subscribeWithInProcessWatcher( try { // Why: setup ownership starts before module loading; an abort or timeout // during the import must settle the caller just like one during the crawl. - watcher = await Promise.race([import('@parcel/watcher'), cancellation]) + watcher = await Promise.race([loadParcelWatcher(), cancellation]) } catch (error) { clearPendingControls() throw error diff --git a/src/main/ipc/parcel-watcher-module-loader.test.ts b/src/main/ipc/parcel-watcher-module-loader.test.ts new file mode 100644 index 00000000000..98e5eada431 --- /dev/null +++ b/src/main/ipc/parcel-watcher-module-loader.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { loadParcelWatcher } from './parcel-watcher-module-loader' + +const state = vi.hoisted((): { named: unknown; fallback: unknown } => ({ + named: undefined, + fallback: undefined +})) +vi.mock('@parcel/watcher', () => ({ + get subscribe() { + return state.named + }, + get default() { + return state.fallback + } +})) +beforeEach(() => { + state.named = undefined + state.fallback = undefined +}) + +it('uses named exports when the runtime exposes them', async () => { + const subscribe = vi.fn() + state.named = subscribe + state.fallback = { subscribe: vi.fn() } + expect((await loadParcelWatcher()).subscribe).toBe(subscribe) +}) + +it('loads the full CommonJS default when a packaged wrapper has no named exports', async () => { + const subscribe = vi.fn() + const getEventsSince = vi.fn() + state.fallback = { subscribe, getEventsSince } + expect(await loadParcelWatcher()).toBe(state.fallback) + expect((await loadParcelWatcher()).getEventsSince).toBe(getEventsSince) +}) + +it.each([undefined, null, {}, { subscribe: false }])( + 'rejects invalid watcher exports (%j)', + async (fallback) => { + state.fallback = fallback + await expect(loadParcelWatcher()).rejects.toThrow('parcel_watcher_module_invalid') + } +) diff --git a/src/main/ipc/parcel-watcher-module-loader.ts b/src/main/ipc/parcel-watcher-module-loader.ts new file mode 100644 index 00000000000..28a501363b5 --- /dev/null +++ b/src/main/ipc/parcel-watcher-module-loader.ts @@ -0,0 +1,11 @@ +import type * as ParcelWatcher from '@parcel/watcher' + +/** Native loading stays in the watcher child, across named and CommonJS exports. */ +export async function loadParcelWatcher(): Promise { + const loaded = await import('@parcel/watcher') + const watcher = typeof loaded.subscribe === 'function' ? loaded : loaded.default + if (!watcher || typeof watcher.subscribe !== 'function') { + throw new Error('parcel_watcher_module_invalid') + } + return watcher +} diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index ad0e79d6b97..0de05010f47 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -8,6 +8,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type * as ParcelWatcher from '@parcel/watcher' +import { loadParcelWatcher } from './parcel-watcher-module-loader' import { startShallowWatcher } from './parcel-watcher-shallow-subscription' import { detectShallowWatchDelivery } from './shallow-watch-delivery-probe' import { @@ -46,7 +47,7 @@ async function startCanary(getStableActivityRevision: () => number | null): Prom let lastEventAt = 0 try { canaryDir = configuredCanaryDir ?? mkdtempSync(join(tmpdir(), 'orca-watcher-canary-')) - const watcher = await import('@parcel/watcher') + const watcher = await loadParcelWatcher() // Why: pin the Windows backend like the main subscriptions do, so the // canary never probes for Watchman. const opts = ( @@ -211,7 +212,7 @@ function main(): void { send({ op: 'watch-error', id, message: errorMessage(error) }) ) } - const watcher = await import('@parcel/watcher') + const watcher = await loadParcelWatcher() return await watcher.subscribe( dir, (err, events) => { diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index bd130bfb33f..e746be8cc5e 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -1,11 +1,35 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ORCAD_PROFILE_PREFLIGHT_FLAG, + ORCAD_STARTUP_PREFLIGHT_FLAG +} from '../../shared/orcad-profile-preflight' /** * The precondition is only worth anything if it runs first. A loader failure is not * catchable, so a preflight that lands after `main()` has already reached * `await import('../ipc/pty')` prevents nothing. */ -const order: string[] = [] +const { order, profileProbe } = vi.hoisted(() => { + const order: string[] = [] + return { order, profileProbe: vi.fn(async () => {}) } +}) + +vi.mock('./orcad-bundled-runtime', () => ({ handoffToBundledOrcad: () => false })) +vi.mock('./orcad-profile-preflight', () => ({ + preflightBundledOrcadStartup: async () => { + order.push('profile-admission') + }, + runOrcadProfilePreflight: profileProbe +})) + +beforeEach(() => { + vi.resetModules() + order.length = 0 +}) +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) vi.mock('./orcad-native-preflight', () => ({ runOrcadNativePreflight: () => { @@ -21,10 +45,23 @@ vi.mock('./orcad-entry', () => ({ })) describe('orcad entry', () => { + it.each([ + { flag: ORCAD_PROFILE_PREFLIGHT_FLAG, nativeFeatures: true }, + { flag: ORCAD_STARTUP_PREFLIGHT_FLAG, nativeFeatures: false } + ])( + 'runs the selected disposable probe without starting a server: $flag', + async ({ flag, nativeFeatures }) => { + vi.spyOn(process, 'argv', 'get').mockReturnValue(['runtime', 'orcad.js', flag, 'nonce']) + await import('./main') + expect(profileProbe).toHaveBeenCalledExactlyOnceWith('nonce', { nativeFeatures }) + expect(order).toEqual([]) + } + ) + it('runs the native preflight before starting the runtime', async () => { await import('./main') await vi.waitFor(() => expect(order).toContain('main')) - expect(order).toEqual(['preflight', 'main']) + expect(order).toEqual(['profile-admission', 'preflight', 'main']) }) }) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 73a973c7b24..79fc667fd22 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -2,6 +2,12 @@ import process from 'node:process' import { main, resolveOrcadExitCode } from './orcad-entry' import { runOrcadNativePreflight } from './orcad-native-preflight' +import { + ORCAD_PROFILE_PREFLIGHT_FLAG, + ORCAD_STARTUP_PREFLIGHT_FLAG +} from '../../shared/orcad-profile-preflight' +import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' +import { handoffToBundledOrcad } from './orcad-bundled-runtime' // Why exit before the preflight: reaching this line means the whole module graph resolved // under plain Node, which is all the build guard needs to prove. Probing natives or @@ -16,12 +22,33 @@ if (process.argv.includes('--orcad-smoke-load-check')) { // evaluated before this statement, so the guarantee is that no module in the graph // requires node-pty at import time — which the bundle's lazy `require("node-pty")` in // local-pty-provider satisfies. See ./node-pty-precondition.ts for why a child process. -runOrcadNativePreflight() - -main().catch((error: unknown) => { +function failStartup(error: unknown): void { console.error('orcad: failed to start:', error) // Why a resolved code and not a bare 1: a data-root or bind-address refusal is a // configuration fault that restarting cannot fix, and a supervisor needs to tell the two // apart to avoid restart-spinning on it. process.exit(resolveOrcadExitCode(error)) -}) +} + +try { + if (!handoffToBundledOrcad()) { + const flag = process.argv[2] + if ( + (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && + process.argv.length === 4 + ) { + void runOrcadProfilePreflight(process.argv[3], { + nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG + }).catch(failStartup) + } else { + void preflightBundledOrcadStartup() + .then(() => { + runOrcadNativePreflight() + return main() + }) + .catch(failStartup) + } + } +} catch (error) { + failStartup(error) +} diff --git a/src/main/orcad/orcad-artifact-identity.ts b/src/main/orcad/orcad-artifact-identity.ts new file mode 100644 index 00000000000..0049ef10ffa --- /dev/null +++ b/src/main/orcad/orcad-artifact-identity.ts @@ -0,0 +1,41 @@ +import { createHash } from 'node:crypto' +import { createReadStream, existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_VERSION, + orcadArtifactFilenames, + orcadArtifactHashPrefix +} from '../../shared/orcad-artifacts' +import { ORCAD_BUN_TARGETS } from '../../shared/orcad-bun-runtime' +import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name' + +/** Hash installed bytes in the build's order; a version marker is not proof of delivery. */ +export async function readOrcadArtifactIdentity(directory: string): Promise { + const target = z + .enum(ORCAD_BUN_TARGETS) + .parse((await readFile(join(directory, ORCAD_BUILD_TARGET_FILENAME), 'utf8')).trim()) + const platform = target.startsWith('win32-') + ? 'win32' + : target.startsWith('darwin-') + ? 'darwin' + : 'linux' + const browser = orcadAgentBrowserNativeName( + platform, + target.split('-')[1] ?? '', + target.endsWith('-musl') ? 'musl' : 'glibc' + ) + const filenames = orcadArtifactFilenames(target) + if (existsSync(join(directory, browser))) { + filenames.push(browser) + } + const hash = createHash('sha256').update(orcadArtifactHashPrefix(target)) + for (const filename of filenames) { + for await (const chunk of createReadStream(join(directory, filename))) { + hash.update(chunk) + } + } + return `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}` +} diff --git a/src/main/orcad/orcad-artifact-preflight.test.ts b/src/main/orcad/orcad-artifact-preflight.test.ts new file mode 100644 index 00000000000..f48a1436a92 --- /dev/null +++ b/src/main/orcad/orcad-artifact-preflight.test.ts @@ -0,0 +1,66 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { orcadArtifactFilenames } from '../../shared/orcad-artifacts' +import { runOrcadProfilePreflight } from './orcad-profile-preflight' +import { readOrcadArtifactIdentity } from './orcad-artifact-identity' +import { resolveOrcadExitCode } from './orcad-exit-code' + +const fixture = vi.hoisted(() => ({ + directory: '', + sqlite: vi.fn(async () => ({ sqliteVersion: '3.53.2', revision: 1 })) +})) +vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => fixture.directory })) +vi.mock('../persistence/profile-state/profile-state-runtime-preflight', () => ({ + preflightProfileStateRuntime: fixture.sqlite +})) +vi.mock('./orcad-bun-native-preflight', () => ({ + preflightOrcadBunNativeRuntime: vi.fn(async () => {}) +})) + +beforeEach(async () => { + fixture.directory = await mkdtemp(join(tmpdir(), 'orcad-artifact-preflight-')) + for (const filename of orcadArtifactFilenames('linux-x64-glibc')) { + const path = join(fixture.directory, filename) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, filename === '.build-target' ? 'linux-x64-glibc\n' : filename) + } + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) +afterEach(async () => { + vi.restoreAllMocks() + vi.clearAllMocks() + await rm(fixture.directory, { recursive: true, force: true }) +}) + +describe('installed artifact admission', () => { + it('qualifies build output before its version marker is published', async () => { + await runOrcadProfilePreflight(randomUUID()) + expect(fixture.sqlite).toHaveBeenCalledOnce() + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(await readOrcadArtifactIdentity(fixture.directory)) + ) + }) + + it.each(['.build-target', 'node_modules/@parcel/watcher/watcher.node', 'bun-runtime'])( + 'refuses a missing %s as configuration before any profile probe', + async (filename) => { + await rm(join(fixture.directory, filename)) + const error = await runOrcadProfilePreflight(randomUUID()).catch( + (failure: unknown) => failure + ) + expect(resolveOrcadExitCode(error)).toBe(78) + expect(fixture.sqlite).not.toHaveBeenCalled() + expect(console.log).not.toHaveBeenCalled() + } + ) + + it('refuses a malformed target even though all named files exist', async () => { + await writeFile(join(fixture.directory, '.build-target'), 'not-a-runtime-target') + const error = await runOrcadProfilePreflight(randomUUID()).catch((failure: unknown) => failure) + expect(resolveOrcadExitCode(error)).toBe(78) + expect(fixture.sqlite).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-bun-launcher.integration.test.ts b/src/main/orcad/orcad-bun-launcher.integration.test.ts new file mode 100644 index 00000000000..85754cfef5d --- /dev/null +++ b/src/main/orcad/orcad-bun-launcher.integration.test.ts @@ -0,0 +1,221 @@ +import { build } from 'esbuild' +import { existsSync } from 'node:fs' +import { copyFile, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' + +const runtimePath = + process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform)) +const nodePath = + process.env.ORCA_TEST_NODE_EXECUTABLE ?? (process.versions.bun ? 'node' : process.execPath) +let directory = '' +const children = new Set>() +const runtimes = new Set() + +describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-bun-launcher-')) + await copyFile(runtimePath, join(directory, orcadBunRuntimeFilename(process.platform))) + await writeFile(join(directory, '.build-target'), `${process.platform}-${process.arch}\n`) + await build({ + stdin: { + contents: ` + import {handoffToBundledOrcad, OrcadBundledRuntimeError} from './src/main/orcad/orcad-bundled-runtime' + import {installOrcadShutdownSignals} from './src/main/orcad/orcad-lifecycle' + import {resolveOrcadExitCode} from './src/main/orcad/orcad-exit-code' + import {writeFile} from 'node:fs/promises' + if (!process.versions.bun) { + if (!handoffToBundledOrcad()) throw new Error('Missing bundled runtime') + process.on('message', signal => process.emit(signal)) + } else { + console.log('booting:' + process.pid) + console.log('runtime:' + process.versions.bun) + console.log('channel-env:' + (process.env.ORCA_BUNDLED_LAUNCHER_CHANNEL ?? 'absent')) + process.on('exit', code => console.log('runtime-exit:' + code)) + const keepalive = setInterval(() => {}, 1_000) + const install = async () => { + if (process.env.ORCA_TEST_FAIL_STARTUP === '1') { + const startup = new Promise((_, reject) => setTimeout(() => + reject(new OrcadBundledRuntimeError('startup configuration failed')), 100)) + installOrcadShutdownSignals(async () => (await startup).stop()) + await startup + return + } + installOrcadShutdownSignals(async () => { + console.log('flushing') + clearInterval(keepalive) + if (process.env.ORCA_TEST_STALL === '1') await new Promise(() => {}) + await new Promise(resolve => setTimeout(resolve, 150)) + await writeFile(process.env.ORCA_TEST_DONE, 'flushed') + }, process.env.ORCA_TEST_STALL === '1' ? 100 : undefined) + console.log('ready') + } + // Exercise the shutdown observer before the outer startup-failure reporter. + const start = () => Promise.resolve().then(install) + .catch(error => setImmediate(() => process.exit(resolveOrcadExitCode(error)))) + if (process.env.ORCA_TEST_DELAY_INSTALL === '1') setTimeout(start, 300) + else start() + } + `, + resolveDir: process.cwd(), + loader: 'ts' + }, + outfile: join(directory, 'orcad.js'), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs' + }) + }) + + afterEach(() => { + for (const child of children) { + child.kill('SIGKILL') + } + children.clear() + for (const pid of runtimes) { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + runtimes.clear() + removeTreeSync(directory) + }) + + function launch( + options: { + direct?: boolean + nohup?: boolean + delay?: boolean + stall?: boolean + failStartup?: boolean + } = {} + ) { + const runtime = options.direct + ? join(directory, orcadBunRuntimeFilename(process.platform)) + : nodePath + const child = spawnProcess({ + program: options.nohup ? 'nohup' : runtime, + args: [...(options.nohup ? [runtime] : []), join(directory, 'orcad.js')], + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_TEST_DONE: join(directory, 'done'), + ORCA_TEST_DELAY_INSTALL: options.delay ? '1' : '0', + ORCA_TEST_STALL: options.stall ? '1' : '0', + ORCA_TEST_FAIL_STARTUP: options.failStartup ? '1' : '0' + }, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }) + children.add(child) + let closed = false + child.once('close', () => { + closed = true + }) + let output = '' + const capture = (chunk: Buffer): void => { + output += chunk.toString() + const pid = /booting:(\d+)/.exec(output)?.[1] + if (pid && !output.includes('runtime-exit:')) { + runtimes.add(Number(pid)) + } else if (pid) { + runtimes.delete(Number(pid)) + } + } + child.stdout.on('data', capture) + child.stderr.on('data', capture) + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => { + children.delete(child) + resolve({ code, signal }) + }) + } + ) + return { child, output: () => output, exit, isClosed: () => closed } + } + + it.each([false, true])( + 'drains Bun after its launcher is killed (startup pending: %s)', + async (delay) => { + const h = launch({ delay }) + await vi.waitFor(() => expect(h.output()).toContain(delay ? 'booting:' : 'ready'), { + timeout: 5_000 + }) + expect(h.output()).toContain(`runtime:${ORCAD_BUN_VERSION}`) + expect(h.output()).toContain('channel-env:absent') + h.child.kill('SIGKILL') + await h.exit + await vi.waitFor( + async () => expect(await readFile(join(directory, 'done'), 'utf8')).toBe('flushed'), + { timeout: 5_000 } + ) + expect(h.output().match(/flushing/g)).toHaveLength(1) + await vi.waitFor(() => expect(h.output()).toContain('runtime-exit:0')) + await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) + } + ) + + it.skipIf(process.platform === 'win32').each([false, true])( + 'survives nohup hangups and drains on TERM (direct Bun: %s)', + async (direct) => { + const h = launch({ direct, nohup: true }) + await vi.waitFor(() => expect(h.output()).toContain('ready'), { timeout: 5_000 }) + if (!h.child.pid) { + throw new Error('Missing launcher pid') + } + process.kill(-h.child.pid, 'SIGHUP') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(h.child.exitCode).toBeNull() + expect(h.child.signalCode).toBeNull() + expect(h.output()).not.toContain('flushing') + h.child.kill('SIGTERM') + expect(await h.exit).toEqual({ code: 0, signal: null }) + expect(await readFile(join(directory, 'done'), 'utf8')).toBe('flushed') + await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) + } + ) + + it('keeps an unfinished shutdown alive until its failure deadline', async () => { + const h = launch({ stall: true }) + await vi.waitFor(() => expect(h.output()).toContain('ready'), { timeout: 5_000 }) + h.child.kill('SIGKILL') + await h.exit + await vi.waitFor(() => expect(h.output()).toContain('runtime-exit:1')) + expect(h.output()).toContain('exceeded 100ms') + await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) + }) + + it.each(process.platform === 'win32' ? [false, true] : [false])( + 'forwards launcher stop requests and drains once (startup pending: %s)', + async (delay) => { + const h = launch({ delay }) + await vi.waitFor(() => expect(h.output()).toContain(delay ? 'booting:' : 'ready'), { + timeout: 5_000 + }) + h.child.send('SIGINT') + h.child.send('SIGTERM') + expect(await h.exit).toEqual({ code: 0, signal: null }) + expect(await readFile(join(directory, 'done'), 'utf8')).toBe('flushed') + expect(h.output().match(/flushing/g)).toHaveLength(1) + await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) + } + ) + + it('preserves a startup configuration verdict after early launcher loss', async () => { + const h = launch({ delay: true, failStartup: true }) + await vi.waitFor(() => expect(h.output()).toContain('booting:'), { timeout: 5_000 }) + h.child.kill('SIGKILL') + await h.exit + await vi.waitFor(() => expect(h.output()).toContain('runtime-exit:78'), { timeout: 5_000 }) + expect(h.output()).toContain('shutdown after launcher disconnect failed') + await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) + }) +}) diff --git a/src/main/orcad/orcad-bun-native-preflight.test.ts b/src/main/orcad/orcad-bun-native-preflight.test.ts new file mode 100644 index 00000000000..3e6df8402c1 --- /dev/null +++ b/src/main/orcad/orcad-bun-native-preflight.test.ts @@ -0,0 +1,165 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WatcherProcessCallback, + WatcherProcessHooks +} from '../ipc/parcel-watcher-process-subscription' +import { preflightOrcadBunNativeRuntime } from './orcad-bun-native-preflight' + +const fixture = vi.hoisted(() => ({ + temp: vi.fn(), + pty: vi.fn(), + available: vi.fn(), + startTime: vi.fn(), + rows: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + remove: vi.fn() +})) +vi.mock('../daemon/pty-subprocess/spawn-preflight', () => ({ runPtySpawnHealthProbe: fixture.pty })) +vi.mock('../windows/windows-process-table', () => ({ + isWindowsProcessTableAvailable: fixture.available, + isWindowsProcessStartTimeAvailable: fixture.startTime, + readWindowsProcessIdentityTableFresh: fixture.rows +})) +vi.mock('node:fs/promises', () => ({ + mkdtemp: fixture.temp, + writeFile: fixture.write, + rm: fixture.remove +})) +vi.mock('../ipc/parcel-watcher-process-supervisor', () => ({ + WatcherProcessSupervisor: class { + subscribe = fixture.subscribe + dispose = fixture.dispose + } +})) + +beforeEach(() => { + vi.useFakeTimers() + fixture.temp.mockResolvedValue('/temp/probe') + fixture.pty.mockResolvedValue(undefined) + fixture.available.mockReturnValue(true) + fixture.startTime.mockReturnValue(true) + fixture.rows.mockResolvedValue([{ pid: process.pid, creationTimeMs: Date.now() - 1_000 }]) + fixture.unsubscribe.mockResolvedValue(undefined) + fixture.remove.mockResolvedValue(undefined) + fixture.subscribe.mockImplementation( + async (directory: string, callback: WatcherProcessCallback) => { + fixture.write.mockImplementation(async () => + callback(null, [{ path: join(directory, 'ready'), type: 'create' }]) + ) + return { unsubscribe: fixture.unsubscribe } + } + ) +}) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.resetAllMocks() +}) + +describe('bundled native readiness', () => { + it('keeps runtime startup independent of PTY or watcher probe availability', async () => { + fixture.pty.mockRejectedValue(new Error('PTY spawn health check timed out')) + fixture.subscribe.mockRejectedValue(new Error('ENOSPC: watch limit reached')) + await preflightOrcadBunNativeRuntime({ nativeFeatures: false }) + expect(fixture.pty).not.toHaveBeenCalled() + expect(fixture.subscribe).not.toHaveBeenCalled() + }) + + it('still requires Windows ownership support on normal startup', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + fixture.startTime.mockReturnValue(false) + await expect(preflightOrcadBunNativeRuntime({ nativeFeatures: false })).rejects.toThrow( + 'Windows process table' + ) + }) + + it('does not admit a failed PTY in explicit qualification', async () => { + fixture.pty.mockRejectedValue(new Error('PTY spawn health check timed out')) + await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow( + 'PTY spawn health check timed out' + ) + }) + + it('awaits actual watcher delivery and unsubscribe before disposing temporary state', async () => { + await preflightOrcadBunNativeRuntime() + expect(fixture.pty).toHaveBeenCalledOnce() + expect(fixture.unsubscribe).toHaveBeenCalledOnce() + expect(fixture.dispose).toHaveBeenCalledOnce() + expect(fixture.remove).toHaveBeenCalledWith('/temp/probe', { recursive: true, force: true }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('cancels a subscribe blocked on capacity by the same readiness deadline', async () => { + fixture.subscribe.mockImplementation( + ( + _directory: string, + _callback: WatcherProcessCallback, + _options: unknown, + hooks: WatcherProcessHooks + ) => + new Promise((_resolve, reject) => { + hooks.signal?.addEventListener('abort', () => reject(hooks.signal?.reason), { + once: true + }) + }) + ) + const readiness = preflightOrcadBunNativeRuntime() + const rejected = expect(readiness).rejects.toThrow('readiness timed out') + await vi.advanceTimersByTimeAsync(5_000) + await rejected + expect(fixture.dispose).toHaveBeenCalledOnce() + expect(fixture.remove).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('cleans up when native delivery fails before subscribe resolves', async () => { + fixture.subscribe.mockImplementation( + async (_directory: string, callback: WatcherProcessCallback) => { + callback(new Error('native watcher failed'), []) + return { unsubscribe: fixture.unsubscribe } + } + ) + await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('native watcher failed') + expect(fixture.unsubscribe).toHaveBeenCalledOnce() + expect(fixture.dispose).toHaveBeenCalledOnce() + }) + + it('still disposes temporary state when unsubscribe fails', async () => { + fixture.unsubscribe.mockRejectedValue(new Error('watcher did not exit')) + await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('watcher did not exit') + expect(fixture.dispose).toHaveBeenCalledOnce() + expect(fixture.remove).toHaveBeenCalledOnce() + }) + + it.each(['missing-addon', 'missing-creation-time', 'invalid-self-row'])( + 'refuses %s on Windows before spawning a PTY or allowing CIM fallback', + async (reason) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + if (reason === 'missing-addon') { + fixture.available.mockReturnValue(false) + } + if (reason === 'missing-creation-time') { + fixture.startTime.mockReturnValue(false) + } + if (reason === 'invalid-self-row') { + fixture.rows.mockResolvedValue([{ pid: process.pid }]) + } + await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('Windows process table') + expect(fixture.pty).not.toHaveBeenCalled() + if (reason !== 'invalid-self-row') { + expect(fixture.rows).not.toHaveBeenCalled() + } + } + ) + + it('reads a fresh self identity on Windows before qualifying the PTY', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + await preflightOrcadBunNativeRuntime() + expect(fixture.rows).toHaveBeenCalledOnce() + expect(fixture.pty).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/orcad/orcad-bun-native-preflight.ts b/src/main/orcad/orcad-bun-native-preflight.ts new file mode 100644 index 00000000000..163f6ce2987 --- /dev/null +++ b/src/main/orcad/orcad-bun-native-preflight.ts @@ -0,0 +1,83 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runPtySpawnHealthProbe } from '../daemon/pty-subprocess/spawn-preflight' +import { WatcherProcessSupervisor } from '../ipc/parcel-watcher-process-supervisor' +import { resolveWatcherProcessEntryPath } from '../ipc/parcel-watcher-entry-path' +import { resolveOrcadInstallRoot } from './orcad-app-paths' +import { + isWindowsProcessTableAvailable, + isWindowsProcessStartTimeAvailable, + readWindowsProcessIdentityTableFresh +} from '../windows/windows-process-table' + +/** The candidate process owns disposable PTY and watcher probes before it touches user state. */ +export async function preflightOrcadBunNativeRuntime( + options: { nativeFeatures?: boolean } = {} +): Promise { + if (process.platform === 'win32') { + await preflightWindowsProcessIdentity() + } + // Runtime health checks can degrade independently; artifact qualification remains strict. + if (options.nativeFeatures === false) { + return + } + await runPtySpawnHealthProbe() + const directory = await mkdtemp(join(tmpdir(), 'orca-native-ready-')) + const supervisor = new WatcherProcessSupervisor({ + entryPath: resolveWatcherProcessEntryPath(resolveOrcadInstallRoot(), false), + useInProcessVitestFallback: false + }) + const cancellation = new AbortController() + let subscription: { unsubscribe(): Promise } | undefined + let timer: ReturnType | undefined + try { + let resolveDelivery: () => void = () => {} + let rejectDelivery: (error: unknown) => void = () => {} + const delivered = new Promise((resolve, reject) => { + resolveDelivery = resolve + rejectDelivery = reject + }) + // A native callback can fail while subscribe is pending. + void delivered.catch(() => {}) + timer = setTimeout(() => { + const error = new Error('Bun file watcher readiness timed out') + cancellation.abort(error) + rejectDelivery(error) + }, 5_000) + subscription = await supervisor.subscribe( + directory, + (error, events) => { + if (error) { + rejectDelivery(error) + } else if (events.some((event) => event.path === join(directory, 'ready'))) { + resolveDelivery() + } + }, + process.platform === 'win32' ? { backend: 'windows' } : {}, + { signal: cancellation.signal, subscribeTimeoutMs: 5_000, onTerminalError: rejectDelivery } + ) + await writeFile(join(directory, 'ready'), '') + await delivered + } finally { + clearTimeout(timer) + try { + await subscription?.unsubscribe() + } finally { + supervisor.dispose() + await rm(directory, { recursive: true, force: true }) + } + } +} + +async function preflightWindowsProcessIdentity(): Promise { + if (!isWindowsProcessTableAvailable() || !isWindowsProcessStartTimeAvailable()) { + throw new Error('The bundled Windows process table must support process creation times') + } + const rows = await readWindowsProcessIdentityTableFresh() + const self = rows.find((row) => row.pid === process.pid) + const created = self?.creationTimeMs + if (created === undefined || !Number.isFinite(created) || created <= 0 || created > Date.now()) { + throw new Error('The bundled Windows process table could not identify this process') + } +} diff --git a/src/main/orcad/orcad-bundle-native-load-order.test.ts b/src/main/orcad/orcad-bundle-native-load-order.test.ts index 1c26501b6a6..5ed9085fa36 100644 --- a/src/main/orcad/orcad-bundle-native-load-order.test.ts +++ b/src/main/orcad/orcad-bundle-native-load-order.test.ts @@ -1,91 +1,70 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { pathToFileURL } from 'node:url' +import { afterEach, expect, it } from 'vitest' import { runProcessSync } from '../../shared/child-process/run-process' -/** - * The preflight only prevents a loader crash if nothing in the bundle's import graph has - * already required node-pty by the time it runs. esbuild wraps `local-pty-provider` in a - * lazy initializer because `orcad-entry` reaches it through `await import('../ipc/pty')`, - * and that laziness is load-bearing rather than incidental — a single top-level static - * import anywhere in the graph would hoist `require("node-pty")` above every statement in - * `main.ts`, including the preflight. - * - * Why this builds the bundle instead of skipping without one: no CI job builds orcad and - * runs vitest. `smoke:orcad-terminal` builds it in the static-analysis job, which never - * runs vitest; the `orcad_browser` job runs vitest but deliberately does not build orcad. - * A `runIf(existsSync(...))` guard therefore skips in every shard, forever — the same way - * an unset ORCA_BROWSER_EXECUTABLE kept the browser provider uncovered. - * - * Why not fail-when-CI instead: the wiring that would satisfy it lives in `.github/`, so - * that turns a silent gap into a red build someone else has to fix. Building costs well - * under a second (esbuild), works in every shard and on every machine, and needs no job - * to cooperate. What it must never do is skip. - */ const REPO_ROOT = join(__dirname, '..', '..', '..') -const BUNDLE = join(REPO_ROOT, 'out', 'orcad', 'orcad.js') -const BUILD_SCRIPT = join(REPO_ROOT, 'config', 'scripts', 'build-orcad.mjs') +const directories: string[] = [] -/** - * Why it throws rather than skipping when the build fails: a bundle that cannot be built - * is a louder problem than the one this test checks, and swallowing it here is exactly - * how the assertion would go missing. - */ -function ensureOrcadBundle(): void { - if (existsSync(BUNDLE)) { - return +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) } - const build = runProcessSync({ - program: process.execPath, - args: [BUILD_SCRIPT], - cwd: REPO_ROOT, - timeoutMs: 300_000 - }) - if (!existsSync(BUNDLE)) { - const output = `${build.stdout}${build.stderr}`.slice(0, 4000) - throw new Error( - `could not build ${BUNDLE} (exit ${build.code}); the load-order assertion cannot run:\n${output}` - ) - } -} - -describe('orcad bundle native load order', () => { - const dirs: string[] = [] - afterEach(() => { - for (const dir of dirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it('does not require node-pty in-process before the entry rejects its argv', () => { - ensureOrcadBundle() - const dir = mkdtempSync(join(tmpdir(), 'orcad-load-order-')) - dirs.push(dir) - const harness = join(dir, 'harness.cjs') - writeFileSync( - harness, - [ - "const Module = require('module')", - 'const original = Module._load', - 'Module._load = function (request, ...rest) {', - " if (request === 'node-pty') { console.log('IN_PROCESS_NODE_PTY_REQUIRE') }", - ' return original.call(this, request, ...rest)', - '}', - "process.argv.push('--orcad-load-order-check')", - `require(${JSON.stringify(BUNDLE)})` - ].join('\n') - ) - - const result = runProcessSync({ - program: process.execPath, - args: [harness], - timeoutMs: 120_000 - }) - const output = `${result.stdout}${result.stderr}` - - // Proof the graph fully loaded and reached argv parsing rather than dying early. - expect(output).toContain('Unknown argument: --orcad-load-order-check') - expect(output).not.toContain('IN_PROCESS_NODE_PTY_REQUIRE') - }, 360_000) }) + +it('loads a fresh production import graph before requiring native PTY code', () => { + const directory = mkdtempSync(join(tmpdir(), 'orcad-load-order-')) + directories.push(directory) + const bundle = join(directory, 'orcad.js') + const builder = pathToFileURL(join(REPO_ROOT, 'config/scripts/orcad-entry-build.mjs')).href + const built = runProcessSync({ + program: process.execPath, + args: [ + '--input-type=module', + '-e', + `import { buildOrcadEntry } from ${JSON.stringify(builder)}; await buildOrcadEntry(${JSON.stringify(bundle)})` + ], + cwd: REPO_ROOT, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 60_000 + }) + expect(built.code, built.stderr.slice(0, 2_000)).toBe(0) + expect(existsSync(bundle)).toBe(true) + + const marker = join(directory, 'premature-native-load') + const preload = join(directory, 'preload.cjs') + writeFileSync( + preload, + [ + "const Module = require('node:module')", + 'const original = Module._load', + 'Module._load = function (request, ...rest) {', + " if (request === 'node-pty') {", + ` require('node:fs').writeFileSync(${JSON.stringify(marker)}, request)`, + " throw new Error('native PTY required before preflight')", + ' }', + ' return original.call(this, request, ...rest)', + '}' + ].join('\n') + ) + const run = (extraArgs: string[] = []) => + runProcessSync({ + program: process.execPath, + // The production load-check exits after module evaluation, before runtime handoff or probes. + args: ['--require', preload, ...extraArgs, bundle, '--orcad-smoke-load-check'], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000 + }) + + const loaded = run() + expect(loaded.code, loaded.stderr.slice(0, 2_000)).toBe(0) + expect(existsSync(marker)).toBe(false) + + // Prove the interception works without relying on minified source echoed in an error. + const eagerNative = join(directory, 'eager-native.cjs') + writeFileSync(eagerNative, "require('node-pty')") + expect(run(['--require', eagerNative]).code).not.toBe(0) + expect(existsSync(marker)).toBe(true) +}, 90_000) diff --git a/src/main/orcad/orcad-bundled-runtime.integration.test.ts b/src/main/orcad/orcad-bundled-runtime.integration.test.ts new file mode 100644 index 00000000000..9db0d5000c9 --- /dev/null +++ b/src/main/orcad/orcad-bundled-runtime.integration.test.ts @@ -0,0 +1,221 @@ +import { build } from 'esbuild' +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { shellEscape } from '../ssh/ssh-connection-utils' + +let directory = '' +const children = new Set>() + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-handoff-')) + await build({ + stdin: { + contents: ` + import { handoffToBundledOrcad, OrcadBundledRuntimeError } from './src/main/orcad/orcad-bundled-runtime' + import { installOrcadShutdownSignals, flushOrcadProfileStoreForShutdown } from './src/main/orcad/orcad-lifecycle' + import { writeFile } from 'node:fs/promises' + if (process.env.ORCA_TEST_HANDOFF_CHILD === '1') { + if (process.env.ORCA_TEST_HANDOFF_DURABLE === '1') { + installOrcadShutdownSignals(() => flushOrcadProfileStoreForShutdown({ + flushFinalOrThrowAsync: async () => { + console.log('flushing') + await new Promise(resolve => setTimeout(resolve, 250)) + await writeFile(process.env.ORCA_TEST_SHUTDOWN_FILE, 'flushed') + }, + freezeWritesAsync: async () => console.log('closed') + })) + } + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + console.log('received:' + signal) + if (process.env.ORCA_TEST_HANDOFF_DURABLE !== '1') process.exit(29) + }) + } + console.log('ready:' + JSON.stringify(process.argv.slice(2))) + console.log('child-pid:' + process.pid) + setTimeout(() => process.exit(99), 4_000) + } else { + try { + if (!handoffToBundledOrcad()) throw new Error('handoff failed') + } catch (error) { + console.error(error.message) + process.exit(error instanceof OrcadBundledRuntimeError ? 78 : 1) + } + } + `, + resolveDir: process.cwd(), + loader: 'ts' + }, + outfile: join(directory, 'orcad.js'), + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs' + }) + await writeFile(join(directory, '.build-target'), 'darwin-arm64\n') + const runtime = join(directory, 'bun-runtime') + await writeFile( + runtime, + `#!/bin/sh\nORCA_TEST_HANDOFF_CHILD=1 exec ${shellEscape(process.execPath)} "$@"\n` + ) + await chmod(runtime, 0o700) +}) + +afterEach(async () => { + for (const child of children) { + child.kill('SIGKILL') + } + children.clear() + await rm(directory, { recursive: true, force: true }) +}) + +function launch( + args: string[], + env: NodeJS.ProcessEnv = {}, + options: { entry?: string; nohup?: boolean } = {} +) { + const child = spawnProcess({ + program: options.nohup ? 'nohup' : process.execPath, + args: [ + ...(options.nohup ? [process.execPath] : []), + options.entry ?? join(directory, 'orcad.js'), + ...args + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true + }) + children.add(child) + let output = '' + child.stdout.on('data', (data: Buffer) => { + output += data.toString() + }) + child.stderr.on('data', (data: Buffer) => { + output += data.toString() + }) + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => { + children.delete(child) + resolve({ code, signal }) + }) + } + ) + return { child, output: () => output, exit } +} + +describe.skipIf(process.platform === 'win32')('bundled handoff process lifecycle', () => { + it('refuses a partial installation before launching its adjacent runtime', async () => { + await rm(join(directory, '.build-target')) + const result = await runProcess({ + program: process.execPath, + args: [join(directory, 'orcad.js')], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 5_000 + }) + expect(result.code).toBe(78) + expect(result.stderr).toContain('bundled Orca runtime target is missing') + expect(result.stdout).not.toContain('ready:') + }) + + it.each(['SIGINT', 'SIGTERM'] as const)( + 'forwards %s to the actual child and mirrors its exit', + async (signal) => { + const args = ['--label', 'two words', 'quote"$literal'] + const { child, output, exit } = launch(args) + await vi.waitFor(() => expect(output()).toContain(`ready:${JSON.stringify(args)}`), { + timeout: 2_000 + }) + child.kill(signal) + expect(await exit).toEqual({ code: 29, signal: null }) + expect(output()).toContain(`received:${signal}`) + } + ) + + it.each( + (['SIGINT', 'SIGTERM'] as const).flatMap((signal) => + (['process group', 'separate service deliveries'] as const).map((delivery) => ({ + signal, + delivery + })) + ) + )( + 'finishes a pending durable flush after duplicate $signal from $delivery', + async ({ signal, delivery }) => { + const shutdownFile = join(directory, 'shutdown-complete') + const { child, output, exit } = launch([], { + ORCA_TEST_HANDOFF_DURABLE: '1', + ORCA_TEST_SHUTDOWN_FILE: shutdownFile + }) + await vi.waitFor(() => expect(output()).toContain('child-pid:'), { timeout: 2_000 }) + const runtimePid = Number(output().match(/child-pid:(\d+)/)?.[1]) + expect(runtimePid).toBeGreaterThan(0) + if (!child.pid) { + throw new Error('Launcher has no process ID') + } + if (delivery === 'process group') { + process.kill(-child.pid, signal) + } else { + process.kill(runtimePid, signal) + await vi.waitFor(() => expect(output()).toContain('flushing')) + child.kill(signal) + } + expect(await exit).toEqual({ code: 0, signal: null }) + expect(await readFile(shutdownFile, 'utf8')).toBe('flushed') + expect(output().match(/flushing/g)).toHaveLength(1) + expect(output()).toContain('closed') + if (delivery === 'separate service deliveries') { + expect(output().match(new RegExp(`received:${signal}`, 'g'))).toHaveLength(2) + } + } + ) + + it('hands off a symlinked entry to its adjacent runtime', async () => { + const aliases = join(directory, 'aliases') + await mkdir(aliases) + const entry = join(aliases, 'orcad.js') + await symlink(join(directory, 'orcad.js'), entry) + const { child, output, exit } = launch([], {}, { entry }) + await vi.waitFor(() => expect(output()).toContain('child-pid:'), { timeout: 2_000 }) + child.kill('SIGTERM') + expect(await exit).toEqual({ code: 29, signal: null }) + }) + + it('drains the child after its launcher is force-killed', async () => { + const shutdownFile = join(directory, 'shutdown-complete') + const { child, output, exit } = launch([], { + ORCA_TEST_HANDOFF_DURABLE: '1', + ORCA_TEST_SHUTDOWN_FILE: shutdownFile + }) + await vi.waitFor(() => expect(output()).toContain('child-pid:'), { timeout: 2_000 }) + child.kill('SIGKILL') + expect(await exit).toEqual({ code: null, signal: 'SIGKILL' }) + await vi.waitFor(async () => expect(await readFile(shutdownFile, 'utf8')).toBe('flushed')) + expect(output().match(/flushing/g)).toHaveLength(1) + }) + + it('preserves nohup across a terminal hangup and still stops gracefully on SIGTERM', async () => { + const shutdownFile = join(directory, 'shutdown-complete') + const { child, output, exit } = launch( + [], + { ORCA_TEST_HANDOFF_DURABLE: '1', ORCA_TEST_SHUTDOWN_FILE: shutdownFile }, + { nohup: true } + ) + await vi.waitFor(() => expect(output()).toContain('child-pid:'), { timeout: 2_000 }) + if (!child.pid) { + throw new Error('Launcher has no process ID') + } + process.kill(-child.pid, 'SIGHUP') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(child.exitCode).toBeNull() + expect(child.signalCode).toBeNull() + expect(output()).not.toContain('flushing') + child.kill('SIGTERM') + expect(await exit).toEqual({ code: 0, signal: null }) + expect(await readFile(shutdownFile, 'utf8')).toBe('flushed') + }) +}) diff --git a/src/main/orcad/orcad-bundled-runtime.test.ts b/src/main/orcad/orcad-bundled-runtime.test.ts new file mode 100644 index 00000000000..5541314057e --- /dev/null +++ b/src/main/orcad/orcad-bundled-runtime.test.ts @@ -0,0 +1,176 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { handoffToBundledOrcad } from './orcad-bundled-runtime' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' + +const fixture = vi.hoisted(() => ({ + exists: vi.fn<(path: string) => boolean>(), + realpath: vi.fn<(path: string) => string>(), + spawn: vi.fn() +})) +vi.mock('node:fs', () => ({ existsSync: fixture.exists, realpathSync: fixture.realpath })) +vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: fixture.spawn })) + +class RuntimeChild extends EventEmitter { + kill = vi.fn() + disconnect = vi.fn() + connected = true +} + +let child: RuntimeChild +const signalNames = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const +let oldListeners: Map> + +beforeEach(() => { + oldListeners = new Map(signalNames.map((signal) => [signal, process.rawListeners(signal)])) + child = new RuntimeChild() + fixture.exists.mockReturnValue(true) + fixture.realpath.mockImplementation((path) => path) + fixture.spawn.mockReturnValue(child) + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('test process exit') + }) + vi.spyOn(process, 'kill').mockReturnValue(true) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(process, 'argv', 'get').mockReturnValue(['/node', '/slot/orcad.js', '--port', '0']) +}) + +afterEach(() => { + for (const signal of signalNames) { + for (const listener of process.rawListeners(signal)) { + if (!oldListeners.get(signal)?.includes(listener)) { + process.off(signal, listener) + } + } + } + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('bundled Orca runtime handoff', () => { + it('leaves nonpackaged entries on their existing runtime', () => { + fixture.exists.mockReturnValue(false) + expect(handoffToBundledOrcad()).toBe(false) + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('refuses an incomplete slot before starting a process', () => { + fixture.exists.mockImplementation((path) => path.endsWith('.build-target')) + expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime is missing') + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('refuses a versioned slot missing both its runtime and target marker', () => { + fixture.exists.mockImplementation((path) => path.endsWith(ORCAD_VERSION_FILENAME)) + expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime target is missing') + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('refuses a remaining bundled runtime without its target marker', () => { + fixture.exists.mockImplementation((path) => !path.endsWith('.build-target')) + expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime target is missing') + expect(fixture.realpath).toHaveBeenCalledExactlyOnceWith('/slot/orcad.js') + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('accepts only the pinned version when already executing the bundled runtime', () => { + fixture.realpath.mockReturnValue('/real/runtime') + vi.spyOn(process, 'versions', 'get').mockReturnValue({ + ...process.versions, + bun: ORCAD_BUN_VERSION + }) + expect(handoffToBundledOrcad()).toBe(false) + expect(fixture.spawn).not.toHaveBeenCalled() + }) + + it('refuses an adjacent runtime that reports the wrong Bun version', () => { + fixture.realpath.mockReturnValue('/real/runtime') + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, bun: '0.0.0' }) + expect(() => handoffToBundledOrcad()).toThrow(`must be Bun ${ORCAD_BUN_VERSION}`) + }) + + it.each(['linux', 'darwin', 'win32'] as const)( + 'hands off arguments and respects %s signal delivery', + (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + expect(handoffToBundledOrcad()).toBe(true) + expect(fixture.spawn).toHaveBeenCalledWith({ + program: expect.stringMatching(/bun-runtime(?:\.exe)?$/), + args: ['/slot/orcad.js', '--port', '0'], + env: expect.objectContaining({ ORCA_BUNDLED_LAUNCHER_CHANNEL: '1' }), + detached: true, + stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + }) + for (const signal of signalNames) { + const listener = process + .rawListeners(signal) + .find((candidate) => !oldListeners.get(signal)?.includes(candidate)) + if (signal === 'SIGHUP' && platform === 'win32') { + expect(listener).toBeUndefined() + continue + } + expect(listener).toBeDefined() + if (listener) { + listener.call(process, signal) + } + if (signal === 'SIGHUP') { + expect(child.kill).not.toHaveBeenCalledWith('SIGHUP') + } else if (platform === 'win32') { + expect(child.kill).not.toHaveBeenCalled() + expect(child.disconnect).toHaveBeenCalled() + } else { + expect(child.kill).toHaveBeenLastCalledWith(signal) + } + } + } + ) + + it('propagates a child exit code and removes every signal listener', () => { + handoffToBundledOrcad() + expect(() => child.emit('exit', 23, null)).toThrow('test process exit') + expect(process.exit).toHaveBeenCalledWith(23) + expect(process.kill).not.toHaveBeenCalled() + for (const signal of signalNames) { + expect(process.rawListeners(signal)).toEqual(oldListeners.get(signal)) + } + }) + + it('locates the runtime beside the resolved entry rather than its symlink', () => { + fixture.realpath.mockImplementation((path) => + path === '/slot/orcad.js' ? '/real/slot/orcad.js' : path + ) + handoffToBundledOrcad() + expect(fixture.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + program: expect.stringMatching(/real\/slot\/bun-runtime(?:\.exe)?$/), + args: ['/real/slot/orcad.js', '--port', '0'] + }) + ) + }) + + it('reports failed spawn as a configuration failure and removes listeners', () => { + handoffToBundledOrcad() + expect(() => child.emit('error', new Error('ENOENT'))).toThrow('test process exit') + expect(process.exit).toHaveBeenCalledWith(78) + for (const signal of signalNames) { + expect(process.rawListeners(signal)).toEqual(oldListeners.get(signal)) + } + }) + + it('mirrors a POSIX signal exit without exiting before the signal is delivered', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + handoffToBundledOrcad() + child.emit('exit', null, 'SIGTERM') + expect(process.kill).toHaveBeenCalledWith(process.pid, 'SIGTERM') + expect(process.exit).not.toHaveBeenCalled() + }) + + it('preserves a signal exit without sending unsupported signals on Windows', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + handoffToBundledOrcad() + expect(() => child.emit('exit', null, 'SIGTERM')).toThrow('test process exit') + expect(process.exit).toHaveBeenCalledWith(143) + expect(process.kill).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-bundled-runtime.ts b/src/main/orcad/orcad-bundled-runtime.ts new file mode 100644 index 00000000000..05b86387124 --- /dev/null +++ b/src/main/orcad/orcad-bundled-runtime.ts @@ -0,0 +1,90 @@ +import { existsSync, realpathSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { constants } from 'node:os' +import { spawnProcess } from '../../shared/child-process/run-process' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_VERSION_FILENAME, + orcadBunRuntimeFilename +} from '../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' + +export class OrcadBundledRuntimeError extends Error {} +export const ORCAD_BUNDLED_LAUNCHER_ENV = 'ORCA_BUNDLED_LAUNCHER_CHANNEL' + +/** Keep old Node service commands usable without letting Node open the profile. */ +export function handoffToBundledOrcad(): boolean { + const script = process.argv[1] + if (!script) { + return false + } + const entry = realpathSync(script) + const directory = dirname(entry) + const runtime = join(directory, orcadBunRuntimeFilename(process.platform)) + const hasTarget = existsSync(join(directory, ORCAD_BUILD_TARGET_FILENAME)) + const hasRuntime = existsSync(runtime) + if (!hasTarget && !hasRuntime && !existsSync(join(directory, ORCAD_VERSION_FILENAME))) { + return false + } + if (!hasTarget) { + throw new OrcadBundledRuntimeError('The bundled Orca runtime target is missing') + } + if (!hasRuntime) { + throw new OrcadBundledRuntimeError('The bundled Orca runtime is missing') + } + if (realpathSync(process.execPath) === realpathSync(runtime)) { + if (process.versions.bun !== ORCAD_BUN_VERSION) { + throw new OrcadBundledRuntimeError( + `The bundled Orca runtime must be Bun ${ORCAD_BUN_VERSION}` + ) + } + return false + } + const child = spawnProcess({ + program: runtime, + args: [entry, ...process.argv.slice(2)], + env: { ...process.env, [ORCAD_BUNDLED_LAUNCHER_ENV]: '1' }, + // Windows' default child job kills Bun before it can drain on launcher disconnect. + detached: true, + stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + }) + // Node resets nohup's disposition; headless runtimes stop through INT/TERM or owner loss. + const ignoreHangup = (): void => {} + if (process.platform !== 'win32') { + process.on('SIGHUP', ignoreHangup) + } + const forwards = (['SIGINT', 'SIGTERM'] as const).map((signal) => { + const forward = (): void => { + if (process.platform === 'win32') { + // Detached Windows children have a separate console; kill() skips durable shutdown. + if (child.connected) { + child.disconnect() + } + } else { + child.kill(signal) + } + } + process.on(signal, forward) + return { signal, forward } + }) + const cleanup = (): void => { + process.off('SIGHUP', ignoreHangup) + for (const { signal, forward } of forwards) { + process.off(signal, forward) + } + } + child.once('error', (error) => { + cleanup() + console.error('orcad: could not start the bundled runtime:', error.message) + process.exit(78) + }) + child.once('exit', (code, signal) => { + cleanup() + if (signal && process.platform !== 'win32') { + process.kill(process.pid, signal) + return + } + process.exit(code ?? (signal ? 128 + constants.signals[signal] : 1)) + }) + return true +} diff --git a/src/main/orcad/orcad-entry.test.ts b/src/main/orcad/orcad-entry.test.ts index 63ec99ca627..5b0fe6fa560 100644 --- a/src/main/orcad/orcad-entry.test.ts +++ b/src/main/orcad/orcad-entry.test.ts @@ -1,7 +1,39 @@ import { describe, expect, it, vi } from 'vitest' -import { flushOrcadProfileStoreForShutdown } from './orcad-lifecycle' +import { + flushOrcadProfileStoreForShutdown, + installOrcadShutdownSignals, + ORCAD_SHUTDOWN_DEADLINE_MS +} from './orcad-lifecycle' describe('orcad profile-state shutdown', () => { + it('keeps one bounded shutdown even when stop signals repeat', () => { + vi.useFakeTimers() + let signal: (() => void) | undefined + vi.spyOn(process, 'on').mockImplementation((event, listener) => { + if (event === 'SIGTERM') { + signal = listener + } + return process + }) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('shutdown deadline') + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const stop = vi.fn(() => new Promise(() => {})) + try { + installOrcadShutdownSignals(stop) + signal?.() + signal?.() + expect(stop).toHaveBeenCalledOnce() + expect(exit).not.toHaveBeenCalled() + expect(() => vi.advanceTimersByTime(ORCAD_SHUTDOWN_DEADLINE_MS)).toThrow('shutdown deadline') + expect(exit).toHaveBeenCalledWith(1) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } + }) + it('flushes durably before closing the profile store', async () => { const events: string[] = [] const store = { diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 70a028419ce..16df8754d76 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -16,15 +16,13 @@ import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environ import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' +import { describeOrcadBindExposure, resolveOrcadBindHost } from './orcad-bind-address' import { - describeOrcadBindExposure, - OrcadBindAddressError, - resolveOrcadBindHost -} from './orcad-bind-address' -import { OrcadInstanceLockError } from './orcad-instance-lock' -import { flushOrcadProfileStoreForShutdown, startOrcadWithHost } from './orcad-lifecycle' + flushOrcadProfileStoreForShutdown, + installOrcadShutdownSignals, + startOrcadWithHost +} from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' -import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' import { changedAiVaultSearchSettings, type AiVaultSearchSettings @@ -369,52 +367,18 @@ async function startOrcadRuntime( * supervision contract has to prevent, so systemd's `RestartPreventExitStatus` needs a code * that means "do not retry" and nothing else does. */ -export const ORCAD_EXIT_OK = 0 -export const ORCAD_EXIT_FAILED = 1 -export const ORCAD_EXIT_CONFIGURATION = 78 +export { + ORCAD_EXIT_OK, + ORCAD_EXIT_FAILED, + ORCAD_EXIT_CONFIGURATION, + resolveOrcadExitCode +} from './orcad-exit-code' /** Bounded so a wedged transport cannot hold a supervisor's stop past its own deadline. */ -export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 - -export function resolveOrcadExitCode(error: unknown): number { - return error instanceof OrcadInstanceLockError || - error instanceof OrcadBindAddressError || - error instanceof ProfileStateAccessError - ? ORCAD_EXIT_CONFIGURATION - : ORCAD_EXIT_FAILED -} +export { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-lifecycle' export async function main(argv: string[] = process.argv.slice(2)): Promise { - const handle = await startOrcad(parseArgs(argv)) - let stopping = false - const shutdown = (signal: NodeJS.Signals): void => { - if (stopping) { - // Why escalate rather than ignore: a supervisor's second signal means the first - // deadline elapsed. Continuing to wait silently is what makes a stop hang until - // SIGKILL, which is the one teardown that skips the daemon handoff entirely. - console.error(`orcad: second ${signal} during shutdown — exiting immediately`) - process.exit(ORCAD_EXIT_FAILED) - } - stopping = true - // Why a self-imposed deadline as well: the supervisor's SIGKILL leaves no exit code and - // no log line. Exiting ourselves keeps the failure attributable. - const deadline = setTimeout(() => { - console.error( - `orcad: shutdown after ${signal} exceeded ${ORCAD_SHUTDOWN_DEADLINE_MS}ms — exiting` - ) - process.exit(ORCAD_EXIT_FAILED) - }, ORCAD_SHUTDOWN_DEADLINE_MS) - deadline.unref() - handle - .stop() - .then(() => process.exit(ORCAD_EXIT_OK)) - // Why not rethrow: we are already tearing down on a signal, and an exit code is - // the only thing a supervisor can act on. - .catch((error) => { - console.error(`orcad: shutdown after ${signal} failed:`, error) - process.exit(ORCAD_EXIT_FAILED) - }) - } - process.on('SIGINT', () => shutdown('SIGINT')) - process.on('SIGTERM', () => shutdown('SIGTERM')) + const startup = startOrcad(parseArgs(argv)) + installOrcadShutdownSignals(async () => (await startup).stop()) + await startup } diff --git a/src/main/orcad/orcad-exit-code.ts b/src/main/orcad/orcad-exit-code.ts new file mode 100644 index 00000000000..8b07dd2f5c9 --- /dev/null +++ b/src/main/orcad/orcad-exit-code.ts @@ -0,0 +1,18 @@ +import { OrcadBindAddressError } from './orcad-bind-address' +import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' +import { OrcadInstanceLockError } from './orcad-instance-lock' +import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' + +export const ORCAD_EXIT_OK = 0 +export const ORCAD_EXIT_FAILED = 1 +export const ORCAD_EXIT_CONFIGURATION = 78 + +/** Configuration faults cannot be repaired by a supervisor restart. */ +export function resolveOrcadExitCode(error: unknown): number { + return error instanceof OrcadInstanceLockError || + error instanceof OrcadBindAddressError || + error instanceof OrcadBundledRuntimeError || + error instanceof ProfileStateAccessError + ? ORCAD_EXIT_CONFIGURATION + : ORCAD_EXIT_FAILED +} diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index 30d805f63fa..26f2f28cbed 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -13,6 +13,7 @@ import { startOrcadWithLifecycle } from './orcad-lifecycle' import { OrcadBindAddressError } from './orcad-bind-address' import { OrcadInstanceLockError } from './orcad-instance-lock' import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' +import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' describe('parseArgs', () => { it('accepts --bind and leaves it unset when absent', () => { @@ -43,6 +44,9 @@ describe('resolveOrcadExitCode', () => { ORCAD_EXIT_CONFIGURATION ) expect(resolveOrcadExitCode(new Error('port in use'))).toBe(ORCAD_EXIT_FAILED) + expect(resolveOrcadExitCode(new OrcadBundledRuntimeError('partial installation'))).toBe( + ORCAD_EXIT_CONFIGURATION + ) expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED) }) }) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index 208b805ec79..bfff3b7f14e 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -1,11 +1,16 @@ import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { acquireOrcadInstanceLock } from './orcad-instance-lock' +import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime' +import { resolveOrcadExitCode } from './orcad-exit-code' import { acquireProfileStateRuntimeAdmission, type ProfileStateRuntimeAdmission } from '../persistence/profile-state/profile-state-access' +const bundledLauncherChannel = process.env[ORCAD_BUNDLED_LAUNCHER_ENV] === '1' +delete process.env[ORCAD_BUNDLED_LAUNCHER_ENV] + function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promise { let completion: Promise | null = null return () => { @@ -14,6 +19,44 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promi } } +export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 + +/** A launcher and its child can both receive the same process-group or service stop signal. */ +export function installOrcadShutdownSignals( + stop: () => Promise, + deadlineMs = ORCAD_SHUTDOWN_DEADLINE_MS +): void { + let stopping = false + const shutdown = (signal: string): void => { + if (stopping) { + return + } + stopping = true + setTimeout(() => { + console.error(`orcad: shutdown after ${signal} exceeded ${deadlineMs}ms — exiting`) + process.exit(1) + }, deadlineMs) + stop() + .then(() => process.exit(0)) + .catch((error) => { + console.error(`orcad: shutdown after ${signal} failed:`, error) + process.exit(resolveOrcadExitCode(error)) + }) + } + process.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) + // Headless runtimes survive terminal hangups; INT/TERM are the graceful stop contract. + if (process.platform !== 'win32') { + process.on('SIGHUP', () => {}) + } + if (bundledLauncherChannel && typeof process.send === 'function') { + process.once('disconnect', () => shutdown('launcher disconnect')) + if (!process.connected) { + shutdown('launcher disconnect') + } + } +} + export async function startOrcadWithLifecycle( start: (registerRuntimeCleanup: (cleanup: () => Promise) => void) => Promise, cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise diff --git a/src/main/orcad/orcad-native-preflight.ts b/src/main/orcad/orcad-native-preflight.ts index 0f3c2cf8970..ee2f323dbe5 100644 --- a/src/main/orcad/orcad-native-preflight.ts +++ b/src/main/orcad/orcad-native-preflight.ts @@ -5,6 +5,7 @@ * testable apart from the detection (what the verdict is). */ import process from 'node:process' +import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' import { setRuntimeTerminalUnavailableCause } from '../runtime/native-terminal-availability' import { terminalUnavailableMessage } from '../../shared/runtime-types' import { @@ -37,6 +38,10 @@ export type NativePreflightHooks = { * sentence printed here. */ export function runOrcadNativePreflight(hooks: NativePreflightHooks = {}): boolean { + if (!hooks.check && canUseBunPty()) { + setRuntimeTerminalUnavailableCause(null) + return true + } const check = hooks.check ?? checkNodePtyPrecondition const warn = hooks.warn ?? ((message: string) => console.warn(message)) const fail = hooks.fail ?? ((message: string) => console.error(message)) diff --git a/src/main/orcad/orcad-profile-preflight.test.ts b/src/main/orcad/orcad-profile-preflight.test.ts new file mode 100644 index 00000000000..9c51e1a349e --- /dev/null +++ b/src/main/orcad/orcad-profile-preflight.test.ts @@ -0,0 +1,197 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' +import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' +import { resolveOrcadExitCode } from './orcad-exit-code' +import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' + +const fixture = vi.hoisted(() => ({ + identity: vi.fn(), + readVersion: vi.fn(), + sql: vi.fn(), + native: vi.fn(), + run: vi.fn<(spec: ProcessSpec) => Promise>() +})) +vi.mock('./orcad-artifact-identity', () => ({ readOrcadArtifactIdentity: fixture.identity })) +vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => '/slot' })) +vi.mock('node:fs/promises', () => ({ readFile: fixture.readVersion })) +vi.mock('../persistence/profile-state/profile-state-runtime-preflight', () => ({ + preflightProfileStateRuntime: fixture.sql +})) +vi.mock('./orcad-bun-native-preflight', () => ({ + preflightOrcadBunNativeRuntime: fixture.native +})) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: fixture.run })) + +const identity = '0.1.0+aaaaaaaaaaaa' +const nonce = '743bf9c8-2e58-4c79-a0ac-52c8d3e8e103' + +function readyResult(challenge: string | undefined): ProcessResult { + return { + code: 0, + signal: null, + timedOut: false, + stderr: '', + stdout: JSON.stringify({ + type: 'orca_profile_state_ready', + nonce: challenge, + runtime: 'bun', + runtimeVersion: ORCAD_BUN_VERSION, + artifactVersion: identity, + sqliteVersion: '3.53.2', + revision: 1 + }) + } +} + +beforeEach(() => { + vi.spyOn(process, 'versions', 'get').mockReturnValue({ + ...process.versions, + bun: ORCAD_BUN_VERSION + }) + fixture.identity.mockResolvedValue(identity) + fixture.readVersion.mockResolvedValue(`${identity}\n`) + fixture.sql.mockResolvedValue({ sqliteVersion: '3.53.2', revision: 1 }) + fixture.native.mockResolvedValue(undefined) + fixture.run.mockImplementation(async (spec) => readyResult(spec.args?.[2])) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.resetAllMocks() +}) + +describe('bundled Orca startup readiness', () => { + it.each(['win32', 'darwin', 'linux'] as const)( + 'isolates native process state in the exact bundled %s executable', + async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + await preflightBundledOrcadStartup() + expect(fixture.run).toHaveBeenCalledOnce() + expect(fixture.run).toHaveBeenCalledWith({ + program: join('/slot', platform === 'win32' ? 'bun-runtime.exe' : 'bun-runtime'), + args: [join('/slot', 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, expect.any(String)], + env: expect.objectContaining({ ORCA_BACKGROUND_LAUNCH: '1' }), + timeoutMs: 90_000, + maxOutputBytes: 64 * 1024, + terminationBarrier: true + }) + expect(fixture.sql).not.toHaveBeenCalled() + expect(fixture.native).not.toHaveBeenCalled() + } + ) + + it('leaves legacy Node startup on its existing readiness path', async () => { + const { bun: _bun, ...versions } = process.versions + vi.spyOn(process, 'versions', 'get').mockReturnValue(versions) + await preflightBundledOrcadStartup() + expect(fixture.identity).not.toHaveBeenCalled() + expect(fixture.run).not.toHaveBeenCalled() + }) + + it('hashes installed bytes only in the isolated child', async () => { + await preflightBundledOrcadStartup() + expect(fixture.identity).not.toHaveBeenCalled() + vi.spyOn(console, 'log').mockImplementation(() => {}) + await runOrcadProfilePreflight(nonce, { nativeFeatures: false }) + expect(fixture.identity).toHaveBeenCalledOnce() + }) + + it.each(['missing artifact', 'corrupt build target'])( + 'classifies %s as a configuration fault before testing SQLite', + async (message) => { + fixture.identity.mockRejectedValue(new Error(message)) + const failure = await runOrcadProfilePreflight(nonce).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(OrcadBundledRuntimeError) + expect(resolveOrcadExitCode(failure)).toBe(78) + expect(fixture.sql).not.toHaveBeenCalled() + } + ) + + it('classifies changed artifact bytes as configuration faults on normal startup', async () => { + fixture.readVersion.mockResolvedValue('0.1.0+bbbbbbbbbbbb') + await expect(preflightBundledOrcadStartup()).rejects.toThrow(OrcadBundledRuntimeError) + }) + + it.each([undefined, 'broken-version'])( + 'classifies an unreadable or malformed version marker as configuration: %s', + async (version) => { + if (version === undefined) { + fixture.readVersion.mockRejectedValue(new Error('ENOENT')) + } else { + fixture.readVersion.mockResolvedValue(version) + } + await expect(preflightBundledOrcadStartup()).rejects.toThrow(OrcadBundledRuntimeError) + expect(fixture.run).not.toHaveBeenCalled() + } + ) + + it('awaits probe termination before permitting server startup', async () => { + const exit = Promise.withResolvers() + fixture.run.mockReturnValue(exit.promise) + let admitted = false + const startup = preflightBundledOrcadStartup().then(() => { + admitted = true + }) + await vi.waitFor(() => expect(fixture.run).toHaveBeenCalledOnce()) + expect(admitted).toBe(false) + exit.resolve(readyResult(fixture.run.mock.calls[0]?.[0].args?.[2])) + await startup + expect(admitted).toBe(true) + }) + + it.each([{ code: 78 }, { timedOut: true }, { outputTruncated: true }])( + 'refuses a failed child even if it emitted a valid readiness reply: %j', + async (failure) => { + fixture.run.mockImplementation(async (spec) => ({ + ...readyResult(spec.args?.[2]), + ...failure, + stderr: 'native probe failed' + })) + await expect(preflightBundledOrcadStartup()).rejects.toThrow('native probe failed') + } + ) + + it('preserves configuration exit status from the isolated child', async () => { + fixture.run.mockImplementation(async (spec) => ({ ...readyResult(spec.args?.[2]), code: 78 })) + const failure = await preflightBundledOrcadStartup().catch((error: unknown) => error) + expect(resolveOrcadExitCode(failure)).toBe(78) + }) + + it('keeps transient SQLite readiness failures retryable', async () => { + fixture.sql.mockRejectedValue(new Error('SQLITE_BUSY')) + const failure = await runOrcadProfilePreflight(nonce).catch((error: unknown) => error) + expect(resolveOrcadExitCode(failure)).toBe(1) + }) + + it('leaves optional native probes to runtime health on normal startup', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + await runOrcadProfilePreflight(nonce, { nativeFeatures: false }) + expect(fixture.sql).toHaveBeenCalledOnce() + expect(fixture.native).toHaveBeenCalledWith({ nativeFeatures: false }) + }) + + it('rejects stale output from a different challenge', async () => { + fixture.run.mockResolvedValue(readyResult(nonce)) + await expect(preflightBundledOrcadStartup()).rejects.toThrow('invalid readiness identity') + }) + + it('rechecks the child artifact identity against the verified installed version', async () => { + fixture.run.mockImplementation(async (spec) => { + const result = readyResult(spec.args?.[2]) + return { ...result, stdout: result.stdout.replace(identity, '0.1.0+bbbbbbbbbbbb') } + }) + await expect(preflightBundledOrcadStartup()).rejects.toThrow('invalid readiness identity') + }) + + it('runs disposable probes directly in the command child without recursive spawning', async () => { + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + await runOrcadProfilePreflight(nonce) + expect(fixture.sql).toHaveBeenCalledOnce() + expect(fixture.native).toHaveBeenCalledOnce() + expect(fixture.run).not.toHaveBeenCalled() + expect(output).toHaveBeenCalledWith(readyResult(nonce).stdout) + }) +}) diff --git a/src/main/orcad/orcad-profile-preflight.ts b/src/main/orcad/orcad-profile-preflight.ts new file mode 100644 index 00000000000..eeb5bb611dd --- /dev/null +++ b/src/main/orcad/orcad-profile-preflight.ts @@ -0,0 +1,93 @@ +import { z } from 'zod' +import { randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { preflightProfileStateRuntime } from '../persistence/profile-state/profile-state-runtime-preflight' +import { + ORCAD_STARTUP_PREFLIGHT_FLAG, + ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, + parseOrcadProfilePreflight, + orcadProfilePreflightResponseSchema, + type OrcadProfilePreflightResponse +} from '../../shared/orcad-profile-preflight' +import { readOrcadArtifactIdentity } from './orcad-artifact-identity' +import { resolveOrcadInstallRoot } from './orcad-app-paths' +import { ORCAD_VERSION_FILENAME, orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { runProcess } from '../../shared/child-process/run-process' +import { preflightOrcadBunNativeRuntime } from './orcad-bun-native-preflight' +import { OrcadBundledRuntimeError } from './orcad-bundled-runtime' + +/** Check every packaged start before a profile index, data-root lock or import is touched. */ +export async function preflightBundledOrcadStartup(): Promise { + if (!process.versions.bun) { + return + } + const directory = resolveOrcadInstallRoot() + const identity = await readInstalledVersion(directory) + const nonce = randomUUID() + // Keep disposable SQLite ownership and native state out of the serving process. + const result = await runProcess({ + program: join(directory, orcadBunRuntimeFilename(process.platform)), + args: [join(directory, 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, nonce], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, + maxOutputBytes: 64 * 1024, + terminationBarrier: true + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + const Failure = result.code === 78 ? OrcadBundledRuntimeError : Error + throw new Failure(`The bundled Orca runtime failed readiness: ${result.stderr}`) + } + try { + parseOrcadProfilePreflight(result.stdout, nonce, ORCAD_BUN_VERSION, identity) + } catch (cause) { + throw new OrcadBundledRuntimeError('The bundled runtime returned invalid readiness identity', { + cause + }) + } +} + +/** Only disposable state is opened; no server, profile index or host adapters are installed. */ +export async function runOrcadProfilePreflight( + nonce: string | undefined, + options: { nativeFeatures?: boolean } = {} +): Promise { + const checkedNonce = z.string().uuid().parse(nonce) + let artifactVersion: string + try { + artifactVersion = await readOrcadArtifactIdentity(resolveOrcadInstallRoot()) + } catch (cause) { + throw new OrcadBundledRuntimeError('The bundled Orca artifacts are incomplete or altered', { + cause + }) + } + const result = await preflightProfileStateRuntime() + if (process.versions.bun) { + await preflightOrcadBunNativeRuntime(options) + } + const response: OrcadProfilePreflightResponse = { + type: 'orca_profile_state_ready', + nonce: checkedNonce, + runtime: process.versions.bun ? 'bun' : 'node', + runtimeVersion: process.versions.bun ?? process.versions.node, + artifactVersion, + ...result + } + console.log(JSON.stringify(response)) +} + +async function readInstalledVersion(directory: string): Promise { + try { + return orcadProfilePreflightResponseSchema.shape.artifactVersion.parse( + (await readFile(join(directory, ORCAD_VERSION_FILENAME), 'utf8')).trim() + ) + } catch (cause) { + throw new OrcadBundledRuntimeError( + 'The installed Orca artifact version is missing or invalid', + { + cause + } + ) + } +} diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts index f8214c93711..aa60618cc42 100644 --- a/src/main/persistence/loading-store/profile-state-store-backups.test.ts +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -1,12 +1,12 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushActiveProfileBeforeFileMutation } from '../../orca-profiles/profile-persistence-deadline' import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database' import { readProfileStateSnapshot } from '../profile-state/profile-state-documents' import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path' -import * as snapshots from '../profile-state/profile-state-database-snapshot' +import * as backupExecution from '../profile-state/profile-state-backup-worker' import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' import { Store } from './store' import { scheduleSave } from './write-scheduling' @@ -42,6 +42,10 @@ const HOUR = 60 * 60 * 1000 const fixtures: { directory: string; store: Store; authority: ProfileStateSqliteAuthority }[] = [] const releases: (() => void)[] = [] +beforeEach(() => { + vi.spyOn(backupExecution, 'runProfileStateBackup') +}) + afterEach(async () => { for (const release of releases.splice(0)) { release() @@ -136,17 +140,15 @@ describe('Store automatic SQLite recovery snapshots', () => { it('acknowledges a routine flush while the previous recovery backup is still running', async () => { const state = await fixture() - const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync + const realSnapshot = backupExecution.runProfileStateBackup const started = Promise.withResolvers() const gate = Promise.withResolvers() releases.push(gate.resolve) - vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce( - async (db, target) => { - started.resolve() - await gate.promise - await realSnapshot(db, target) - } - ) + vi.spyOn(backupExecution, 'runProfileStateBackup').mockImplementationOnce(async (job) => { + started.resolve() + await gate.promise + await realSnapshot(job) + }) state.store.updateSettings({ theme: 'dark' }) state.store.flushOrThrow() await started.promise @@ -168,7 +170,7 @@ describe('Store automatic SQLite recovery snapshots', () => { '%s waits for its owned backup across Store close', async (kind) => { const state = await fixture() - const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync + const realSnapshot = backupExecution.runProfileStateBackup let begin: () => void = () => {} let release: () => void = () => {} const started = new Promise((resolve) => { @@ -178,13 +180,11 @@ describe('Store automatic SQLite recovery snapshots', () => { release = resolve }) releases.push(release) - vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce( - async (db, target) => { - begin() - await gate - await realSnapshot(db, target) - } - ) + vi.spyOn(backupExecution, 'runProfileStateBackup').mockImplementationOnce(async (job) => { + begin() + await gate + await realSnapshot(job) + }) state.store.updateSettings({ theme: 'dark' }) state.store.flushOrThrow() await started @@ -222,7 +222,8 @@ describe('Store automatic SQLite recovery snapshots', () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}) const failure = new Error('injected backup disk failure') const snapshot = vi - .spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync') + .spyOn(backupExecution, 'runProfileStateBackup') + .mockClear() .mockRejectedValueOnce(failure) state.store.updateSettings({ theme: 'dark' }) if (flush === 'sync') { diff --git a/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts index 72df43fdbae..eb99b1fd128 100644 --- a/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-rotation.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync import * as fsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ProfileStateBackupRotation } from './profile-state-backup-rotation' import { createProfileStateDatabaseBackupId, @@ -14,7 +14,7 @@ import { openProfileStateDatabaseReadOnly } from './profile-state-database' import { importProfileStateJson, readProfileStateSnapshot } from './profile-state-documents' -import * as snapshots from './profile-state-database-snapshot' +import * as backupExecution from './profile-state-backup-worker' vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -26,6 +26,10 @@ const rotations: ProfileStateBackupRotation[] = [] const databases: ReturnType[] = [] const HOUR = 60 * 60 * 1000 +beforeEach(() => { + vi.spyOn(backupExecution, 'runProfileStateBackup') +}) + afterEach(async () => { for (const rotation of rotations.splice(0)) { rotation.stop() @@ -126,7 +130,8 @@ describe('automatic SQLite recovery generations', () => { await rotation.drain() const retained = profileStateDatabaseBackups(databasePath) const snapshot = vi - .spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync') + .spyOn(backupExecution, 'runProfileStateBackup') + .mockClear() .mockRejectedValueOnce(new Error('disk full')) const log = vi.spyOn(console, 'error').mockImplementation(() => {}) clock.now = beginning + HOUR @@ -153,9 +158,9 @@ describe('automatic SQLite recovery generations', () => { await rotation.drain() const retained = profileStateDatabaseBackups(databasePath) vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockRejectedValueOnce( - new Error('staged validation failed') - ) + vi.spyOn(backupExecution, 'runProfileStateBackup') + .mockClear() + .mockRejectedValueOnce(new Error('staged validation failed')) clock.now = beginning + HOUR rotation.schedule() await rotation.drain() @@ -213,7 +218,7 @@ describe('automatic SQLite recovery generations', () => { it('owns an in-flight source until completion and blocks synchronous quarantine', async () => { const { databasePath, rotation, opened } = fixture() - const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync + const realSnapshot = backupExecution.runProfileStateBackup let begin: () => void = () => {} let release: () => void = () => {} const started = new Promise((resolve) => { @@ -222,13 +227,11 @@ describe('automatic SQLite recovery generations', () => { const gate = new Promise((resolve) => { release = resolve }) - vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce( - async (source, target) => { - begin() - await gate - await realSnapshot(source, target) - } - ) + vi.spyOn(backupExecution, 'runProfileStateBackup').mockImplementationOnce(async (job) => { + begin() + await gate + await realSnapshot(job) + }) rotation.schedule() await started rotation.stop() diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.ts b/src/main/persistence/profile-state/profile-state-backup-worker.ts index 287ca2ce34b..b671df94112 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.ts @@ -20,12 +20,12 @@ export function resolveProfileStateBackupWorkerPath(moduleDir = __dirname): stri return [entry, join(dirname(entry), '..', WORKER_FILENAME)].find(existsSync) ?? entry } -/** Desktop validation runs off the UI thread; plain-Node backups retain the native async path. */ +/** Bun snapshot copying and desktop validation run off the owning runtime thread. */ export function runProfileStateBackup( job: ProfileStateBackupJob, signal?: AbortSignal ): Promise { - return process.versions.electron + return process.versions.electron || process.versions.bun ? runProfileStateBackupWorker(job, { signal }) : writeProfileStateBackup(job) } diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts index da0ebcf7ac5..e0147cb937f 100644 --- a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -96,7 +96,8 @@ describe('asynchronous profile-state database snapshots', () => { expect(existsSync(targetPath)).toBe(false) }) - it.each(['same connection', 'another connection'] as const)( + // Node's incremental backup callback is not part of Bun's worker snapshot contract. + it.skipIf(!!process.versions.bun).each(['same connection', 'another connection'] as const)( 'keeps a consistent complete revision while writes occur from %s', async (connection) => { const { directory, databasePath, db, targetPath } = fixture() @@ -245,21 +246,24 @@ describe('asynchronous profile-state database snapshots', () => { expectNoTemporaryFiles(directory) }) - it('fails clearly when native backup is unsupported without replacing the destination', async () => { - const { directory, db, targetPath } = fixture() - writeFileSync(targetPath, 'previous recovery artifact') - const getBuiltinModule = process.getBuiltinModule.bind(process) - vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => - id === 'node:sqlite' ? {} : getBuiltinModule(id) - ) + it.skipIf(!!process.versions.bun)( + 'fails clearly when native backup is unsupported without replacing the destination', + async () => { + const { directory, db, targetPath } = fixture() + writeFileSync(targetPath, 'previous recovery artifact') + const getBuiltinModule = process.getBuiltinModule.bind(process) + vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => + id === 'node:sqlite' ? {} : getBuiltinModule(id) + ) - await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( - 'Asynchronous SQLite backup is unavailable' - ) + await expect(writeProfileStateDatabaseSnapshotAsync(db, targetPath)).rejects.toThrow( + 'Asynchronous SQLite backup is unavailable' + ) - expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') - expectNoTemporaryFiles(directory) - }) + expect(readFileSync(targetPath, 'utf8')).toBe('previous recovery artifact') + expectNoTemporaryFiles(directory) + } + ) it.each(['', 'invalid\0path'])('rejects the invalid target %j', async (path) => { const { db } = fixture() diff --git a/src/main/persistence/profile-state/profile-state-database.ts b/src/main/persistence/profile-state/profile-state-database.ts index d264d9f27d8..e09c8c87869 100644 --- a/src/main/persistence/profile-state/profile-state-database.ts +++ b/src/main/persistence/profile-state/profile-state-database.ts @@ -1,5 +1,5 @@ import { withProfileStateWriteTransaction } from './profile-state-write-transaction' -import Database from '../../sqlite/sync-database' +import Database, { isSqliteAvailable } from '../../sqlite/sync-database' import { migrateAutomationRunsStorage } from './profile-state-automation-runs-migration' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' import { @@ -13,7 +13,6 @@ import { PROFILE_STATE_DATABASE_FILE_NAME, profileStateDatabaseFile } from '../../../shared/profile-state-storage-paths' -import { isRecord } from './profile-state-document-validation' import { ProfileStateDatabaseOpenError, type ProfileStateDatabaseOpenErrorCode @@ -25,29 +24,8 @@ import { export const PROFILE_STATE_BUSY_TIMEOUT_MS = 5_000 -/** - * Probe SQLite without importing the builtin at module evaluation time. - * - * The packaged orcad runtime still supports Node 18, where `node:sqlite` does - * not exist. Keeping this probe beside the opener gives every authority - * selector the same capability decision and keeps that runtime's module graph - * safe to load. - */ -export function isProfileStateSqliteAvailable(): boolean { - if (typeof process.getBuiltinModule !== 'function') { - return false - } - try { - const sqlite: unknown = process.getBuiltinModule('node:sqlite') - return ( - isRecord(sqlite) && - typeof sqlite.DatabaseSync === 'function' && - typeof sqlite.backup === 'function' - ) - } catch { - return false - } -} +// Keep relay-only Node 18 imports safe while selecting the actual database driver. +export const isProfileStateSqliteAvailable = isSqliteAvailable export { ProfileStateDatabaseOpenError } export type { ProfileStateDatabaseOpenErrorCode } diff --git a/src/main/persistence/profile-state/profile-state-runtime-preflight.test.ts b/src/main/persistence/profile-state/profile-state-runtime-preflight.test.ts new file mode 100644 index 00000000000..34a4b02c0a4 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-runtime-preflight.test.ts @@ -0,0 +1,60 @@ +import { build } from 'esbuild' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { preflightProfileStateRuntime } from './profile-state-runtime-preflight' + +let directory: string +let workerPath: string +let backupWorkerPath: string + +beforeAll(async () => { + directory = mkdtempSync(join(tmpdir(), 'orca-preflight-test-')) + workerPath = join(directory, 'profile-state-writer-worker-entry.js') + backupWorkerPath = join(directory, 'profile-state-backup-worker-entry.js') + await build({ + entryPoints: [ + resolve('src/main/persistence/profile-state/profile-state-writer-worker-entry.ts'), + resolve('src/main/persistence/profile-state/profile-state-backup-worker-entry.ts') + ], + outdir: directory, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent' + }) +}) + +afterAll(() => rmSync(directory, { recursive: true, force: true })) + +describe('profile runtime preflight', () => { + it('requires a worker commit and an independently readable backup', async () => { + const result = await preflightProfileStateRuntime({ workerPath, backupWorkerPath }) + expect(result.revision).toBe(1) + expect(result.sqliteVersion).toMatch(/^\d+\.\d+\.\d+$/) + }) + + it('refuses readiness when a required worker artifact is absent', async () => { + await expect( + preflightProfileStateRuntime({ + workerPath: join(directory, 'missing.js'), + backupWorkerPath + }) + ).rejects.toThrow('Profile state writer') + }) + + it('does not trust a backup success reply without a valid database', async () => { + const corruptBackup = join(directory, 'corrupt-backup.cjs') + writeFileSync( + corruptBackup, + `const { parentPort, workerData } = require('node:worker_threads') + require('node:fs').writeFileSync(workerData.targetPath, 'not a database') + parentPort.postMessage({ ok: true }) + parentPort.close()` + ) + await expect( + preflightProfileStateRuntime({ workerPath, backupWorkerPath: corruptBackup }) + ).rejects.toThrow() + }) +}) diff --git a/src/main/persistence/profile-state/profile-state-runtime-preflight.ts b/src/main/persistence/profile-state/profile-state-runtime-preflight.ts new file mode 100644 index 00000000000..90a8ab1fab1 --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-runtime-preflight.ts @@ -0,0 +1,67 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { + openProfileStateDatabase, + openProfileStateDatabaseReadOnly +} from './profile-state-database' +import { readProfileStateSnapshot } from './profile-state-documents' +import { runProfileStateBackupWorker } from './profile-state-backup-worker' +import { ProfileStateWriteWorkerClient } from './profile-state-writer-worker-client' + +export type ProfileStateRuntimePreflightResult = { + sqliteVersion: string + revision: number +} + +/** Qualify the installed writer and backup without opening an existing user profile. */ +export async function preflightProfileStateRuntime( + options: { workerPath?: string; backupWorkerPath?: string; timeoutMs?: number } = {} +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-profile-preflight-')) + const databasePath = join(directory, 'profile.db') + const targetPath = join(directory, 'backup.db') + const profileId = randomUUID() + const payload = JSON.stringify({ witness: profileId, unicode: '雪 🐋', surrogate: '\ud800' }) + let writer: ProfileStateWriteWorkerClient | undefined + try { + const initial = openProfileStateDatabase(databasePath, profileId) + let revision: number + try { + revision = readProfileStateSnapshot(initial.db).revision + } finally { + initial.db.close() + } + writer = new ProfileStateWriteWorkerClient({ databasePath, profileId, revision }, options) + await writer.ready + const committedRevision = await writer.writeCompleteSerializedDomains([ + { domain: 'preflight', payload } + ]) + await writer.close() + await runProfileStateBackupWorker( + { databasePath, profileId, targetPath }, + { workerPath: options.backupWorkerPath, timeoutMs: options.timeoutMs } + ) + const backup = openProfileStateDatabaseReadOnly(targetPath, profileId) + try { + const snapshot = readProfileStateSnapshot(backup.db) + if (snapshot.revision !== committedRevision || snapshot.json !== `{"preflight":${payload}}`) { + throw new Error('Profile runtime backup did not preserve the acknowledged state') + } + const row = backup.db.prepare('SELECT sqlite_version() AS version').get() + if (typeof row?.version !== 'string') { + throw new Error('Profile runtime did not report its SQLite version') + } + return { sqliteVersion: row.version, revision: committedRevision } + } finally { + backup.db.close() + } + } finally { + try { + await writer?.close() + } finally { + await rm(directory, { recursive: true, force: true }) + } + } +} diff --git a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts index 2eb64a50d4d..6bd334063a1 100644 --- a/src/main/persistence/profile-state/profile-state-startup-authority.test.ts +++ b/src/main/persistence/profile-state/profile-state-startup-authority.test.ts @@ -172,7 +172,7 @@ describe('profile-state startup authority boundary', () => { it('rejects an orcad candidate request on a Node 18-style host', async () => { const original = process.getBuiltinModule vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { - if (id === 'node:sqlite') { + if (id === 'node:sqlite' || id === 'bun:sqlite') { return undefined } return original(id) @@ -290,6 +290,9 @@ describe('profile-state startup authority boundary', () => { it('keeps a runtime with SQLite but no native backup on JSON authority', async () => { const original = process.getBuiltinModule vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { + if (id === 'bun:sqlite') { + return undefined + } return id === 'node:sqlite' ? { DatabaseSync: class {} } : original(id) }) expect(orcadProfileStateAuthorityMode()).toBe('legacy') diff --git a/src/main/persistence/profile-state/profile-state-store-factory.test.ts b/src/main/persistence/profile-state/profile-state-store-factory.test.ts index 20af26f1c1c..3feb0cdd2bb 100644 --- a/src/main/persistence/profile-state/profile-state-store-factory.test.ts +++ b/src/main/persistence/profile-state/profile-state-store-factory.test.ts @@ -143,7 +143,7 @@ describe('profile state Store authority factory', () => { it('uses a capability probe that remains false on a Node 18-style host', () => { const original = process.getBuiltinModule vi.spyOn(process, 'getBuiltinModule').mockImplementation((id) => { - if (id === 'node:sqlite') { + if (id === 'node:sqlite' || id === 'bun:sqlite') { return undefined } return original(id) diff --git a/src/main/providers/agent-foreground-process-git-bash.win32.test.ts b/src/main/providers/agent-foreground-process-git-bash.win32.test.ts index 3cd4f07c003..f8fe5f2ac79 100644 --- a/src/main/providers/agent-foreground-process-git-bash.win32.test.ts +++ b/src/main/providers/agent-foreground-process-git-bash.win32.test.ts @@ -7,6 +7,8 @@ import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' import { confirmPtyShellForeground } from '../daemon/pty-subprocess/pty-shell-foreground-confirmation' import { createPtyShellLaunchPlan } from '../daemon/pty-subprocess/shell-launch-plan' import { spawnNativeDaemonPty } from '../daemon/pty-subprocess/native-pty-spawn' +import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' +import { createWindowsBunPtyLaunch } from '../daemon/pty-subprocess/windows-bun-pty-launch' import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' import type { PtySubprocessOptions } from '../daemon/pty-subprocess' import { isGitForWindowsBashLauncherPath } from '../git-bash' @@ -52,7 +54,23 @@ describeOnWindows("Git Bash launcher shell proof with Orca's real launch", () => const plan = createPtyShellLaunchPlan(opts, env) expect(isGitForWindowsBashLauncherPath(plan.shellPath)).toBe(true) expect(plan.shellArgs.join(' ')).toContain('exec "$BASH"') - const spawned = spawnNativeDaemonPty({ ...plan, env, cols: opts.cols, rows: opts.rows }) + const spawned = await spawnNativeDaemonPty( + { ...plan, env, cols: opts.cols, rows: opts.rows }, + { + canUseBunPty, + spawnBunPty: (args) => + spawnBunPty(args, { + // Source tests use the TS worker; packaged hosts resolve their adjacent JS worker. + createWindowsLaunch: (launch) => + createWindowsBunPtyLaunch(launch, { + workerPath: join( + __dirname, + '../daemon/pty-subprocess/windows-bun-pty-gate-entry.ts' + ) + }) + }) + } + ) const proc = spawned.process let output = '' let dead = false @@ -74,13 +92,27 @@ describeOnWindows("Git Bash launcher shell proof with Orca's real launch", () => await vi.waitFor(() => expect(readWindowsPtyJobProcessIds(proc)?.size).toBe(3), { timeout: 5_000 }) - await vi.waitFor(async () => expect(await confirm()).toBe(true), { timeout: 5_000 }) + await vi.waitFor(async () => expect(await confirm(), 'initial prompt').toBe(true), { + timeout: 5_000 + }) - proc.write('sleep 60\r') + // Interrupt only after the child is ready, not during a transient shell fork. + proc.write( + "node -e \"console.log(['ORCA','FOREGROUND_READY'].join('_')); setInterval(() => {}, 1000)\"\r" + ) + await vi.waitFor(() => expect(output).toContain('ORCA_FOREGROUND_READY'), { + timeout: 10_000 + }) await vi.waitFor(async () => expect(await confirm()).toBe(false), { timeout: 10_000 }) proc.write('\x03') - await vi.waitFor(async () => expect(await confirm()).toBe(true), { timeout: 10_000 }) + await vi.waitFor( + async () => { + expect(dead, 'terminal survived foreground interrupt').toBe(false) + expect(await confirm(), 'prompt after interrupt').toBe(true) + }, + { timeout: 10_000 } + ) proc.write('sleep 60 &\r') await vi.waitFor(async () => expect(await confirm()).toBe(false), { timeout: 10_000 }) diff --git a/src/main/providers/local-pty-bun-artifact.integration.test.ts b/src/main/providers/local-pty-bun-artifact.integration.test.ts new file mode 100644 index 00000000000..487127887a3 --- /dev/null +++ b/src/main/providers/local-pty-bun-artifact.integration.test.ts @@ -0,0 +1,107 @@ +import { build } from 'esbuild' +import { existsSync } from 'node:fs' +import { copyFile, mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' + +const runtime = + process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform)) + +describe.skipIf(!existsSync(runtime))('isolated Bun in-process PTY artifact', () => { + it('spawns, reattaches, delivers data and retires a shell without node-pty installed', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-bun-local-pty-')) + try { + const entry = join(directory, 'local-pty.cjs') + const external = ['node-pty', 'electron', 'bun:ffi', '@parcel/watcher', '*.node'] + await build({ + stdin: { + contents: ` + import { LocalPtyProvider } from './src/main/providers/local-pty-provider' + import { setAppEnvironment } from './src/shared/app-environment' + import { getCmdExePath } from './src/shared/windows-batch-spawn' + try { require.resolve('node-pty'); throw new Error('node-pty unexpectedly available') } + catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error } + setAppEnvironment({ + getPath: () => process.cwd(), getAppPath: () => process.cwd(), + getVersion: () => 'test', isPackaged: () => true, + onWillQuit() {}, exit: code => process.exit(code), getAppMetrics: () => [] + }) + const provider = new LocalPtyProvider() + let output = '', resolveExit + const exit = new Promise(resolve => { resolveExit = resolve }) + provider.onData(event => { output += event.data }) + provider.onExit(event => resolveExit(event.code)) + const deadline = setTimeout(() => { provider.killAll(); process.exit(98) }, 10_000) + ;(async () => { + const first = await provider.spawn({ + sessionId: 'isolated-bun-fallback', cols: 80, rows: 24, cwd: process.cwd(), + shellOverride: process.platform === 'win32' ? getCmdExePath() : '/bin/sh' + }) + const again = await provider.spawn({sessionId:first.id, cols:100, rows:30}) + provider.write(first.id, process.platform === 'win32' + ? 'echo ORCA_BUN_FALLBACK_READY & exit 17\\r' + : 'printf ORCA_BUN_FALLBACK_READY; exit 17\\r') + const code = await exit + clearTimeout(deadline) + console.log(JSON.stringify({ + version:process.versions.bun, code, output:output.includes('ORCA_BUN_FALLBACK_READY'), + reattached:again.isReattach === true && again.pid === first.pid, + retired:provider.getPtyProcess(first.id) === undefined + })) + })().catch(error => { clearTimeout(deadline); provider.killAll(); console.error(error);process.exitCode=1 }) + `, + resolveDir: process.cwd(), + loader: 'ts' + }, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node18', + external, + outfile: entry, + logLevel: 'silent' + }) + if (process.platform === 'win32') { + await build({ + entryPoints: ['src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + external, + outfile: join(directory, 'windows-bun-pty-gate-entry.js'), + logLevel: 'silent' + }) + } + const isolatedRuntime = join(directory, orcadBunRuntimeFilename(process.platform)) + await copyFile(runtime, isolatedRuntime) + const result = await runProcess({ + program: isolatedRuntime, + args: ['--no-install', entry], + cwd: directory, + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1', + ORCA_USER_DATA_PATH: directory + }, + timeoutMs: 15_000, + terminationBarrier: true + }) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + version: ORCAD_BUN_VERSION, + code: 17, + output: true, + reattached: true, + retired: true + }) + } finally { + removeTreeSync(directory) + } + }, 20_000) +}) diff --git a/src/main/providers/local-pty-bun-posix-inspection.test.ts b/src/main/providers/local-pty-bun-posix-inspection.test.ts new file mode 100644 index 00000000000..c71e4a3fa6c --- /dev/null +++ b/src/main/providers/local-pty-bun-posix-inspection.test.ts @@ -0,0 +1,153 @@ +import type { IPty } from 'node-pty' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ProcessTableReader from '../../shared/process-table-snapshot-reader' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' +import { + confirmLocalPtyForegroundProcess, + getLocalPtyForegroundProcess, + hasLocalPtyChildProcesses, + inspectLocalPtyChildProcesses +} from './local-pty-foreground-inspection' +import { LocalPtyProvider } from './local-pty-provider' +import { ptyLastRecognizedForeground, ptyProcesses, ptyShellPath } from './local-pty-provider-state' + +const scans = vi.hoisted(() => ({ full: vi.fn(), fresh: vi.fn(), strict: vi.fn() })) +vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getProcessTableSnapshot: scans.full, + getFreshProcessTableSnapshot: scans.fresh, + getStrictProcessTableSnapshotWithAge: scans.strict +})) +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +const id = 'bun-pane' +const provider = new LocalPtyProvider() +let rows: ProcessTableRow[] + +function row(pid: number, ppid: number, command: string, foregroundPid: number): ProcessTableRow { + return { + pid, + ppid, + command, + pgid: pid, + tpgid: foregroundPid, + stat: pid === foregroundPid ? 'Ss+' : 'Ss', + tty: 'ttys002' + } +} + +function pane(): IPty & { processNameIsSpawnFile: true } { + return { + pid: 100, + process: '/bin/zsh', + processNameIsSpawnFile: true, + cols: 80, + rows: 24, + handleFlowControl: false, + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + resize() {}, + clear() {}, + write() {}, + kill() {}, + pause() {}, + resume() {} + } +} + +beforeEach(() => { + vi.resetAllMocks() + scans.full.mockImplementation(async () => rows) + scans.fresh.mockImplementation(async () => rows) + scans.strict.mockImplementation(async () => ({ rows, capturedAgeMs: 0 })) + ptyProcesses.set(id, pane()) + ptyShellPath.set(id, '/bin/zsh') +}) +afterEach(() => { + ptyProcesses.clear() + ptyShellPath.clear() + ptyLastRecognizedForeground.clear() + Object.defineProperty(process, 'platform', platform) + vi.restoreAllMocks() +}) + +describe.each(['darwin', 'linux'])('Bun in-process %s inspection', (host) => { + beforeEach(() => Object.defineProperty(process, 'platform', { value: host, configurable: true })) + + it('reports the actual foreground command and warns before closing a busy pane', async () => { + rows = [row(100, 1, '-zsh', 101), row(101, 100, 'vim notes.md', 101)] + expect(await provider.inspectProcess(id)).toEqual({ + foregroundProcess: 'vim', + hasChildProcesses: true, + childProcessEvidence: 'children' + }) + expect(await hasLocalPtyChildProcesses(id)).toBe(true) + expect(await confirmLocalPtyForegroundProcess(id)).toBe('vim') + expect(scans.fresh).toHaveBeenCalledOnce() + }) + + it('proves an idle shell has no children', async () => { + rows = [row(100, 1, '-zsh', 100)] + expect(await provider.inspectProcess(id)).toEqual({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + expect(await hasLocalPtyChildProcesses(id)).toBe(false) + }) + + it('does not mistake the login wrapper for a running user job', async () => { + ptyProcesses.set(id, { ...pane(), process: '/usr/bin/login' }) + rows = [row(100, 1, '/usr/bin/login -fp test', 101), row(101, 100, '-zsh', 101)] + expect(await provider.inspectProcess(id)).toEqual({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + childProcessEvidence: 'no-children' + }) + }) + + it('preserves a cached agent when the foreground scan cannot verify it', async () => { + rows = [] + scans.full.mockRejectedValue(new Error('process table unavailable')) + scans.strict.mockRejectedValue(new Error('process table unavailable')) + ptyLastRecognizedForeground.set(id, { name: 'claude', pid: 101, at: Date.now() }) + expect(await provider.inspectProcess(id)).toEqual({ + foregroundProcess: 'claude', + hasChildProcesses: true, + childProcessEvidence: 'unverifiable' + }) + expect(await hasLocalPtyChildProcesses(id)).toBe(true) + expect(await confirmLocalPtyForegroundProcess(id)).toBeNull() + }) + + it('returns uncertainty when the process table has no pane root', async () => { + rows = [row(900, 1, '-zsh', 900)] + expect(await getLocalPtyForegroundProcess(id)).toBeNull() + expect(await inspectLocalPtyChildProcesses(id)).toBe('unverifiable') + expect(await hasLocalPtyChildProcesses(id)).toBe(true) + }) + + it('does not resurrect an agent cache after replacement during fingerprint capture', async () => { + rows = [row(100, 1, '-zsh', 101), row(101, 100, 'node /usr/local/bin/claude', 101)] + scans.full + .mockImplementationOnce(async () => rows) + .mockImplementationOnce(async () => { + ptyProcesses.set(id, { ...pane(), pid: 999 }) + return rows + }) + expect(await getLocalPtyForegroundProcess(id)).toBeNull() + expect(ptyLastRecognizedForeground.has(id)).toBe(false) + }) + + it('does not combine an old foreground with a replacement pane during a child scan', async () => { + rows = [row(100, 1, '-zsh', 101), row(101, 100, 'vim notes.md', 101)] + scans.strict.mockImplementationOnce(async () => { + ptyProcesses.set(id, { ...pane(), pid: 999 }) + return { rows, capturedAgeMs: 0 } + }) + expect(await provider.inspectProcess(id)).toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + childProcessEvidence: 'unverifiable' + }) + }) +}) diff --git a/src/main/providers/local-pty-bun-windows-identity.test.ts b/src/main/providers/local-pty-bun-windows-identity.test.ts new file mode 100644 index 00000000000..43444579046 --- /dev/null +++ b/src/main/providers/local-pty-bun-windows-identity.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ptyProcesses, ptyShellPath } from './local-pty-provider-state' +import { + confirmLocalPtyShellForeground, + inspectLocalPtyChildProcesses +} from './local-pty-foreground-inspection' +import { getLocalPtyCwd, sendLocalPtySignal } from './local-pty-session-operations' + +const { confirm, cwd, membership } = vi.hoisted(() => ({ + confirm: vi.fn(), + cwd: vi.fn(), + membership: vi.fn() +})) +vi.mock('./agent-foreground-process', () => ({ + confirmShellForegroundProcess: confirm, + resolveAgentForegroundProcessWithAvailability: vi.fn() +})) +vi.mock('./process-cwd', () => ({ resolveProcessCwd: cwd })) +vi.mock('./windows-pty-job-membership', () => ({ + readWindowsPtyJobProcessIds: membership, + isWindowsPtyJobReadable: () => true +})) + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + confirm.mockResolvedValue(true) + cwd.mockResolvedValue('C:\\work') +}) +afterEach(() => { + ptyProcesses.clear() + ptyShellPath.clear() + Object.defineProperty(process, 'platform', originalPlatform) + vi.restoreAllMocks() +}) + +describe('Bun in-process Windows shell identity', () => { + it('uses the child shell for cwd and foreground checks while signaling the owned job', async () => { + const proc = { + pid: 1200, + shellProcessId: 1201, + jobRootProcessIsWrapper: true as const, + processNameIsSpawnFile: true as const, + process: 'cmd.exe', + cols: 80, + rows: 24, + handleFlowControl: false, + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + write() {}, + clear() {}, + pause() {}, + resume() {}, + resize() {}, + kill() {}, + signalProcess: vi.fn() + } + ptyProcesses.set('gated-shell', proc) + const shellPath = 'C:\\Windows\\System32\\cmd.exe' + ptyShellPath.set('gated-shell', shellPath) + const kill = vi.spyOn(process, 'kill').mockReturnValue(true) + expect(await getLocalPtyCwd('gated-shell')).toBe('C:\\work') + expect(cwd).toHaveBeenCalledWith(1201) + expect(await confirmLocalPtyShellForeground('gated-shell')).toBe(true) + expect(confirm).toHaveBeenCalledWith(1201, shellPath, expect.any(Object)) + await sendLocalPtySignal('gated-shell', 'SIGTERM') + expect(proc.signalProcess).toHaveBeenCalledWith('SIGTERM') + expect(kill).not.toHaveBeenCalled() + membership.mockReturnValue(new Set([1201])) + expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('no-children') + membership.mockReturnValue(new Set([1201, 1202])) + expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('children') + membership.mockReturnValue(null) + expect(await inspectLocalPtyChildProcesses('gated-shell')).toBe('unverifiable') + Reflect.deleteProperty(proc, 'shellProcessId') + cwd.mockClear() + expect(await getLocalPtyCwd('gated-shell')).toBe('') + expect(cwd).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/local-pty-child-process-verdict.test.ts b/src/main/providers/local-pty-child-process-verdict.test.ts index 0338b38aaa4..857017b6671 100644 --- a/src/main/providers/local-pty-child-process-verdict.test.ts +++ b/src/main/providers/local-pty-child-process-verdict.test.ts @@ -99,7 +99,7 @@ describe('confirmLocalPtyShellForeground', () => { const describeOnPosix = process.platform === 'win32' ? describe.skip : describe describe('inspectLocalPtyChildProcesses', () => { - it('reports unverifiable when the pty fd cannot be read', () => { + it('reports unverifiable when the pty fd cannot be read', async () => { registerPane( 'pty-closed', () => { @@ -107,24 +107,24 @@ describe('inspectLocalPtyChildProcesses', () => { }, '/bin/zsh' ) - expect(inspectLocalPtyChildProcesses('pty-closed')).toBe('unverifiable') + expect(await inspectLocalPtyChildProcesses('pty-closed')).toBe('unverifiable') }) - it('still answers no-children when the shell itself is in the foreground', () => { + it('still answers no-children when the shell itself is in the foreground', async () => { registerPane('pty-idle', 'zsh', '/bin/zsh') - expect(inspectLocalPtyChildProcesses('pty-idle')).toBe('no-children') + expect(await inspectLocalPtyChildProcesses('pty-idle')).toBe('no-children') }) - it('answers children when something else is in the foreground', () => { + it('answers children when something else is in the foreground', async () => { registerPane('pty-busy', 'vim', '/bin/zsh') - expect(inspectLocalPtyChildProcesses('pty-busy')).toBe('children') + expect(await inspectLocalPtyChildProcesses('pty-busy')).toBe('children') }) - it('treats a pane this provider does not hold as a real negative', () => { - expect(inspectLocalPtyChildProcesses('pty-absent')).toBe('no-children') + it('treats a pane this provider does not hold as a real negative', async () => { + expect(await inspectLocalPtyChildProcesses('pty-absent')).toBe('no-children') }) - it('collapses uncertainty to false only in the boolean adapter', async () => { + it('preserves uncertainty conservatively in the boolean adapter', async () => { let reads = 0 registerPane( 'pty-closed', @@ -134,8 +134,8 @@ describe('inspectLocalPtyChildProcesses', () => { }, '/bin/zsh' ) - await expect(hasLocalPtyChildProcesses('pty-closed')).resolves.toBe(false) - // The `false` has to come from the failed read, not from an earlier short-circuit. + await expect(hasLocalPtyChildProcesses('pty-closed')).resolves.toBe(true) + // The result must come from the failed read, not from an earlier short-circuit. expect(reads).toBe(1) }) }) @@ -147,14 +147,14 @@ describeOnPosix('inspectLocalPtyChildProcesses on a retired master', () => { // The mechanism is silent: this is the same string an idle pane reports. expect(term.process).toBe(POSIX_SHELL) // Not `no-children`: the close guard reads that as "nothing is running here" and kills the pane. - expect(inspectLocalPtyChildProcesses('pty-retired')).toBe('unverifiable') + expect(await inspectLocalPtyChildProcesses('pty-retired')).toBe('unverifiable') }, 15000) - it('collapses uncertainty to false only in the boolean adapter', async () => { + it('preserves uncertainty conservatively in the boolean adapter', async () => { await registerRetiredPane('pty-retired') // The adapter exists for `IPtyProvider.hasChildProcesses`, which has no third slot. - await expect(hasLocalPtyChildProcesses('pty-retired')).resolves.toBe(false) + await expect(hasLocalPtyChildProcesses('pty-retired')).resolves.toBe(true) }, 15000) }) @@ -176,7 +176,7 @@ describe('inspectPtyProviderProcess child-process evidence', () => { ) await expect(inspectPtyProviderProcess(provider, 'pty-closing')).resolves.toEqual({ foregroundProcess: '/bin/zsh', - hasChildProcesses: false, + hasChildProcesses: true, childProcessEvidence: 'unverifiable' }) }) @@ -216,7 +216,7 @@ describe('inspectPtyProviderProcess child-process evidence', () => { await expect(inspectPtyProviderProcess(provider, 'pty-swapped')).resolves.toEqual({ foregroundProcess: null, - hasChildProcesses: false, + hasChildProcesses: true, childProcessEvidence: 'unverifiable' }) }) @@ -229,7 +229,7 @@ describeOnPosix('inspectPtyProviderProcess on a retired master', () => { await registerRetiredPane('pty-retired') const inspection = await inspectPtyProviderProcess(provider, 'pty-retired') - expect(inspection.hasChildProcesses).toBe(false) + expect(inspection.hasChildProcesses).toBe(true) expect(inspection.childProcessEvidence).toBe('unverifiable') }, 15000) }) diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts index 1eb4876cfd0..8c3b0a6dcce 100644 --- a/src/main/providers/local-pty-foreground-inspection.ts +++ b/src/main/providers/local-pty-foreground-inspection.ts @@ -1,13 +1,22 @@ import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { getCheapProcessTableSnapshot } from '../../shared/cheap-process-table-snapshot-reader' -import { getProcessTableSnapshot } from '../../shared/process-table-snapshot-reader' import { - confirmShellForegroundProcess, - resolveAgentForegroundProcessWithAvailability -} from './agent-foreground-process' + getProcessTableSnapshot, + getStrictProcessTableSnapshotWithAge +} from '../../shared/process-table-snapshot-reader' +import { confirmShellForegroundProcess } from './agent-foreground-process' +import { + createPtyForegroundResolver, + ptyProcessNameIsSpawnFile +} from '../daemon/pty-subprocess/spawn-file-foreground-process' +import { + inspectSpawnFileChildProcessesFromRows, + inspectSpawnFileWindowsChildProcesses +} from '../daemon/pty-subprocess/spawn-file-child-processes' import { buildPaneProcessFingerprint } from './posix-pane-foreground-fingerprint' import { isRetiredPtyMaster } from '../pty/node-pty-master-fd-retirement' +import { ptyShellProcessId } from '../windows/windows-pty-job' import { resolveForegroundFallbackProcess } from './local-pty-launch-helpers' import { ptyAgentForegroundContextPaths, @@ -29,7 +38,7 @@ import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows- * equals the recorded shell and would otherwise read as a real "nothing is running here". Ask the * descriptor before the name, because an unreadable PTY is not evidence that its children exited. */ -export function inspectLocalPtyChildProcesses(id: string): PtyChildProcessVerdict { +export async function inspectLocalPtyChildProcesses(id: string): Promise { const proc = ptyProcesses.get(id) if (!proc) { return 'no-children' @@ -38,6 +47,19 @@ export function inspectLocalPtyChildProcesses(id: string): PtyChildProcessVerdic return 'unverifiable' } try { + if (ptyProcessNameIsSpawnFile(proc)) { + if (process.platform === 'win32') { + return inspectSpawnFileWindowsChildProcesses(proc) + } + const snapshot = await getStrictProcessTableSnapshotWithAge() + return ptyProcesses.get(id) === proc + ? inspectSpawnFileChildProcessesFromRows( + snapshot.rows, + proc.pid, + getPtyShellName(id) ?? null + ) + : 'unverifiable' + } const foreground = proc.process const shell = getPtyShellName(id) if (!shell) { @@ -51,7 +73,7 @@ export function inspectLocalPtyChildProcesses(id: string): PtyChildProcessVerdic } export async function hasLocalPtyChildProcesses(id: string): Promise { - return inspectLocalPtyChildProcesses(id) === 'children' + return (await inspectLocalPtyChildProcesses(id)) !== 'no-children' } /** @@ -89,7 +111,7 @@ export async function getLocalPtyForegroundProcess(id: string): Promise readWindowsPtyJobProcessIds(proc) } diff --git a/src/main/providers/local-pty-launch-plan.ts b/src/main/providers/local-pty-launch-plan.ts index c8b784e7cb3..17351325300 100644 --- a/src/main/providers/local-pty-launch-plan.ts +++ b/src/main/providers/local-pty-launch-plan.ts @@ -1,4 +1,5 @@ import { win32 as pathWin32 } from 'node:path' +import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' import { resolveWindowsGitBashShellPath } from '../git-bash' @@ -76,7 +77,9 @@ function finalizeLocalPtyLaunchPlan( windowsFallbackAttempts?: ReturnType } ): LocalPtyLaunchPlan { - ensureNodePtySpawnHelperExecutable() + if (!canUseBunPty()) { + ensureNodePtySpawnHelperExecutable() + } if (seed.args.prevalidatedCwd !== shell.validationCwd) { validateWorkingDirectory(shell.validationCwd) } diff --git a/src/main/providers/local-pty-pending-native-spawn.test.ts b/src/main/providers/local-pty-pending-native-spawn.test.ts new file mode 100644 index 00000000000..7f292e41273 --- /dev/null +++ b/src/main/providers/local-pty-pending-native-spawn.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnLocalPty } from './local-pty-spawn' +import { cancelPendingLocalPtySpawns } from './local-pty-spawn-state' +import { pendingLocalPtySpawns, ptyProcesses } from './local-pty-provider-state' + +const { spawn, activate, destroy } = vi.hoisted(() => ({ + spawn: vi.fn(), + activate: vi.fn(), + destroy: vi.fn() +})) +vi.mock('./local-pty-runtime-spawn', () => ({ loadLocalPtyRuntimeSpawn: async () => spawn })) +vi.mock('./macos-tcc-login-shell', () => ({ prepareMacosTccLoginShell: async () => {} })) +vi.mock('./local-pty-finalize-environment', () => ({ + finalizeLocalPtySpawnEnvironment: () => null +})) +vi.mock('./local-pty-spawn-environment', () => ({ + buildLocalPtySpawnEnvironment: () => ({}), + enforceLocalPtySpawnEnvironmentOverrides() {} +})) +vi.mock('./local-pty-launch-plan', () => ({ + DeferredLocalPtyLaunchPlan: class {}, + createLocalPtyLaunchPlan: () => ({ + shellPath: '/bin/sh', + shellArgs: [], + effectiveCwd: '/tmp', + cwd: '/tmp', + windowsFallbackAttempts: [] + }) +})) +vi.mock('./local-pty-session-activation', () => ({ activateLocalPtySession: activate })) +vi.mock('./local-pty-termination', () => ({ destroyPtyProcess: destroy })) + +function createProcess() { + return { + pid: 12345, + process: '/bin/sh', + cols: 80, + rows: 24, + handleFlowControl: false, + onData: () => ({ dispose() {} }), + onExit: () => ({ dispose() {} }), + write() {}, + clear() {}, + pause() {}, + resume() {}, + resize: vi.fn(), + kill: vi.fn() + } +} + +function start(id = 'pending-bun-shell') { + return spawnLocalPty({ sessionId: id, cols: 80, rows: 24 }, () => ({})) +} + +beforeEach(() => { + vi.clearAllMocks() + activate.mockImplementation(({ id, proc }) => { + ptyProcesses.set(id, proc) + return { id, pid: proc.pid } + }) +}) +afterEach(() => { + ptyProcesses.clear() + expect(pendingLocalPtySpawns.size).toBe(0) +}) + +describe('local PTY native spawn admission', () => { + it('aborts a pending receipt when the requesting client disconnects', async () => { + const controller = new AbortController() + spawn.mockImplementationOnce( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const result = spawnLocalPty( + { sessionId: 'disconnected-shell', cols: 80, rows: 24, signal: controller.signal }, + () => ({}) + ) + const rejected = expect(result).rejects.toThrow('client disconnected') + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + controller.abort(new Error('client disconnected')) + await rejected + expect(activate).not.toHaveBeenCalled() + }) + + it('reserves the same session until a delayed shell receipt is activated', async () => { + const proc = createProcess() + let release!: () => void + spawn.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({ process: proc, shellPath: '/bin/sh' }) + }) + ) + const first = start() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + const second = start() + await new Promise((resolve) => setImmediate(resolve)) + expect(spawn).toHaveBeenCalledOnce() + release() + expect(await first).toEqual({ id: 'pending-bun-shell', pid: proc.pid }) + expect(await second).toMatchObject({ id: 'pending-bun-shell', pid: proc.pid, isReattach: true }) + expect(activate).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() + }) + + it('aborts a pending receipt on shutdown and cancels queued same-session launches', async () => { + spawn.mockImplementationOnce( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const first = start() + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + const second = start() + const results = Promise.allSettled([first, second]) + cancelPendingLocalPtySpawns('pending-bun-shell') + expect(await results).toEqual([ + { status: 'rejected', reason: new Error('PTY spawn canceled: pending-bun-shell') }, + { status: 'rejected', reason: new Error('PTY spawn canceled: pending-bun-shell') } + ]) + expect(spawn).toHaveBeenCalledOnce() + expect(activate).not.toHaveBeenCalled() + }) + + it('cleans a confirmed process when shutdown races the receipt continuation', async () => { + const proc = createProcess() + let release!: () => void + spawn.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({ process: proc, shellPath: '/bin/sh' }) + }) + ) + const first = start() + const rejected = expect(first).rejects.toThrow('PTY spawn canceled: pending-bun-shell') + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + release() + cancelPendingLocalPtySpawns('pending-bun-shell') + await rejected + expect(proc.kill).toHaveBeenCalledWith('SIGKILL') + expect(destroy).toHaveBeenCalledWith(proc) + expect(activate).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index 9dfa08d81bf..cf523a469fc 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -53,6 +53,10 @@ vi.mock('node-pty', () => ({ spawn: spawnMock })) +vi.mock('../daemon/pty-subprocess/bun-pty-process-capabilities', () => ({ + canUseBunPty: () => false +})) + vi.mock('./macos-tcc-login-shell', async (importOriginal) => ({ ...(await importOriginal()), prepareMacosTccLoginShell: prepareMacosTccLoginShellMock @@ -115,6 +119,13 @@ vi.mock('../shell-prompt-readiness-probe', () => ({ })) import { LocalPtyProvider } from './local-pty-provider' +import { + pendingLocalPtySpawns, + ptyDisposables, + ptyExitDisposables, + ptyPhysicalExits, + startupIngressByPty +} from './local-pty-provider-state' import { applyLocalPtyProviderMockDefaults, createLocalPtyMockProcess, @@ -164,6 +175,42 @@ describe('LocalPtyProvider', () => { expect(typeof result.id).toBe('string') }) + it('retires buffered output and synchronous Bun exit before replying to spawn', async () => { + const disposeData = vi.fn() + const disposeExit = vi.fn() + const onExit = vi.fn() + provider.configure({ onExit }) + mockProc.onData.mockImplementation((listener: (data: string) => void) => { + listener('last output') + return { dispose: disposeData } + }) + mockProc.onExit.mockImplementation((listener: (event: { exitCode: number }) => void) => { + listener({ exitCode: 17 }) + return { dispose: disposeExit } + }) + const result = await provider.spawn({ + cols: 80, + rows: 24, + sessionId: 'already-exited-bun-shell', + command: 'must-not-run' + }) + expect(result.exitedBeforeSpawnReply).toBe(true) + expect(provider.getPtyProcess(result.id)).toBeUndefined() + for (const map of [ + ptyDisposables, + ptyExitDisposables, + ptyPhysicalExits, + startupIngressByPty + ]) { + expect(map.has(result.id)).toBe(false) + } + expect(disposeData).toHaveBeenCalledOnce() + expect(disposeExit).toHaveBeenCalledOnce() + expect(onExit).toHaveBeenCalledOnce() + await Promise.resolve() + expect(mockProc.write).not.toHaveBeenCalled() + }) + it('reattaches to an existing caller-supplied session id without spawning', async () => { const first = await provider.spawn({ cols: 80, rows: 24, sessionId: 'serve-session-1' }) spawnMock.mockClear() @@ -363,41 +410,154 @@ describe('LocalPtyProvider', () => { expect(spawnMock).not.toHaveBeenCalled() }) - it('registers post-build preflight before a nested-microtask shutdown', async () => { - spawnMock.mockClear() - let finishEnvBuild!: () => void - const envProvider = new LocalPtyProvider({ - buildSpawnEnv: (_id, baseEnv) => - new Promise>((resolve) => { - finishEnvBuild = () => resolve(baseEnv) - }) - }) - const spawn = envProvider.spawn({ - cols: 80, - rows: 24, - sessionId: 'resolved-env-build-session' - }) - const canceledSpawn = expect(spawn).rejects.toThrow( - 'PTY spawn canceled: resolved-env-build-session' - ) - await vi.waitFor(() => expect(finishEnvBuild).toBeTypeOf('function')) + it.each([1, 2])( + 'keeps cancellation registered across %i environment-resume microtasks', + async (microtasks) => { + spawnMock.mockClear() + let finishEnvBuild!: () => void + const envProvider = new LocalPtyProvider({ + buildSpawnEnv: (_id, baseEnv) => + new Promise>((resolve) => { + finishEnvBuild = () => resolve(baseEnv) + }) + }) + const spawn = envProvider.spawn({ + cols: 80, + rows: 24, + sessionId: 'resolved-env-build-session' + }) + const canceledSpawn = expect(spawn).rejects.toThrow( + 'PTY spawn canceled: resolved-env-build-session' + ) + await vi.waitFor(() => expect(finishEnvBuild).toBeTypeOf('function')) - finishEnvBuild() - const shutdown = new Promise((resolve, reject) => { - queueMicrotask(() => { - queueMicrotask(() => { + finishEnvBuild() + const shutdown = new Promise((resolve, reject) => { + const shutdown = () => { envProvider .shutdown('resolved-env-build-session', { immediate: true }) .then(resolve, reject) - }) + } + queueMicrotask(() => (microtasks === 1 ? shutdown() : queueMicrotask(shutdown))) }) - }) - await shutdown - await canceledSpawn - expect(spawnMock).not.toHaveBeenCalled() + await shutdown + await canceledSpawn + expect(spawnMock).not.toHaveBeenCalled() + } + ) + + it.each([1, 2])( + 'settles final-preflight shutdown without a late PTY (%i microtasks)', + async (microtasks) => { + spawnMock.mockClear() + let finishPreparation!: () => void + prepareMacosTccLoginShellMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishPreparation = resolve + }) + ) + const id = 'preflight-resume-session' + const kill = mockProc.kill + let committed = false + const outcome = provider + .spawn({ + cols: 80, + rows: 24, + sessionId: id, + onPtySpawnCommitted: () => { + committed = true + } + }) + .then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ ok: false as const, error }) + ) + await import('node-pty') + await vi.waitFor(() => expect(finishPreparation).toBeTypeOf('function')) + finishPreparation() + let committedAtShutdown = false + await new Promise((resolve, reject) => { + const shutdown = () => { + committedAtShutdown = committed + provider.shutdown(id, { immediate: true }).then(resolve, reject) + } + queueMicrotask(() => (microtasks === 1 ? shutdown() : queueMicrotask(shutdown))) + }) + const settled = await outcome + if (committedAtShutdown) { + expect(settled.ok).toBe(true) + expect(kill).toHaveBeenCalled() + } else { + expect(settled).toEqual({ ok: false, error: new Error(`PTY spawn canceled: ${id}`) }) + expect(spawnMock.mock.calls.length).toBe(0) + } + expect(provider.getPtyProcess(id)).toBeUndefined() + expect(pendingLocalPtySpawns.has(id)).toBe(false) + } + ) + + it('cancels deferred shell availability before finalizing a launch plan', async () => { + spawnMock.mockClear() + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + let finishAvailability!: (available: boolean) => void + const buildSpawnEnv = vi.fn((_id: string, env: Record) => env) + provider.configure({ + getWindowsShell: () => 'powershell.exe', + getWindowsPowerShellImplementation: () => 'auto', + pwshAvailable: () => + new Promise((resolve) => { + finishAvailability = resolve + }), + buildSpawnEnv + }) + const id = 'deferred-availability-session' + const spawn = provider.spawn({ cols: 80, rows: 24, sessionId: id }) + const rejected = expect(spawn).rejects.toThrow(`PTY spawn canceled: ${id}`) + await provider.shutdown(id, { immediate: true }) + finishAvailability(true) + await rejected + expect(buildSpawnEnv).not.toHaveBeenCalled() + expect(spawnMock.mock.calls.length).toBe(0) + expect(pendingLocalPtySpawns.has(id)).toBe(false) }) + it.each(['synchronous environment', 'async environment', 'preflight'])( + 'releases cancellation state after failed %s and allows a fresh retry', + async (phase) => { + spawnMock.mockClear() + const id = 'failed-preparation-session' + let fail = true + provider.configure({ + buildSpawnEnv: (_id, env) => { + if (fail && phase === 'synchronous environment') { + throw new Error('preparation failed') + } + if (fail && phase === 'async environment') { + return Promise.reject(new Error('preparation failed')) + } + return env + } + }) + if (phase === 'preflight') { + prepareMacosTccLoginShellMock.mockRejectedValueOnce(new Error('preparation failed')) + } + await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).rejects.toThrow( + 'preparation failed' + ) + expect(pendingLocalPtySpawns.has(id)).toBe(false) + expect(spawnMock.mock.calls.length).toBe(0) + await provider.shutdown(id, { immediate: true }) + fail = false + await expect(provider.spawn({ cols: 80, rows: 24, sessionId: id })).resolves.toMatchObject({ + id + }) + expect(spawnMock.mock.calls.length).toBe(1) + expect(pendingLocalPtySpawns.has(id)).toBe(false) + } + ) + it('coalesces a concurrent same-session-id spawn before launching a redundant shell (F3)', async () => { spawnMock.mockClear() const procA = { ...mockProc, pid: 1001 } diff --git a/src/main/providers/local-pty-provider-state.ts b/src/main/providers/local-pty-provider-state.ts index d38175c0d2b..10cef331a62 100644 --- a/src/main/providers/local-pty-provider-state.ts +++ b/src/main/providers/local-pty-provider-state.ts @@ -12,7 +12,7 @@ export type PtyShutdownOperation = { } export type PendingLocalPtySpawn = { - canceled: boolean + cancellation: AbortController } export type DataCallback = (payload: { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 8dad9843ab6..a8e2f9ce609 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -132,20 +132,18 @@ export class LocalPtyProvider implements IPtyProvider { async inspectProcess(id: string): Promise { const proc = ptyProcesses.get(id) const foregroundProcess = await getLocalPtyForegroundProcess(id) - // Both fields have to describe one PTY: cleanup plus reactivation across the await above would - // otherwise pair the old pane's identity with the replacement's children. The child read below - // is synchronous, so this recheck is the last point either answer can drift. + const childProcessEvidence = await inspectLocalPtyChildProcesses(id) + // Neither asynchronous inspection may publish a replacement pane's identity. if (ptyProcesses.get(id) !== proc) { return { foregroundProcess: null, - hasChildProcesses: false, + hasChildProcesses: true, childProcessEvidence: 'unverifiable' } } - const childProcessEvidence = inspectLocalPtyChildProcesses(id) return { foregroundProcess, - hasChildProcesses: childProcessEvidence === 'children', + hasChildProcesses: childProcessEvidence !== 'no-children', childProcessEvidence } } diff --git a/src/main/providers/local-pty-runtime-spawn.ts b/src/main/providers/local-pty-runtime-spawn.ts new file mode 100644 index 00000000000..7d3c26d90ad --- /dev/null +++ b/src/main/providers/local-pty-runtime-spawn.ts @@ -0,0 +1,44 @@ +import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' +import { spawnNativeDaemonPty } from '../daemon/pty-subprocess/native-pty-spawn' +import { + spawnShellWithFallback, + type ShellSpawnParams, + type ShellSpawnResult +} from './local-pty-utils' + +type LocalPtySpawn = ( + params: Omit & { signal?: AbortSignal } +) => ShellSpawnResult | Promise + +/** Degraded daemon routing uses the same runtime as the packaged host. */ +export async function loadLocalPtyRuntimeSpawn(): Promise { + if (!canUseBunPty()) { + const pty = await import('node-pty') + return (params) => spawnShellWithFallback({ ...params, ptySpawn: pty.spawn }) + } + if (process.platform === 'win32') { + return (params) => + spawnNativeDaemonPty({ + ...params, + spawnCwd: params.cwd, + windowsFallbackAttempts: params.windowsFallbackAttempts ?? [] + }) + } + return (params) => + spawnShellWithFallback({ + ...params, + ptySpawn(file, args = [], options = {}) { + if (!Array.isArray(args)) { + throw new Error('POSIX PTY arguments must be an array') + } + return spawnBunPty({ + file, + args, + cwd: options.cwd ?? params.cwd, + env: params.env, + cols: options.cols ?? params.cols, + rows: options.rows ?? params.rows + }) + } + }) +} diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts index 71c633483db..e89f0c43316 100644 --- a/src/main/providers/local-pty-session-activation.ts +++ b/src/main/providers/local-pty-session-activation.ts @@ -122,8 +122,11 @@ export function activateLocalPtySession(args: { if (onDataDisposable) { disposables.push(onDataDisposable) } + ptyDisposables.set(id, disposables) + let exitedBeforeSpawnReply = false const onExitDisposable = proc.onExit(({ exitCode, signal }) => { + exitedBeforeSpawnReply = true // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}; the // cause is built here, where the signal and the spawn's trustworthiness // are both still in hand. @@ -151,14 +154,18 @@ export function activateLocalPtySession(args: { } }) if (onExitDisposable) { - ptyExitDisposables.set(id, onExitDisposable) + if (exitedBeforeSpawnReply) { + onExitDisposable.dispose() + } else { + ptyExitDisposables.set(id, onExitDisposable) + } } - ptyDisposables.set(id, disposables) const startupCommandDeliveredByWrapper = spawn.command !== undefined && plan.shellReadyLaunch?.env[POSIX_SHELL_STARTUP_COMMAND_ENV] === spawn.command if ( + !exitedBeforeSpawnReply && spawn.command && !plan.startupCommandDeliveredInShellArgs && !startupCommandDeliveredByWrapper @@ -191,6 +198,7 @@ export function activateLocalPtySession(args: { id, incarnationId, pid, + ...(exitedBeforeSpawnReply ? { exitedBeforeSpawnReply: true } : {}), ...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {}) } } diff --git a/src/main/providers/local-pty-session-operations.ts b/src/main/providers/local-pty-session-operations.ts index 37e77081206..27a151580ce 100644 --- a/src/main/providers/local-pty-session-operations.ts +++ b/src/main/providers/local-pty-session-operations.ts @@ -3,6 +3,7 @@ import { basename } from 'node:path' import type * as pty from 'node-pty' import { readPtsName } from '../pty/node-pty-pts-name' import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' +import { ptyShellProcessId } from '../windows/windows-pty-job' import { isWslAvailableAsync } from '../wsl' import { resolveGitBashPath } from '../git-bash' import { resolveProcessCwd } from './process-cwd' @@ -74,6 +75,10 @@ export async function sendLocalPtySignal(id: string, signal: string): Promise { try { process.kill(proc.pid, signal) @@ -97,7 +102,8 @@ export async function getLocalPtyCwd(id: string): Promise { return '' } // Why: let resolveProcessCwd's '' surface for the renderer fallback chain; a fabricated cwd would short-circuit it. - return resolveProcessCwd(proc.pid) + const shellPid = ptyShellProcessId(proc) + return shellPid === undefined ? '' : resolveProcessCwd(shellPid) } export async function clearLocalPtyBuffer(id: string): Promise { diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 8290baa3dc3..47aaac6cef0 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -11,7 +11,6 @@ import { isWindowsGitBashShellPath } from '../git-bash' import { removeUnspecifiedPaneIdentityEnv } from './local-pty-launch-helpers' import type { LocalPtyLaunchPlan } from './local-pty-launch-plan' import type { LocalPtyProviderOptions } from './local-pty-provider-types' -import { awaitCancelableLocalPtySpawn } from './local-pty-spawn-state' import type { PtySpawnOptions } from './types' /** Pane ownership must be fresh even when Orca itself was launched inside an agent. */ @@ -58,21 +57,16 @@ export function buildLocalPtySpawnEnvironment(args: { if (!getOptions().buildSpawnEnv) { return spawnEnv } - // Why (#16441): building the env now awaits Codex hook installs and trust - // grants, so shutdown must be able to cancel this session id here too. - return awaitCancelableLocalPtySpawn( - id, - getOptions().buildSpawnEnv!(id, spawnEnv, { - explicitEnv: spawn.env ?? {}, - command: spawn.command, - launchAgent: spawn.launchAgent, - codexHomePathOverride: spawn.codexHomePathOverride, - cwd: plan.cwd, - shellPath: plan.shellPath, - isWsl: plan.isWslShell, - wslDistro: plan.launchWslDistro - }) - ) + return getOptions().buildSpawnEnv!(id, spawnEnv, { + explicitEnv: spawn.env ?? {}, + command: spawn.command, + launchAgent: spawn.launchAgent, + codexHomePathOverride: spawn.codexHomePathOverride, + cwd: plan.cwd, + shellPath: plan.shellPath, + isWsl: plan.isWslShell, + wslDistro: plan.launchWslDistro + }) } /** App-level env builders can reintroduce deleted keys; enforce isolation after they finish. */ diff --git a/src/main/providers/local-pty-spawn-state.ts b/src/main/providers/local-pty-spawn-state.ts index 2ab145f6c39..d73d230f68b 100644 --- a/src/main/providers/local-pty-spawn-state.ts +++ b/src/main/providers/local-pty-spawn-state.ts @@ -7,22 +7,34 @@ import { type PendingLocalPtySpawn } from './local-pty-provider-state' -/** Awaits pre-launch work that shutdown must be able to cancel: no node-pty - * process exists yet, so cancellation can only be observed after the await. */ -export async function awaitCancelableLocalPtySpawn( +const spawnReservations = new Map>() + +/** A Windows shell receipt can arrive after another request reaches the same native spawn. */ +export async function reserveLocalPtySpawn(id: string, operation: () => Promise): Promise { + const previous = spawnReservations.get(id) + const pending = previous ? previous.catch(() => {}).then(operation) : operation() + spawnReservations.set(id, pending) + try { + return await pending + } finally { + if (spawnReservations.get(id) === pending) { + spawnReservations.delete(id) + } + } +} + +/** Keep shutdown visible between awaits until the native process is registered. */ +export async function runCancelableLocalPtySpawn( id: string, - operation: T | Promise + operation: (throwIfCanceled: () => void, signal: AbortSignal) => Promise ): Promise { - const pendingSpawn: PendingLocalPtySpawn = { canceled: false } + const cancellation = new AbortController() + const pendingSpawn: PendingLocalPtySpawn = { cancellation } const pending = pendingLocalPtySpawns.get(id) ?? new Set() pending.add(pendingSpawn) pendingLocalPtySpawns.set(id, pending) try { - const result = await operation - if (pendingSpawn.canceled) { - throw new Error(`PTY spawn canceled: ${id}`) - } - return result + return await operation(() => cancellation.signal.throwIfAborted(), cancellation.signal) } finally { pending.delete(pendingSpawn) if (pending.size === 0) { @@ -37,7 +49,7 @@ export function cancelPendingLocalPtySpawns(id: string): void { return } for (const pendingSpawn of pending) { - pendingSpawn.canceled = true + pendingSpawn.cancellation.abort(new Error(`PTY spawn canceled: ${id}`)) } } diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 19436a8c405..cc3c39979a8 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto' import { win32 as pathWin32 } from 'node:path' -import * as pty from 'node-pty' import { SessionNotFoundError } from '../daemon/daemon-errors' import { prepareMacosTccLoginShell } from './macos-tcc-login-shell' import { finalizeLocalPtySpawnEnvironment } from './local-pty-finalize-environment' @@ -13,9 +12,14 @@ import { buildLocalPtySpawnEnvironment, enforceLocalPtySpawnEnvironmentOverrides } from './local-pty-spawn-environment' -import { awaitCancelableLocalPtySpawn, reattachLocalPty } from './local-pty-spawn-state' -import { spawnShellWithFallback } from './local-pty-utils' -import { updateHistoryEnvForFallback, type HistoryInjectionResult } from '../terminal-history' +import { + runCancelableLocalPtySpawn, + reattachLocalPty, + reserveLocalPtySpawn +} from './local-pty-spawn-state' +import { loadLocalPtyRuntimeSpawn } from './local-pty-runtime-spawn' +import { destroyPtyProcess } from './local-pty-termination' +import { updateHistoryEnvForFallback } from '../terminal-history' import type { PtySpawnOptions, PtySpawnResult } from './types' export async function spawnLocalPty( @@ -37,86 +41,108 @@ export async function spawnLocalPty( throw new SessionNotFoundError(args.sessionId ?? '') } const id = allocatePtyId(reattachId ?? undefined) - const incarnationId = randomUUID() - const planResult = createLocalPtyLaunchPlan(args, getOptions) - const plan = - planResult instanceof DeferredLocalPtyLaunchPlan - ? planResult.finish(await planResult.availability) - : planResult - const envResult = buildLocalPtySpawnEnvironment({ - id, - spawn: args, - getOptions, - plan - }) - const finalEnv = envResult instanceof Promise ? await envResult : envResult - enforceLocalPtySpawnEnvironmentOverrides(args, finalEnv) - const historyResult = finalizeLocalPtySpawnEnvironment({ - spawn: args, - getOptions, - plan, - env: finalEnv - }) + return runCancelableLocalPtySpawn(id, async (throwIfCanceled, cancellation) => { + const incarnationId = randomUUID() + let plan = createLocalPtyLaunchPlan(args, getOptions) + if (plan instanceof DeferredLocalPtyLaunchPlan) { + const available = await plan.availability + throwIfCanceled() + plan = plan.finish(available) + } + throwIfCanceled() + const envResult = buildLocalPtySpawnEnvironment({ + id, + spawn: args, + getOptions, + plan + }) + const finalEnv = envResult instanceof Promise ? await envResult : envResult + throwIfCanceled() + enforceLocalPtySpawnEnvironmentOverrides(args, finalEnv) + const historyResult = finalizeLocalPtySpawnEnvironment({ + spawn: args, + getOptions, + plan, + env: finalEnv + }) - // Why: the async macOS capability probe runs before node-pty exists. - await awaitCancelableLocalPtySpawn(id, prepareMacosTccLoginShell()) - if (args.signal?.aborted) { - throw new Error('client_disconnected') - } - // Why: another same-id request can win while this one awaits preflight; attach before launching a redundant shell. - const concurrentWinner = reattachId ? reattachLocalPty(id, args.cols, args.rows) : null - if (concurrentWinner) { - return concurrentWinner - } - const spawnResult = spawnShellWithFallback({ - shellPath: plan.shellPath, - shellArgs: plan.shellArgs, - cols: args.cols, - rows: args.rows, - cwd: plan.effectiveCwd, - env: finalEnv, - termName: finalEnv.TERM, - ptySpawn: pty.spawn, - getShellReadyConfig: plan.getFallbackShellReadyConfig, - launchEnvKeys: plan.primaryLaunchEnvKeys, - // Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8). - onBeforeFallbackSpawn: historyResult?.historyDir - ? (env, fallbackShell) => - updateHistoryEnvForFallback(env, fallbackShell, historyResult as HistoryInjectionResult) - : undefined, - windowsFallbackAttempts: plan.windowsFallbackAttempts - }) - args.onPtySpawnCommitted?.() - plan.shellPath = spawnResult.shellPath - // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. - if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { - plan.startupCommandDeliveredInShellArgs = spawnResult.startupCommandDeliveredInShellArgs - } - if (args.command && plan.getFallbackShellReadyConfig) { - plan.shellReadyLaunch = plan.getFallbackShellReadyConfig(plan.shellPath) - } + const fallbackHistory = historyResult?.historyDir ? historyResult : undefined + const [spawn] = await Promise.all([loadLocalPtyRuntimeSpawn(), prepareMacosTccLoginShell()]) + return reserveLocalPtySpawn(id, async () => { + const checkCanceled = (): void => { + throwIfCanceled() + if (args.signal?.aborted) { + throw new Error('client_disconnected') + } + } + checkCanceled() + // Why: another same-id request can win while this one awaits preflight; attach before launching a redundant shell. + const concurrentWinner = reattachId ? reattachLocalPty(id, args.cols, args.rows) : null + if (concurrentWinner) { + return concurrentWinner + } + const pendingSpawn = spawn({ + shellPath: plan.shellPath, + shellArgs: plan.shellArgs, + cols: args.cols, + rows: args.rows, + cwd: plan.effectiveCwd, + env: finalEnv, + termName: finalEnv.TERM, + signal: args.signal ? AbortSignal.any([args.signal, cancellation]) : cancellation, + getShellReadyConfig: plan.getFallbackShellReadyConfig, + launchEnvKeys: plan.primaryLaunchEnvKeys, + // Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8). + onBeforeFallbackSpawn: fallbackHistory + ? (env, fallbackShell) => updateHistoryEnvForFallback(env, fallbackShell, fallbackHistory) + : undefined, + windowsFallbackAttempts: plan.windowsFallbackAttempts + }) + const spawnResult = pendingSpawn instanceof Promise ? await pendingSpawn : pendingSpawn + try { + checkCanceled() + } catch (error) { + try { + spawnResult.process.kill('SIGKILL') + } finally { + destroyPtyProcess(spawnResult.process) + } + throw error + } + args.onPtySpawnCommitted?.() + plan.shellPath = spawnResult.shellPath + // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. + if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { + plan.startupCommandDeliveredInShellArgs = spawnResult.startupCommandDeliveredInShellArgs + } + if (args.command && plan.getFallbackShellReadyConfig) { + plan.shellReadyLaunch = plan.getFallbackShellReadyConfig(plan.shellPath) + } - if (process.platform !== 'win32') { - finalEnv.SHELL = plan.shellPath - } + if (process.platform !== 'win32') { + finalEnv.SHELL = plan.shellPath + } - const proc = spawnResult.process - const spawnedShellIsWsl = - process.platform === 'win32' && pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe' - const spawnedWslDistro = spawnedShellIsWsl - ? (plan.launchWslDistro ?? undefined) - : process.platform === 'win32' - ? null - : undefined - return activateLocalPtySession({ - id, - incarnationId, - spawn: args, - getOptions, - plan, - env: finalEnv, - proc, - reportsChildExitStatus: spawnResult.reportsChildExitStatus !== false, - spawnedWslDistro + const proc = spawnResult.process + const spawnedShellIsWsl = + process.platform === 'win32' && + pathWin32.basename(plan.shellPath).toLowerCase() === 'wsl.exe' + const spawnedWslDistro = spawnedShellIsWsl + ? (plan.launchWslDistro ?? undefined) + : process.platform === 'win32' + ? null + : undefined + return activateLocalPtySession({ + id, + incarnationId, + spawn: args, + getOptions, + plan, + env: finalEnv, + proc, + reportsChildExitStatus: spawnResult.reportsChildExitStatus !== false, + spawnedWslDistro + }) + }) }) } diff --git a/src/main/providers/windows-pty-job-membership.ts b/src/main/providers/windows-pty-job-membership.ts index 99bcf7ef2c2..7bb37323b39 100644 --- a/src/main/providers/windows-pty-job-membership.ts +++ b/src/main/providers/windows-pty-job-membership.ts @@ -1,5 +1,9 @@ import type { IPty } from 'node-pty' -import { isPtyJobOwnershipAvailable, listPtyJobProcessIds } from '../windows/windows-pty-job' +import { + isPtyJobOwnershipAvailable, + listPtyJobProcessIds, + ptyShellProcessId +} from '../windows/windows-pty-job' /** * Processes still running under a pane, or null when there is no answer. @@ -30,7 +34,14 @@ export function readWindowsPtyJobProcessIds( const membership = new Set(pids.filter((pid) => Number.isSafeInteger(pid) && pid > 0)) // Without the shell, a size-1 set would read as "shell alone, retire" when it // means the opposite. The forked probe this replaced refused the same way. - return membership.has(proc.pid) ? membership : null + const shellPid = ptyShellProcessId(proc) + if (shellPid === undefined || !membership.has(proc.pid) || !membership.has(shellPid)) { + return null + } + if (shellPid !== proc.pid) { + membership.delete(proc.pid) + } + return membership } /** diff --git a/src/main/pty/posix-pty-process-groups.test.ts b/src/main/pty/posix-pty-process-groups.test.ts index ef9acf05030..f0b3727c6ee 100644 --- a/src/main/pty/posix-pty-process-groups.test.ts +++ b/src/main/pty/posix-pty-process-groups.test.ts @@ -1,19 +1,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult } from '../../shared/child-process/run-process' -const { recordSelfInitiatedTreeKillMock } = vi.hoisted(() => ({ - recordSelfInitiatedTreeKillMock: vi.fn() +const { recordSelfInitiatedTreeKillMock, runProcessMock, runProcessSyncMock } = vi.hoisted(() => ({ + recordSelfInitiatedTreeKillMock: vi.fn(), + runProcessMock: vi.fn(), + runProcessSyncMock: vi.fn() })) vi.mock('../crash-reporting/self-initiated-tree-kill-log', () => ({ recordSelfInitiatedTreeKill: recordSelfInitiatedTreeKillMock })) +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: runProcessMock, + runProcessSync: runProcessSyncMock +})) import { forceKillPosixPtyProcessGroups, - getPosixPtyProcessGroups + getPosixPtyProcessGroups, + isPosixPtyRootStopped, + readPosixPtyProcessTable, + resetPosixPtyProcessTableDialectForTests, + signalPosixPtyProcessGroups } from './posix-pty-process-groups' beforeEach(() => { recordSelfInitiatedTreeKillMock.mockReset() + runProcessMock.mockReset() + runProcessSyncMock.mockReset() + resetPosixPtyProcessTableDialectForTests() }) const TABLE = ` @@ -25,6 +39,265 @@ const TABLE = ` 300 300 ?? ` +const ALL_PROCESS_ARGS = [ + '-e', + '-o', + 'pid=PROCESS_ID,pgid=PROCESS_GID,tty=TERMINAL_DEVICE_NUMBER,stat=PROCESS_STATE' +] +const BUSYBOX_TABLE = ` +PROCESS_ID PROCESS_GID TERMINAL_DEVICE_NUMBER +100 100 136,100 +101 101 136,100 +200 200 136,10 +201 201 136,10 +999 999 ? +` +const unsupportedSelection = (stderr = 'ps: unrecognized option: p\n'): ProcessResult => ({ + code: 1, + signal: null, + stdout: '', + stderr, + timedOut: false +}) + +describe('ps selection compatibility', () => { + it.each([ + ['p', 'ps: unrecognized option: p\nBusyBox v1.37\nUsage: ps'], + ['p', "ps: invalid option -- 'p'\n"], + ['p', 'ps: illegal option -- p\n'], + ['t', 'ps: unrecognized option: t\n'] + ])( + 'falls back after a rejected %s selector and caches only the dialect (%s)', + async (option, stderr) => { + if (option === 't') { + runProcessMock.mockResolvedValueOnce({ code: 0, stdout: '100 100 pts/100' }) + } + runProcessMock + .mockResolvedValueOnce(unsupportedSelection(stderr)) + .mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) + .mockResolvedValueOnce({ + code: 0, + stdout: BUSYBOX_TABLE.replace('201 201 136,10', '202 202 136,10') + }) + + const first = await readPosixPtyProcessTable(100) + const second = await readPosixPtyProcessTable(200) + expect(first).toBe(BUSYBOX_TABLE) + expect(getPosixPtyProcessGroups(first, 100, 999)).toEqual([101, 100]) + expect(getPosixPtyProcessGroups(second, 200, 999)).toEqual([202, 200]) + expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ + ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='], + ...(option === 't' ? [['-t', 'pts/100', '-o', 'pid=,pgid=,tty=,stat=']] : []), + ALL_PROCESS_ARGS, + ALL_PROCESS_ARGS + ]) + expect( + runProcessMock.mock.calls.every( + ([spec]) => spec.maxOutputBytes === 1048576 && spec.timeoutMs === 1000 + ) + ).toBe(true) + } + ) + + it('shares the initial unsupported probe across concurrent callers and cancels waiting independently', async () => { + let resolveProbe: (result: ProcessResult) => void = () => {} + runProcessMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + .mockResolvedValue({ code: 0, stdout: BUSYBOX_TABLE }) + const first = readPosixPtyProcessTable(100) + const second = readPosixPtyProcessTable(200) + const controller = new AbortController() + const cancelled = readPosixPtyProcessTable(300, controller.signal) + expect(runProcessMock).toHaveBeenCalledOnce() + controller.abort() + await expect(cancelled).rejects.toThrow() + resolveProbe(unsupportedSelection()) + + expect(getPosixPtyProcessGroups(await first, 100, 999)).toEqual([101, 100]) + expect(getPosixPtyProcessGroups(await second, 200, 999)).toEqual([201, 200]) + expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ + ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='], + ALL_PROCESS_ARGS, + ALL_PROCESS_ARGS + ]) + }) + + it('uses the cached async dialect for synchronous teardown with fresh membership', async () => { + runProcessMock + .mockResolvedValueOnce(unsupportedSelection()) + .mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) + await readPosixPtyProcessTable(100) + runProcessSyncMock.mockReturnValue({ + code: 0, + stdout: BUSYBOX_TABLE.replace('101 101 136,100', '102 102 136,100') + }) + const signalProcessGroup = vi.fn() + const fallback = vi.fn() + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'linux', + currentPid: 999, + signalProcessGroup + }) + expect(runProcessSyncMock.mock.calls.map(([spec]) => spec.args)).toEqual([ALL_PROCESS_ARGS]) + expect(signalProcessGroup.mock.calls).toEqual([[102], [100]]) + expect(fallback).not.toHaveBeenCalled() + }) + + it('discovers unsupported selection during teardown and shares it with async readers', async () => { + runProcessSyncMock + .mockReturnValueOnce(unsupportedSelection()) + .mockReturnValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) + const signalProcessGroup = vi.fn() + const fallback = vi.fn() + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'linux', + currentPid: 999, + signalProcessGroup + }) + expect(signalProcessGroup.mock.calls).toEqual([[101], [100]]) + expect(fallback).not.toHaveBeenCalled() + runProcessMock.mockResolvedValueOnce({ code: 0, stdout: BUSYBOX_TABLE }) + await readPosixPtyProcessTable(200) + expect(runProcessMock.mock.calls.map(([spec]) => spec.args)).toEqual([ALL_PROCESS_ARGS]) + }) + + it.each([ + { stderr: 'ps: permission denied' }, + { stderr: 'ps: unrecognized option: o' }, + { stderr: 'ps: unrecognized option: t' }, + { timedOut: true }, + { outputTruncated: true }, + { code: null, signal: 'SIGTERM' } + ])('does not turn an unrelated failure into a full-host scan: %j', async (failure) => { + runProcessMock.mockResolvedValueOnce({ ...unsupportedSelection(), ...failure }) + await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable') + expect(runProcessMock).toHaveBeenCalledOnce() + runProcessMock + .mockResolvedValueOnce({ code: 0, stdout: '100 100 ttys001' }) + .mockResolvedValueOnce({ code: 0, stdout: TABLE }) + await readPosixPtyProcessTable(100) + expect(runProcessMock.mock.calls[1][0].args).toEqual([ + '-p', + '100', + '-o', + 'pid=,pgid=,tty=,stat=' + ]) + }) + + it.each([{ code: 1 }, { code: 0, timedOut: true }, { code: 0, outputTruncated: true }])( + 'rejects incomplete fallback snapshots for async discovery and sync teardown: %j', + async (failure) => { + runProcessMock + .mockResolvedValueOnce(unsupportedSelection()) + .mockResolvedValueOnce({ stdout: BUSYBOX_TABLE, ...failure }) + await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable') + runProcessSyncMock.mockReturnValue({ stdout: BUSYBOX_TABLE, ...failure }) + const signalProcessGroup = vi.fn() + const fallback = vi.fn() + forceKillPosixPtyProcessGroups(100, fallback, { + platform: 'linux', + currentPid: 999, + signalProcessGroup + }) + expect(fallback).toHaveBeenCalledOnce() + expect(signalProcessGroup).not.toHaveBeenCalled() + } + ) + + it('does not cache a rejected selector if the caller has already cancelled', async () => { + const controller = new AbortController() + runProcessMock.mockImplementationOnce(async () => { + controller.abort() + return unsupportedSelection() + }) + await expect(readPosixPtyProcessTable(100, controller.signal)).rejects.toThrow() + expect(runProcessMock).toHaveBeenCalledOnce() + runProcessMock.mockResolvedValueOnce({ code: 0, stdout: '100 100 ?' }) + await readPosixPtyProcessTable(100) + expect(runProcessMock.mock.calls[1][0].args[0]).toBe('-p') + }) + + it.each(['?', '??', '-', '0', '0,0'])( + 'refuses to group processes without a controlling terminal (%s)', + (tty) => { + expect(getPosixPtyProcessGroups(`100 100 ${tty}\n101 101 ${tty}`, 100, 999)).toBeNull() + } + ) + + it('preserves full numeric terminal identity and the daemon terminal guard', () => { + expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 100, 999)).toEqual([101, 100]) + expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 200, 999)).toEqual([201, 200]) + expect(getPosixPtyProcessGroups(BUSYBOX_TABLE, 100, 101)).toBeNull() + }) +}) + +describe('asynchronous PTY process discovery', () => { + it('requires a stopped state for the exact shell process', () => { + expect(isPosixPtyRootStopped('100 100 pts/test Ts\n101 101 pts/test R+', 100)).toBe(true) + expect(isPosixPtyRootStopped('100 100 pts/test S\n101 101 pts/test T', 100)).toBe(false) + expect(isPosixPtyRootStopped('101 101 pts/test T', 100)).toBe(false) + expect(isPosixPtyRootStopped('100 100 pts/test\n101 101 pts/test T', 100)).toBe(false) + }) + + it('bounds each lookup and selects the root terminal without synchronous subprocesses', async () => { + const controller = new AbortController() + runProcessMock + .mockResolvedValueOnce({ code: 0, stdout: '100 100 ttys001' }) + .mockResolvedValueOnce({ code: 0, stdout: TABLE }) + + expect(await readPosixPtyProcessTable(100, controller.signal)).toBe(`100 100 ttys001\n${TABLE}`) + expect( + runProcessMock.mock.calls.map(([spec]) => [{ ...spec, env: { LC_ALL: spec.env.LC_ALL } }]) + ).toEqual([ + [ + { + program: 'ps', + env: { LC_ALL: 'C' }, + args: ['-p', '100', '-o', 'pid=,pgid=,tty=,stat='], + timeoutMs: 1000, + maxOutputBytes: 1048576, + signal: controller.signal + } + ], + [ + { + program: 'ps', + env: { LC_ALL: 'C' }, + args: ['-t', 'ttys001', '-o', 'pid=,pgid=,tty=,stat='], + timeoutMs: 1000, + maxOutputBytes: 1048576, + signal: controller.signal + } + ] + ]) + expect(runProcessSyncMock).not.toHaveBeenCalled() + }) + + it.each([{ code: 1 }, { code: 0, timedOut: true }, { code: 0, outputTruncated: true }])( + 'rejects incomplete process evidence: %j', + async (result) => { + runProcessMock.mockResolvedValue({ stdout: TABLE, ...result }) + await expect(readPosixPtyProcessTable(100)).rejects.toThrow('unavailable') + expect(runProcessMock).toHaveBeenCalledOnce() + } + ) + + it('does not start the second lookup after cancellation', async () => { + const controller = new AbortController() + runProcessMock.mockImplementation(async () => { + controller.abort() + return { code: 0, stdout: '100 100 ttys001' } + }) + await expect(readPosixPtyProcessTable(100, controller.signal)).rejects.toThrow() + expect(runProcessMock).toHaveBeenCalledOnce() + }) +}) + describe('POSIX PTY process-group termination', () => { it('returns every group attached to the root PTY with the root group last', () => { expect(getPosixPtyProcessGroups(TABLE, 100, 999)).toEqual([101, 103, 100]) @@ -51,6 +324,43 @@ describe('POSIX PTY process-group termination', () => { expect(fallback).not.toHaveBeenCalled() }) + it.each([ + ['SIGSTOP', [99, 101, 103]], + ['SIGCONT', [101, 103, 99]] + ] as const)('orders shell and job groups safely for %s', (signal, expected) => { + const signalProcessGroup = vi.fn() + signalPosixPtyProcessGroups(100, signal, vi.fn(), { + platform: 'linux', + currentPid: 999, + readProcessTable: () => TABLE.replace('100 100', '100 99'), + signalProcessGroup + }) + expect(signalProcessGroup.mock.calls.map(([pgid]) => pgid)).toEqual(expected) + }) + + it.each(['EPERM', 'ESRCH'])( + 'does not stop jobs when stopping the shell fails with %s', + (code) => { + const error = Object.assign(new Error('stop failed'), { code }) + const signalProcessGroup = vi.fn(() => { + throw error + }) + const stop = () => + signalPosixPtyProcessGroups(100, 'SIGSTOP', vi.fn(), { + platform: 'linux', + currentPid: 999, + readProcessTable: () => TABLE, + signalProcessGroup + }) + if (code === 'ESRCH') { + expect(stop).not.toThrow() + } else { + expect(stop).toThrow(error) + } + expect(signalProcessGroup.mock.calls).toEqual([[100]]) + } + ) + it('falls back when the process table cannot prove PTY ownership', () => { const fallback = vi.fn() diff --git a/src/main/pty/posix-pty-process-groups.ts b/src/main/pty/posix-pty-process-groups.ts index c34e7ff8123..1f7f05f73a1 100644 --- a/src/main/pty/posix-pty-process-groups.ts +++ b/src/main/pty/posix-pty-process-groups.ts @@ -1,13 +1,35 @@ -import { execFileSync } from 'node:child_process' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { + runProcess, + runProcessSync, + type ProcessResult +} from '../../shared/child-process/run-process' const PROCESS_TABLE_TIMEOUT_MS = 1_000 const PROCESS_TABLE_MAX_BYTES = 1024 * 1024 +const SELECTED_COLUMNS = 'pid=,pgid=,tty=,stat=' +// Explicit widths prevent BusyBox from truncating device numbers into another terminal's identity. +const ALL_PROCESS_ARGS = [ + '-e', + '-o', + 'pid=PROCESS_ID,pgid=PROCESS_GID,tty=TERMINAL_DEVICE_NUMBER,stat=PROCESS_STATE' +] +let psDialect: 'selected' | 'all' | undefined +let dialectProbe: Promise | undefined + +class UnsupportedPsSelectionError extends Error {} + +export function resetPosixPtyProcessTableDialectForTests(): void { + psDialect = undefined + dialectProbe = undefined +} type ProcessRow = { pid: number pgid: number tty: string + state?: string } export type PosixPtyProcessGroupTerminationDeps = { @@ -17,41 +39,149 @@ export type PosixPtyProcessGroupTerminationDeps = { signalProcessGroup?: (pgid: number) => void } -function runPs(args: string[]): string { - return execFileSync('ps', args, { - encoding: 'utf8', - timeout: PROCESS_TABLE_TIMEOUT_MS, - maxBuffer: PROCESS_TABLE_MAX_BYTES - }) +function readProcessTableResult(result: ProcessResult): string { + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error('PTY process table is unavailable') + } + return result.stdout +} + +function readSelectionResult(result: ProcessResult, option: 'p' | 't'): string { + const rejectedOption = + /^ps: (?:invalid|illegal|unrecognized) option(?: -- |: | )['"]?-?([pt])['"]?\s*$/m.exec( + result.stderr ?? '' + )?.[1] + if ( + result.code !== null && + result.code !== 0 && + !result.signal && + !result.timedOut && + !result.outputTruncated && + rejectedOption === option + ) { + throw new UnsupportedPsSelectionError() + } + const output = readProcessTableResult(result) + if (psDialect === 'all') { + throw new UnsupportedPsSelectionError() + } + return output +} + +function hasControllingTty(tty: string): boolean { + return tty !== '?' && tty !== '??' && tty !== '-' && tty !== '0' && !/^0,\d+$/.test(tty) +} + +function* processTableQueries(rootPid: number): Generator { + if (psDialect !== 'all') { + try { + const root = readSelectionResult(yield ['-p', String(rootPid), '-o', SELECTED_COLUMNS], 'p') + const rootRow = parseProcessRows(root).find((row) => row.pid === rootPid) + if (!rootRow || !hasControllingTty(rootRow.tty)) { + return root + } + const terminal = readSelectionResult(yield ['-t', rootRow.tty, '-o', SELECTED_COLUMNS], 't') + psDialect ??= 'selected' + return `${root}\n${terminal}` + } catch (error) { + if (!(error instanceof UnsupportedPsSelectionError)) { + throw error + } + psDialect = 'all' + } + } + return readProcessTableResult(yield ALL_PROCESS_ARGS) +} + +function processTableSpec(args: string[]) { + return { + program: 'ps', + args, + env: { ...process.env, LC_ALL: 'C' }, + timeoutMs: PROCESS_TABLE_TIMEOUT_MS, + maxOutputBytes: PROCESS_TABLE_MAX_BYTES + } } function readPtyProcessTable(rootPid: number): string { - const root = runPs(['-p', String(rootPid), '-o', 'pid=,pgid=,tty=']) - const rootRow = parseProcessRows(root).find((row) => row.pid === rootPid) - if (!rootRow || rootRow.tty === '?' || rootRow.tty === '??') { - return root + const queries = processTableQueries(rootPid) + let next = queries.next() + while (!next.done) { + next = queries.next(runProcessSync(processTableSpec(next.value))) + } + return next.value +} + +export async function readPosixPtyProcessTable( + rootPid: number, + signal?: AbortSignal +): Promise { + while (dialectProbe) { + await waitForPromiseWithSignal(dialectProbe, signal) + } + signal?.throwIfAborted() + let releaseProbe: (() => void) | undefined + if (psDialect === undefined) { + dialectProbe = new Promise((resolve) => { + releaseProbe = resolve + }) + } + try { + const queries = processTableQueries(rootPid) + let next = queries.next() + while (!next.done) { + signal?.throwIfAborted() + const result = await runProcess({ ...processTableSpec(next.value), signal }) + signal?.throwIfAborted() + next = queries.next(result) + } + return next.value + } finally { + if (releaseProbe) { + dialectProbe = undefined + releaseProbe() + } } - // Why: a whole-host `ps -ax` takes nearly a second on large machines. TTY - // selection keeps forced terminal teardown proportional to one terminal. - return `${root}\n${runPs(['-t', rootRow.tty, '-o', 'pid=,pgid=,tty='])}` } function parseProcessRows(output: string): ProcessRow[] { const rows: ProcessRow[] = [] for (const line of output.split(/\r?\n/)) { - const match = /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line) + const match = /^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(\S+))?/.exec(line) if (!match) { continue } const pid = Number(match[1]) const pgid = Number(match[2]) if (pid > 0 && pgid > 1) { - rows.push({ pid, pgid, tty: match[3] }) + rows.push({ pid, pgid, tty: match[3], state: match[4] }) } } return rows } +export function isPosixPtyRootStopped(output: string, rootPid: number): boolean { + return ( + parseProcessRows(output) + .find((row) => row.pid === rootPid) + ?.state?.startsWith('T') === true + ) +} + +/** The root was stopped by flow control; preserve independently stopped jobs. */ +export function getPosixPtyStoppedJobGroups(output: string, rootPid: number): Set { + const rows = parseProcessRows(output) + const root = rows.find((row) => row.pid === rootPid) + return new Set( + rows + .filter( + (row) => + root && row.tty === root.tty && row.pgid !== root.pgid && /^[Tt]/.test(row.state ?? '') + ) + .map((row) => row.pgid) + ) +} + export function getPosixPtyProcessGroups( output: string, rootPid: number, @@ -59,7 +189,7 @@ export function getPosixPtyProcessGroups( ): number[] | null { const rows = parseProcessRows(output) const root = rows.find((row) => row.pid === rootPid) - if (!root || root.tty === '?' || root.tty === '??') { + if (!root || !hasControllingTty(root.tty)) { return null } // Why: a development daemon can inherit its launch TTY. Never group-signal @@ -91,6 +221,16 @@ export function forceKillPosixPtyProcessGroups( rootPid: number, fallback: () => void, deps: PosixPtyProcessGroupTerminationDeps = {} +): void { + signalPosixPtyProcessGroups(rootPid, 'SIGKILL', fallback, deps) +} + +/** Signal every process group proven to belong to one POSIX PTY. */ +export function signalPosixPtyProcessGroups( + rootPid: number, + signal: NodeJS.Signals, + fallback: () => void, + deps: PosixPtyProcessGroupTerminationDeps = {} ): void { if ((deps.platform ?? process.platform) === 'win32') { fallback() @@ -110,14 +250,24 @@ export function forceKillPosixPtyProcessGroups( fallback() return } + if (signal === 'SIGSTOP') { + // Stop the shell before its jobs so it cannot treat their suspension as completion. + groups.unshift(...groups.splice(-1)) + } const signalProcessGroup = - deps.signalProcessGroup ?? ((pgid: number) => process.kill(-pgid, 'SIGKILL')) + deps.signalProcessGroup ?? ((pgid: number) => process.kill(-pgid, signal)) let firstError: unknown for (const pgid of groups) { try { signalProcessGroup(pgid) } catch (error) { + if (signal === 'SIGSTOP' && pgid === groups[0]) { + if (isProcessAlreadyGone(error)) { + return + } + throw error + } // Why: the PTY exit callback may reap a group between `ps` and killpg. // ESRCH is proof that this captured owner is already gone, not failure. if (!isProcessAlreadyGone(error) && firstError === undefined) { @@ -127,11 +277,13 @@ export function forceKillPosixPtyProcessGroups( } // Outside the try: this catch is the ESRCH contract, and a throw from the // breadcrumb path would be rethrown as a failed kill. - recordSelfInitiatedTreeKill({ - pid: pgid, - site: 'posix-pty-process-group-sweep', - scope: 'posix-process-group' - }) + if (signal === 'SIGKILL') { + recordSelfInitiatedTreeKill({ + pid: pgid, + site: 'posix-pty-process-group-sweep', + scope: 'posix-process-group' + }) + } } if (firstError !== undefined) { throw firstError diff --git a/src/main/sqlite/bun-readonly-wal.test.ts b/src/main/sqlite/bun-readonly-wal.test.ts new file mode 100644 index 00000000000..f60482c8e0e --- /dev/null +++ b/src/main/sqlite/bun-readonly-wal.test.ts @@ -0,0 +1,180 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { initializeBunReadonlyWal } from './bun-readonly-wal' +import Database from './sync-database' + +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal() + return { ...original, openSync: vi.fn(original.openSync) } +}) + +const directories: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + const original = await vi.importActual('node:fs') + vi.mocked(fs.openSync).mockImplementation(original.openSync) + for (const directory of directories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(wal = true): string { + const directory = fs.mkdtempSync(join(tmpdir(), 'orca-readonly-wal-')) + directories.push(directory) + const file = join(directory, 'state.db') + const db = new Database(file) + db.exec('CREATE TABLE state (id INTEGER PRIMARY KEY, value TEXT)') + if (wal) { + db.pragma('journal_mode=WAL') + } + db.close() + return file +} + +describe('readonly WAL initialization', () => { + it.each(['EACCES', 'EPERM', 'EROFS'])( + 'lets SQLite decide read access when WAL creation fails with %s', + async (code) => { + const file = fixture() + const { openSync: open } = await vi.importActual('node:fs') + vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => { + if (path === `${file}-wal` && flags === 'wx') { + throw Object.assign(new Error('read-only directory'), { code }) + } + return open(path, flags, ...rest) + }) + expect(() => initializeBunReadonlyWal(file)).not.toThrow() + expect(fs.existsSync(`${file}-wal`)).toBe(false) + } + ) + + it('still reports unexpected WAL creation failures', async () => { + const file = fixture() + const { openSync: open } = await vi.importActual('node:fs') + vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => { + if (path === `${file}-wal` && flags === 'wx') { + throw Object.assign(new Error('disk failure'), { code: 'EIO' }) + } + return open(path, flags, ...rest) + }) + expect(() => initializeBunReadonlyWal(file)).toThrow('disk failure') + }) + + it('allows a clean WAL database to reopen without changing its bytes or admitting SQL writes', () => { + const file = fixture() + const bytes = fs.readFileSync(file) + initializeBunReadonlyWal(file) + if (process.platform !== 'win32') { + expect(fs.statSync(`${file}-wal`).mode & 0o777).toBe(0o600) + } + const reader = new Database(file, { readonly: true }) + try { + expect(reader.prepare('SELECT COUNT(*) AS count FROM state').get()).toEqual({ count: 0 }) + expect(() => reader.exec("INSERT INTO state VALUES(1,'write')")).toThrow() + } finally { + reader.close() + } + expect(fs.readFileSync(file)).toEqual(bytes) + }) + + it('leaves a concurrent writer’s newly published WAL intact', async () => { + const file = fixture() + const { openSync: open } = await vi.importActual('node:fs') + vi.mocked(fs.openSync).mockImplementation((path, flags, ...rest) => { + if (path === `${file}-wal` && flags === 'wx') { + fs.writeFileSync(path, 'concurrent writer evidence') + } + return open(path, flags, ...rest) + }) + initializeBunReadonlyWal(file) + expect(fs.readFileSync(`${file}-wal`, 'utf8')).toBe('concurrent writer evidence') + }) + + it('leaves a current writer’s WAL bytes intact', () => { + const file = fixture() + const writer = new Database(file) + try { + writer.exec("INSERT INTO state VALUES(1,'durable')") + const before = fs.readFileSync(`${file}-wal`) + initializeBunReadonlyWal(file) + expect(fs.readFileSync(`${file}-wal`)).toEqual(before) + const reader = new Database(file, { readonly: true }) + try { + expect(reader.prepare('SELECT value FROM state').get()).toEqual({ value: 'durable' }) + } finally { + reader.close() + } + } finally { + writer.close() + } + }) + + it.skipIf(process.platform === 'win32')('opens a cold WAL database through a symlink', () => { + const file = fixture() + const alias = join(directories[0]!, 'alias.db') + fs.symlinkSync(file, alias) + const reader = new Database(alias, { readonly: true }) + try { + expect(reader.prepare('SELECT COUNT(*) AS count FROM state').get()).toEqual({ count: 0 }) + expect(fs.existsSync(`${alias}-wal`)).toBe(false) + } finally { + reader.close() + } + }) + + it('does not add recovery evidence to a rollback-journal database or malformed file', () => { + const file = fixture(false) + initializeBunReadonlyWal(file) + expect(fs.existsSync(`${file}-wal`)).toBe(false) + fs.writeFileSync(file, 'not SQLite') + initializeBunReadonlyWal(file) + expect(fs.existsSync(`${file}-wal`)).toBe(false) + }) + + it.skipIf(process.platform !== 'darwin' || !process.versions.bun)( + 'preserves another connection’s exclusive lock while inspecting the database header', + async () => { + const file = fixture(false) + const writer = new Database(file) + const probe = () => + runProcess({ + program: process.execPath, + args: [ + '-e', + `const { Database } = require('bun:sqlite') + const db = new Database(process.argv[1], { readonly: true }) + try { db.prepare('SELECT * FROM state').all(); process.stdout.write('readable') } + catch (error) { process.stdout.write(error.code) } + finally { db.close(true) }`, + file + ], + timeoutMs: 5_000 + }) + try { + writer.exec("BEGIN EXCLUSIVE; INSERT INTO state VALUES(1,'uncommitted')") + expect(await probe()).toMatchObject({ code: 0, stdout: 'SQLITE_BUSY' }) + const reader = new Database(file, { readonly: true }) + reader.close() + expect(await probe()).toMatchObject({ code: 0, stdout: 'SQLITE_BUSY' }) + } finally { + writer.close() + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'does not follow an existing dangling WAL symlink', + () => { + const file = fixture() + const outside = join(directories[0]!, 'unrelated') + fs.symlinkSync(outside, `${file}-wal`) + initializeBunReadonlyWal(file) + expect(fs.existsSync(outside)).toBe(false) + expect(fs.lstatSync(`${file}-wal`).isSymbolicLink()).toBe(true) + } + ) +}) diff --git a/src/main/sqlite/bun-readonly-wal.ts b/src/main/sqlite/bun-readonly-wal.ts new file mode 100644 index 00000000000..b7cbd39880a --- /dev/null +++ b/src/main/sqlite/bun-readonly-wal.ts @@ -0,0 +1,37 @@ +import { closeSync, existsSync, openSync, readSync } from 'node:fs' + +/** Match SQLite's WAL creation without permitting writes through the database connection. */ +export function initializeBunReadonlyWal(path: string): void { + const wal = `${path}-wal` + if (existsSync(wal) || !hasWalHeader(path)) { + return + } + try { + // Apple's SQLite requires an existing WAL; exclusive creation preserves every existing byte. + closeSync(openSync(wal, 'wx', 0o600)) + } catch (error) { + if ( + typeof error !== 'object' || + error === null || + !('code' in error) || + !['EEXIST', 'EACCES', 'EPERM', 'EROFS'].includes(String(error.code)) + ) { + throw error + } + } +} + +function hasWalHeader(path: string): boolean { + const file = openSync(path, 'r') + try { + const header = Buffer.alloc(20) + return ( + readSync(file, header, 0, header.length, 0) === header.length && + header.subarray(0, 16).toString() === 'SQLite format 3\0' && + header[18] === 2 && + header[19] === 2 + ) + } finally { + closeSync(file) + } +} diff --git a/src/main/sqlite/bun-sqlite-database.ts b/src/main/sqlite/bun-sqlite-database.ts new file mode 100644 index 00000000000..b9695e08781 --- /dev/null +++ b/src/main/sqlite/bun-sqlite-database.ts @@ -0,0 +1,129 @@ +import type { DatabaseSync } from 'node:sqlite' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { BunSqliteStatement, type BunStatement } from './bun-sqlite-statement' +import { initializeBunReadonlyWal } from './bun-readonly-wal' + +type BunDatabase = { + exec(sql: string): void + prepare(sql: string): BunStatement + readonly inTransaction: boolean + close(throwOnError: boolean): void + fileControl(command: number, value: Int32Array): number +} + +type BunSqlite = { + Database: new (path: string, flags: number) => BunDatabase +} + +const SQLITE_OPEN_READONLY = 0x01 +const SQLITE_OPEN_READWRITE = 0x02 +const SQLITE_OPEN_CREATE = 0x04 +const SQLITE_OPEN_URI = 0x40 +const SQLITE_FCNTL_PERSIST_WAL = 10 + +export function loadBunSqlite(): BunSqlite | undefined { + if (!process.versions.bun || typeof process.getBuiltinModule !== 'function') { + return undefined + } + const sqlite: unknown = process.getBuiltinModule('bun:sqlite') + return isBunSqlite(sqlite) ? sqlite : undefined +} + +function isBunSqlite(value: unknown): value is BunSqlite { + return ( + typeof value === 'object' && + value !== null && + 'Database' in value && + typeof value.Database === 'function' + ) +} + +export class BunSqliteDatabase { + private readonly database: BunDatabase + + constructor( + path: ConstructorParameters[0], + options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {} + ) { + const sqlite = loadBunSqlite() + if (!sqlite) { + throw new Error('SQLite is unavailable in this runtime') + } + const filename = + path instanceof URL ? fileURLToPath(path) : typeof path === 'string' ? path : path.toString() + const flags = options.readonly + ? SQLITE_OPEN_READONLY + : SQLITE_OPEN_READWRITE | (options.fileMustExist ? 0 : SQLITE_OPEN_CREATE) + this.database = new sqlite.Database( + filename === ':memory:' || filename === '' ? filename : sqliteFileUri(filename), + // URI parsing defaults differ between the platform SQLite libraries. + flags | SQLITE_OPEN_URI + ) + try { + if (process.platform === 'darwin' && filename !== ':memory:' && filename !== '') { + // Apple's default retains WAL files; match the other shipped SQLite drivers. + if (this.database.fileControl(SQLITE_FCNTL_PERSIST_WAL, new Int32Array(2)) !== 0) { + throw new Error('SQLite cannot configure WAL cleanup') + } + if (options.readonly) { + const statement = this.database.prepare('PRAGMA database_list') + try { + const path = statement.get()?.file + if (typeof path !== 'string' || path.length === 0) { + throw new Error('SQLite did not report its database filename') + } + // SQLite resolves symlinks before locating its sidecars. + initializeBunReadonlyWal(path) + } finally { + statement.finalize() + } + } + } + const timeout = options.timeout ?? 0 + if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 2_147_483_647) { + throw new RangeError('SQLite busy timeout must be a nonnegative 32-bit integer') + } + this.database.exec(`PRAGMA foreign_keys = ON; PRAGMA busy_timeout = ${timeout}`) + } catch (error) { + this.database.close(true) + throw error + } + } + + exec(sql: string): void { + this.database.exec(sql) + } + + prepare(sql: string): BunSqliteStatement { + return new BunSqliteStatement(this.database.prepare(sql), () => this.database.prepare(sql)) + } + + get isTransaction(): boolean { + return this.database.inTransaction + } + + /** Logical snapshot; implicit rowids may change. The caller runs this in its backup worker. */ + backup(path: string): void { + const statement = this.database.prepare('VACUUM INTO ?') + try { + statement.run(sqliteFileUri(path)) + } finally { + statement.finalize() + } + } + + close(): void { + this.database.close(true) + } +} + +function sqliteFileUri(path: string): string { + const url = pathToFileURL(path) + if (url.hostname) { + const hostname = url.hostname + url.hostname = '' + // SQLite accepts UNC paths with an empty URI authority on Windows. + url.pathname = `//${hostname}${url.pathname}` + } + return url.href +} diff --git a/src/main/sqlite/bun-sqlite-statement.ts b/src/main/sqlite/bun-sqlite-statement.ts new file mode 100644 index 00000000000..01b8081721a --- /dev/null +++ b/src/main/sqlite/bun-sqlite-statement.ts @@ -0,0 +1,77 @@ +import type { SQLInputValue, StatementResultingChanges } from 'node:sqlite' +import { SqliteIntegerReader } from './sqlite-integer-reader' +import type { SqliteBindings, SqliteRow, SqliteStatement } from './sqlite-statement' + +type BunBindings = (SQLInputValue | SQLInputValue[])[] +const EMPTY_BINDINGS: BunBindings = [[]] + +export type BunStatement = { + all(...parameters: BunBindings): SqliteRow[] + get(...parameters: BunBindings): SqliteRow | null + run(...parameters: BunBindings): StatementResultingChanges + iterate(...parameters: BunBindings): IterableIterator + finalize(): void + safeIntegers(enabled: boolean): void + readonly paramsCount: number +} + +export class BunSqliteStatement implements SqliteStatement { + private readonly integers = new SqliteIntegerReader() + private readonly parameterCount: number + + constructor( + private readonly statement: BunStatement, + private readonly prepareIterator: () => BunStatement + ) { + statement.safeIntegers(true) + this.parameterCount = statement.paramsCount + } + + all(...parameters: SqliteBindings): SqliteRow[] { + const rows = this.statement.all(...this.bindings(parameters)) + for (const row of rows) { + this.integers.row(row) + } + return rows + } + + get(...parameters: SqliteBindings): SqliteRow | undefined { + const row = this.statement.get(...this.bindings(parameters)) + return row === null ? undefined : this.integers.row(row) + } + + run(...parameters: SqliteBindings): StatementResultingChanges { + return this.integers.result(this.statement.run(...this.bindings(parameters))) + } + + *iterate(...parameters: SqliteBindings): IterableIterator { + // Bun leaves interrupted iterators positioned on their last row and exposes no reset. + const statement = this.prepareIterator() + try { + statement.safeIntegers(true) + for (const row of statement.iterate(...this.bindings(parameters))) { + yield this.integers.row(row) + } + } finally { + statement.finalize() + } + } + + setReadBigInts(enabled: boolean): void { + this.integers.readBigInts = enabled + } + + private bindings(parameters: SqliteBindings): BunBindings { + if (parameters.some((value) => value === undefined)) { + throw new TypeError('Undefined cannot be bound to a SQLite parameter') + } + // No arguments would reuse the driver's previous bindings. + if (parameters.length === 0) { + return EMPTY_BINDINGS + } + if (parameters.length >= this.parameterCount) { + return parameters + } + return [...parameters, ...Array(this.parameterCount - parameters.length).fill(null)] + } +} diff --git a/src/main/sqlite/node-sqlite-statement.ts b/src/main/sqlite/node-sqlite-statement.ts new file mode 100644 index 00000000000..53a83108086 --- /dev/null +++ b/src/main/sqlite/node-sqlite-statement.ts @@ -0,0 +1,39 @@ +import type { StatementResultingChanges } from 'node:sqlite' +import { SqliteIntegerReader } from './sqlite-integer-reader' +import type { SqliteBindings, SqliteRow, SqliteStatement } from './sqlite-statement' + +export class NodeSqliteStatement implements SqliteStatement { + private readonly integers = new SqliteIntegerReader() + + constructor(private readonly statement: SqliteStatement) { + // Native number reads overflow their INT64_MIN guard on some builds and round insert rowids. + statement.setReadBigInts(true) + } + + all(...parameters: SqliteBindings): SqliteRow[] { + const rows = this.statement.all(...parameters) + for (const row of rows) { + this.integers.row(row) + } + return rows + } + + get(...parameters: SqliteBindings): SqliteRow | undefined { + const row = this.statement.get(...parameters) + return row === undefined ? undefined : this.integers.row(row) + } + + run(...parameters: SqliteBindings): StatementResultingChanges { + return this.integers.result(this.statement.run(...parameters)) + } + + *iterate(...parameters: SqliteBindings): IterableIterator { + for (const row of this.statement.iterate(...parameters)) { + yield this.integers.row(row) + } + } + + setReadBigInts(enabled: boolean): void { + this.integers.readBigInts = enabled + } +} diff --git a/src/main/sqlite/sqlite-integer-reader.ts b/src/main/sqlite/sqlite-integer-reader.ts new file mode 100644 index 00000000000..e3a5821bcda --- /dev/null +++ b/src/main/sqlite/sqlite-integer-reader.ts @@ -0,0 +1,40 @@ +import type { StatementResultingChanges } from 'node:sqlite' +import type { SqliteRow } from './sqlite-statement' + +export class SqliteIntegerReader { + readBigInts = false + + row(row: SqliteRow): SqliteRow { + if (!this.readBigInts) { + for (const key of Object.keys(row)) { + const value = row[key] + if (typeof value === 'bigint') { + row[key] = this.integer(value) + } + } + } + return row + } + + result(result: StatementResultingChanges): StatementResultingChanges { + return { + changes: this.integer(result.changes, false), + lastInsertRowid: this.integer(result.lastInsertRowid, false) + } + } + + private integer(value: number | bigint, requireSafeNumber = true): number | bigint { + if (this.readBigInts) { + return BigInt(value) + } + const number = Number(value) + if (!Number.isSafeInteger(number)) { + // Metadata conversion must not report failure after a write has committed. + if (!requireSafeNumber) { + return BigInt(value) + } + throw new RangeError('SQLite integer cannot be represented safely as a JavaScript number') + } + return number + } +} diff --git a/src/main/sqlite/sqlite-read-failure.test.ts b/src/main/sqlite/sqlite-read-failure.test.ts index 8bd43d54eaa..fdb3dc88365 100644 --- a/src/main/sqlite/sqlite-read-failure.test.ts +++ b/src/main/sqlite/sqlite-read-failure.test.ts @@ -10,13 +10,12 @@ import { classifySqliteReadFailure, isTransientSqliteContention } from './sqlite // with no usable -shm reports errcode 14 ("unable to open database file"). The // two need opposite responses, so the classifier must never conflate them. -let tempDirs: string[] = [] +const tempDirs: string[] = [] afterEach(() => { - for (const dir of tempDirs) { + for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) } - tempDirs = [] }) function contendedDatabase(): { path: string; release: () => void } { @@ -40,18 +39,22 @@ function contendedDatabase(): { path: string; release: () => void } { describe('isTransientSqliteContention', () => { it('recognizes a real SQLITE_BUSY thrown by a read-only open', () => { const contended = contendedDatabase() + let reader: SyncDatabase | undefined let thrown: unknown try { - new SyncDatabase(contended.path, { readonly: true, timeout: 0 }) - .prepare('SELECT id FROM session') - .all() + reader = new SyncDatabase(contended.path, { readonly: true, timeout: 0 }) + reader.prepare('SELECT id FROM session').all() } catch (error) { thrown = error } finally { - contended.release() + try { + reader?.close() + } finally { + contended.release() + } } - expect((thrown as { errcode?: number }).errcode).toBe(5) + expect(thrown).toMatchObject({ [process.versions.bun ? 'errno' : 'errcode']: 5 }) expect(isTransientSqliteContention(thrown)).toBe(true) }) diff --git a/src/main/sqlite/sqlite-read-failure.ts b/src/main/sqlite/sqlite-read-failure.ts index f96176eee0a..6cdb75f343a 100644 --- a/src/main/sqlite/sqlite-read-failure.ts +++ b/src/main/sqlite/sqlite-read-failure.ts @@ -13,10 +13,10 @@ const SQLITE_CANTOPEN = 14 const CONTENTION_MESSAGE = /SQLITE_(?:BUSY|LOCKED)|database (?:is )?(?:busy|locked)/i function primaryErrcode(error: unknown): number | null { - if (!error || typeof error !== 'object' || !('errcode' in error)) { + if (!error || typeof error !== 'object') { return null } - const errcode = (error as { errcode?: unknown }).errcode + const errcode = 'errcode' in error ? error.errcode : 'errno' in error ? error.errno : undefined return typeof errcode === 'number' && Number.isFinite(errcode) ? errcode & 0xff : null } diff --git a/src/main/sqlite/sqlite-statement.ts b/src/main/sqlite/sqlite-statement.ts new file mode 100644 index 00000000000..32d1e183be0 --- /dev/null +++ b/src/main/sqlite/sqlite-statement.ts @@ -0,0 +1,12 @@ +import type { SQLInputValue, SQLOutputValue, StatementResultingChanges } from 'node:sqlite' + +export type SqliteBindings = SQLInputValue[] +export type SqliteRow = Record + +export type SqliteStatement = { + all(...parameters: SqliteBindings): SqliteRow[] + get(...parameters: SqliteBindings): SqliteRow | undefined + run(...parameters: SqliteBindings): StatementResultingChanges + iterate(...parameters: SqliteBindings): IterableIterator + setReadBigInts(enabled: boolean): void +} diff --git a/src/main/sqlite/sync-database-concurrent-backup.test.ts b/src/main/sqlite/sync-database-concurrent-backup.test.ts new file mode 100644 index 00000000000..94b6221e1d6 --- /dev/null +++ b/src/main/sqlite/sync-database-concurrent-backup.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { expect, it } from 'vitest' +import SyncDatabase from './sync-database' + +const WRITER_SOURCE = ` + const { parentPort, workerData } = require('node:worker_threads') + const Database = process.versions.bun + ? require('bun:sqlite').Database + : require('node:sqlite').DatabaseSync + const db = new Database(workerData.path) + db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL') + const count = new Int32Array(workerData.count) + let revision = 0 + function commit() { + revision += 1 + db.exec('BEGIN IMMEDIATE; UPDATE marker SET revision=' + revision + '; COMMIT') + Atomics.store(count, 0, revision) + if (revision === 1) { + parentPort.once('message', commit) + parentPort.postMessage('writing') + return + } + if (revision < 40) setTimeout(commit, 2) + else { db.close(true); parentPort.close() } + } + commit() +` + +it('backs up one complete revision while another thread commits to the WAL', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-sqlite-concurrent-backup-')) + const path = join(directory, 'source.db') + const target = join(directory, 'snapshot.db') + const source = new SyncDatabase(path) + let writer: Worker | undefined + let snapshot: SyncDatabase | undefined + try { + source.exec(` + PRAGMA journal_mode=WAL; + PRAGMA synchronous=FULL; + CREATE TABLE marker(name TEXT PRIMARY KEY, revision INTEGER NOT NULL); + INSERT INTO marker VALUES('first',0),('second',0); + CREATE TABLE payload(id INTEGER PRIMARY KEY, value BLOB NOT NULL); + WITH RECURSIVE rows(id) AS (VALUES(1) UNION ALL SELECT id+1 FROM rows WHERE id<1024) + INSERT INTO payload SELECT id, zeroblob(65536) FROM rows; + `) + const sharedCount = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT) + const count = new Int32Array(sharedCount) + writer = new Worker(WRITER_SOURCE, { eval: true, workerData: { path, count: sharedCount } }) + const firstCommit = new Promise((resolve, reject) => { + writer?.once('message', () => resolve()) + writer?.once('error', reject) + }) + const writerExit = new Promise((resolve, reject) => { + writer?.once('error', reject) + writer?.once('exit', (code) => + code === 0 ? resolve() : reject(new Error(`Writer exited ${code}`)) + ) + }) + // Attach failure handling before awaiting either event. + void writerExit.catch(() => {}) + await firstCommit + const before = Atomics.load(count, 0) + // Keep the writer alive until this thread is ready to start the backup. + writer.postMessage('continue') + await source.backup(target) + const after = Atomics.load(count, 0) + expect(after).toBeGreaterThan(before) + await writerExit + snapshot = new SyncDatabase(target, { readonly: true, fileMustExist: true }) + const rows = snapshot.prepare('SELECT revision FROM marker ORDER BY name').all() + expect(rows).toHaveLength(2) + expect(rows[0]).toEqual(rows[1]) + expect(rows[0]?.revision).toBeGreaterThanOrEqual(before) + expect(rows[0]?.revision).toBeLessThanOrEqual(Atomics.load(count, 0)) + expect(snapshot.prepare('SELECT count(*) AS count FROM payload').get()).toEqual({ count: 1024 }) + expect(snapshot.pragma('integrity_check', { simple: true })).toBe('ok') + } finally { + await writer?.terminate() + snapshot?.close() + source.close() + rmSync(directory, { recursive: true, force: true }) + } +}, 20_000) diff --git a/src/main/sqlite/sync-database-portability.test.ts b/src/main/sqlite/sync-database-portability.test.ts new file mode 100644 index 00000000000..fe8534eaf98 --- /dev/null +++ b/src/main/sqlite/sync-database-portability.test.ts @@ -0,0 +1,278 @@ +import { existsSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import SyncDatabase, { isSqliteAvailable } from './sync-database' + +const directories: string[] = [] +const databases: SyncDatabase[] = [] + +function fixture(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-sqlite-runtime-')) + directories.push(directory) + return directory +} + +function open(path: string): SyncDatabase { + const db = new SyncDatabase(path) + databases.push(db) + return db +} + +afterEach(() => { + for (const database of databases.splice(0)) { + try { + database.close() + } catch { + // A close-contract test already released this connection. + } + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('SQLite runtime contract', () => { + it('restarts an interrupted cached iterator without skipping its failed row', () => { + const db = open(':memory:') + db.exec('CREATE TABLE rows(value INTEGER); INSERT INTO rows VALUES(1),(2),(3)') + const statement = db.prepare('SELECT value FROM rows ORDER BY value') + for (let attempt = 0; attempt < 3; attempt++) { + expect(() => { + for (const row of statement.iterate()) { + expect(row.value).toBe(1) + throw new Error('invalid row') + } + }).toThrow('invalid row') + } + expect([...statement.iterate()]).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }]) + db.exec('DROP TABLE rows') + }) + + it('admits the actual runtime driver', () => { + expect(isSqliteAvailable()).toBe(true) + }) + + it('enforces foreign keys by default', () => { + const db = open(':memory:') + db.exec( + 'CREATE TABLE parents(id INTEGER PRIMARY KEY); CREATE TABLE children(parent INTEGER REFERENCES parents(id))' + ) + expect(() => db.prepare('INSERT INTO children VALUES(?)').run(1)).toThrow() + expect(db.prepare('SELECT * FROM children').all()).toEqual([]) + db.prepare('INSERT INTO parents VALUES(?)').run(1) + expect(db.prepare('INSERT INTO children VALUES(?)').run(1).changes).toBe(1) + expect(() => db.prepare('DELETE FROM parents WHERE id = ?').run(1)).toThrow() + }) + + it('returns safe integers as numbers without changing other SQLite values', () => { + const db = open(':memory:') + const row = db + .prepare('SELECT ? AS integer, ? AS real, ? AS text, ? AS blob, ? AS empty') + .get(Number.MAX_SAFE_INTEGER, 1.25, 'héllo', Buffer.from([0, 255, 17]), null) + expect(row).toEqual({ + integer: Number.MAX_SAFE_INTEGER, + real: 1.25, + text: 'héllo', + blob: expect.any(Uint8Array), + empty: null + }) + expect(row?.blob).toEqual(new Uint8Array([0, 255, 17])) + }) + + it('preserves 64-bit integers and rejects rounding in every reader by default', () => { + const db = open(':memory:') + const statement = db.prepare('SELECT ? AS integer') + for (const value of [ + -(1n << 63n), + -(1n << 63n) + 1n, + -(1n << 53n), + 1n << 53n, + (1n << 53n) + 1n, + (1n << 63n) - 1n + ]) { + expect(() => statement.get(value)).toThrow(RangeError) + expect(() => statement.all(value)).toThrow(RangeError) + expect(() => [...statement.iterate(value)]).toThrow(RangeError) + statement.setReadBigInts(true) + expect(statement.get(value)).toEqual({ integer: value }) + expect(statement.all(value)).toEqual([{ integer: value }]) + expect([...statement.iterate(value)]).toEqual([{ integer: value }]) + statement.setReadBigInts(false) + } + }) + + it('distinguishes large REAL values from INTEGER values in the same column', () => { + const db = open(':memory:') + db.exec('CREATE TABLE values_by_type(value); INSERT INTO values_by_type VALUES(1)') + const statement = db.prepare('SELECT value FROM values_by_type') + for (const readBigInts of [false, true, false]) { + statement.setReadBigInts(readBigInts) + db.exec('DELETE FROM values_by_type; INSERT INTO values_by_type VALUES(9007199254740991)') + const safe = readBigInts ? 9007199254740991n : Number.MAX_SAFE_INTEGER + expect(statement.get()).toEqual({ value: safe }) + db.exec( + 'DELETE FROM values_by_type; INSERT INTO values_by_type VALUES(CAST(-9223372036854775808 AS REAL))' + ) + const real = { value: Number(-(1n << 63n)) } + expect(statement.get()).toEqual(real) + expect(statement.all()).toEqual([real]) + expect([...statement.iterate()]).toEqual([real]) + db.exec('DELETE FROM values_by_type; INSERT INTO values_by_type VALUES(-9223372036854775808)') + if (readBigInts) { + expect(statement.get()).toEqual({ value: -(1n << 63n) }) + } else { + expect(() => statement.get()).toThrow(RangeError) + expect(() => statement.all()).toThrow(RangeError) + expect(() => [...statement.iterate()]).toThrow(RangeError) + } + } + }) + + it('rejects integers outside SQLite range before changing rows', () => { + const db = open(':memory:') + db.exec('CREATE TABLE items(value INTEGER)') + const insert = db.prepare('INSERT INTO items VALUES(?)') + for (const value of [-(1n << 63n) - 1n, 1n << 63n]) { + expect(() => insert.run(value)).toThrow() + } + expect(db.prepare('SELECT count(*) AS count FROM items').get()).toEqual({ count: 0 }) + }) + + it('clears old bindings and binds omitted positional values as null', () => { + const db = open(':memory:') + const statement = db.prepare('SELECT ? AS first, ? AS second') + expect(statement.get('old', 'secret')).toEqual({ first: 'old', second: 'secret' }) + expect(statement.get('new')).toEqual({ first: 'new', second: null }) + expect(statement.get()).toEqual({ first: null, second: null }) + expect(statement.all()).toEqual([{ first: null, second: null }]) + expect([...statement.iterate()]).toEqual([{ first: null, second: null }]) + expect(db.prepare('SELECT 1 WHERE 0').get()).toBeUndefined() + }) + + it('reports changes and explicit rowids using the requested integer mode', () => { + const db = open(':memory:') + db.exec('CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT)') + const insert = db.prepare('INSERT INTO items VALUES(?, ?)') + expect(insert.run(3, 'first')).toEqual({ changes: 1, lastInsertRowid: 3 }) + insert.setReadBigInts(true) + expect(insert.run(9007199254740993n, 'large')).toEqual({ + changes: 1n, + lastInsertRowid: 9007199254740993n + }) + }) + + it('preserves large write metadata without reporting a committed write as failed', () => { + const db = open(':memory:') + db.exec('CREATE TABLE items(id INTEGER PRIMARY KEY)') + const rowid = 9007199254740993n + expect(db.prepare('INSERT INTO items VALUES(?)').run(rowid)).toEqual({ + changes: 1, + lastInsertRowid: rowid + }) + expect(db.prepare('UPDATE items SET id=id').run()).toEqual({ + changes: 1, + lastInsertRowid: rowid + }) + const statement = db.prepare('SELECT id FROM items') + statement.setReadBigInts(true) + expect(statement.all()).toEqual([{ id: rowid }]) + }) + + it('rejects explicit undefined bindings before modifying rows', () => { + const db = open(':memory:') + db.exec('CREATE TABLE items(value TEXT)') + const insert = db.prepare('INSERT INTO items VALUES(?)') + // @ts-expect-error Exercise invalid input from untyped callers. + expect(() => insert.run(undefined)).toThrow() + expect(db.prepare('SELECT count(*) AS count FROM items').get()).toEqual({ count: 0 }) + const select = db.prepare('SELECT ? AS value') + // @ts-expect-error Exercise invalid input from untyped callers. + expect(() => select.get(undefined)).toThrow() + // @ts-expect-error Exercise invalid input from untyped callers. + expect(() => select.all(undefined)).toThrow() + // @ts-expect-error Exercise invalid input from untyped callers. + expect(() => [...select.iterate(undefined)]).toThrow() + }) + + it('releases statements and an unfinished iterator before filesystem retirement', () => { + const path = join(fixture(), 'database.db') + const db = open(path) + db.exec('CREATE TABLE items(id INTEGER PRIMARY KEY); INSERT INTO items VALUES(1),(2)') + db.exec("CREATE VIRTUAL TABLE search USING fts5(content); INSERT INTO search VALUES('needle')") + const statement = db.prepare('SELECT id FROM items ORDER BY id') + const search = db.prepare("SELECT content FROM search WHERE search MATCH 'needle'") + expect(search.get()).toEqual({ content: 'needle' }) + const iterator = statement.iterate() + expect(iterator.next().value).toEqual({ id: 1 }) + db.close() + expect(() => statement.get()).toThrow() + expect(() => search.get()).toThrow() + renameSync(path, `${path}.retired`) + const reopened = open(`${path}.retired`) + reopened.exec('DROP TABLE items') + }) + + it('opens literal filenames, URL paths and Buffer paths without treating them as data', () => { + const path = join(fixture(), "profile % # ' é.db") + const writer = open(path) + writer.exec('CREATE TABLE items(id INTEGER PRIMARY KEY); INSERT INTO items VALUES(9)') + for (const input of [pathToFileURL(path), Buffer.from(path)]) { + const reader = new SyncDatabase(input, { readonly: true, fileMustExist: true }) + databases.push(reader) + expect(reader.prepare('SELECT id FROM items').get()).toEqual({ id: 9 }) + } + expect(existsSync(path)).toBe(true) + }) + + it('refuses missing databases and changes through read-only connections', () => { + const path = join(fixture(), 'database.db') + for (const input of [path, pathToFileURL(path), Buffer.from(path)]) { + expect(() => new SyncDatabase(input, { fileMustExist: true })).toThrow() + expect(() => new SyncDatabase(input, { readonly: true, fileMustExist: true })).toThrow() + expect(existsSync(path)).toBe(false) + } + const writer = open(path) + writer.exec('CREATE TABLE items(id INTEGER PRIMARY KEY)') + const reader = new SyncDatabase(path, { readonly: true, timeout: 4321 }) + databases.push(reader) + expect(reader.pragma('busy_timeout', { simple: true })).toBe(4321) + expect(() => reader.exec('INSERT INTO items VALUES(1)')).toThrow() + expect(writer.pragma('busy_timeout', { simple: true })).toBe(0) + }) + + it('copies committed WAL data through a read-only source into privately precreated output', async () => { + const directory = fixture() + const path = join(directory, 'database.db') + const target = join(directory, "snapshot % # '.db") + const writer = open(path) + writer.exec( + 'PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE items(id INTEGER PRIMARY KEY, value TEXT)' + ) + writer.prepare('INSERT INTO items VALUES(?, ?)').run(19, 'committed in WAL') + expect(existsSync(`${path}-wal`)).toBe(true) + const source = new SyncDatabase(path, { readonly: true, fileMustExist: true }) + databases.push(source) + writeFileSync(target, '', { flag: 'wx', mode: 0o600 }) + await source.backup(target) + const snapshot = open(target) + expect(snapshot.prepare('SELECT id, value FROM items').get()).toEqual({ + id: 19, + value: 'committed in WAL' + }) + expect(snapshot.pragma('integrity_check', { simple: true })).toBe('ok') + }) + + it('refuses an active transaction before creating any backup destination', async () => { + const directory = fixture() + const db = open(join(directory, 'database.db')) + db.exec( + 'CREATE TABLE items(id INTEGER PRIMARY KEY); BEGIN IMMEDIATE; INSERT INTO items VALUES(1)' + ) + const target = join(directory, 'backup.db') + await expect(db.backup(target)).rejects.toThrow(/idle/) + expect(existsSync(target)).toBe(false) + db.exec('ROLLBACK') + }) +}) diff --git a/src/main/sqlite/sync-database.test.ts b/src/main/sqlite/sync-database.test.ts index 39cb443aeeb..4c937197a43 100644 --- a/src/main/sqlite/sync-database.test.ts +++ b/src/main/sqlite/sync-database.test.ts @@ -213,7 +213,7 @@ describe('SyncDatabase read-only opens under contention', () => { thrown = error } - expect((thrown as { errcode?: number }).errcode).toBe(5) + expect(thrown).toMatchObject({ [process.versions.bun ? 'errno' : 'errcode']: 5 }) expect((thrown as Error).message).toContain('database is locked') expect(Date.now() - startedAt).toBeLessThan(200) }) diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 08f69b9cf99..5f27b52329e 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -1,5 +1,8 @@ import { existsSync } from 'node:fs' -import type { backup, BackupOptions, DatabaseSync, StatementSync, SQLInputValue } from 'node:sqlite' +import type { backup, BackupOptions, DatabaseSync, SQLInputValue } from 'node:sqlite' +import { BunSqliteDatabase, loadBunSqlite } from './bun-sqlite-database' +import { NodeSqliteStatement } from './node-sqlite-statement' +import type { SqliteStatement } from './sqlite-statement' type SqlitePath = ConstructorParameters[0] @@ -13,7 +16,7 @@ type PragmaOptions = { simple?: boolean } -export type SqliteStatement = StatementSync +export type { SqliteStatement } from './sqlite-statement' // Why: dynamic `IN (?,?,…)` clauses mint a new SQL string per arity, so the cache must stay bounded. const STATEMENT_CACHE_LIMIT = 256 @@ -32,8 +35,20 @@ function loadDatabaseSync(): typeof DatabaseSync { if (typeof process.getBuiltinModule !== 'function') { throw new Error('node:sqlite is unavailable in this Node.js runtime') } - return (process.getBuiltinModule('node:sqlite') as { DatabaseSync: typeof DatabaseSync }) - .DatabaseSync + const sqlite: unknown = process.getBuiltinModule('node:sqlite') + if (!hasDatabaseSync(sqlite)) { + throw new Error('node:sqlite is unavailable in this Node.js runtime') + } + return sqlite.DatabaseSync +} + +function hasDatabaseSync(value: unknown): value is { DatabaseSync: typeof DatabaseSync } { + return ( + typeof value === 'object' && + value !== null && + 'DatabaseSync' in value && + typeof value.DatabaseSync === 'function' + ) } function hasBackup(value: unknown): value is { backup: typeof backup } { @@ -45,24 +60,35 @@ function hasBackup(value: unknown): value is { backup: typeof backup } { ) } +export function isSqliteAvailable(): boolean { + try { + if (process.versions.bun) { + return loadBunSqlite() !== undefined + } + const sqlite: unknown = process.getBuiltinModule?.('node:sqlite') + return hasDatabaseSync(sqlite) && hasBackup(sqlite) + } catch { + return false + } +} + class SyncDatabase { - private readonly db: DatabaseSync - private readonly statementCache = new Map() + private readonly db: DatabaseSync | BunSqliteDatabase + private readonly statementCache = new Map() constructor(path: SqlitePath, options: SyncDatabaseOptions = {}) { - if ( - options.fileMustExist && - typeof path === 'string' && - path !== ':memory:' && - !existsSync(path) - ) { - throw new Error(`SQLite database does not exist: ${path}`) + if (options.fileMustExist && path !== ':memory:' && !existsSync(path)) { + throw new Error(`SQLite database does not exist: ${String(path)}`) + } + if (process.versions.bun) { + this.db = new BunSqliteDatabase(path, options) + } else { + const DatabaseSync = loadDatabaseSync() + this.db = new DatabaseSync(path, { + readOnly: options.readonly, + timeout: options.timeout + }) } - const DatabaseSync = loadDatabaseSync() - this.db = new DatabaseSync(path, { - readOnly: options.readonly, - timeout: options.timeout - }) } exec(sql: string): void { @@ -73,14 +99,17 @@ class SyncDatabase { this.db.exec(sql) } - prepare(sql: string): StatementSync { + prepare(sql: string): SqliteStatement { const cached = this.statementCache.get(sql) if (cached) { this.statementCache.delete(sql) this.statementCache.set(sql, cached) return cached } - const statement = this.db.prepare(sql) + const statement = + this.db instanceof BunSqliteDatabase + ? this.db.prepare(sql) + : new NodeSqliteStatement(this.db.prepare(sql)) if (isStatementCacheable(sql)) { if (this.statementCache.size >= STATEMENT_CACHE_LIMIT) { const oldest = this.statementCache.keys().next().value @@ -94,7 +123,7 @@ class SyncDatabase { } pragma(sql: string, options?: PragmaOptions): unknown { - const statement = this.db.prepare(`PRAGMA ${sql}`) + const statement = this.prepare(`PRAGMA ${sql}`) if (options?.simple) { const row = statement.get() if (!row) { @@ -109,8 +138,18 @@ class SyncDatabase { return this.db.isTransaction } - /** The source connection must remain open until the native backup settles. */ - async backup(path: string, options?: BackupOptions): Promise { + /** Keep the source open until completion; Bun's compact snapshot runs synchronously. */ + async backup(path: string, options?: BackupOptions): Promise { + if (this.db.isTransaction) { + throw new Error('SQLite backup requires an idle database connection') + } + if (this.db instanceof BunSqliteDatabase) { + if (options && Object.keys(options).length > 0) { + throw new Error('Incremental SQLite backup options are unavailable in this runtime') + } + this.db.backup(path) + return + } const sqlite: unknown = typeof process.getBuiltinModule === 'function' ? process.getBuiltinModule('node:sqlite') @@ -118,10 +157,7 @@ class SyncDatabase { if (!hasBackup(sqlite)) { throw new Error('Asynchronous SQLite backup is unavailable in this Node.js runtime') } - if (this.db.isTransaction) { - throw new Error('Asynchronous SQLite backup requires an idle database connection') - } - return sqlite.backup(this.db, path, options ?? {}) + await sqlite.backup(this.db, path, options ?? {}) } close(): void { diff --git a/src/main/ssh/orcad-artifact-materializer.test.ts b/src/main/ssh/orcad-artifact-materializer.test.ts new file mode 100644 index 00000000000..beff61176ee --- /dev/null +++ b/src/main/ssh/orcad-artifact-materializer.test.ts @@ -0,0 +1,319 @@ +import { createHash } from 'node:crypto' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ORCAD_BUILD_TARGET_FILENAME, + ORCAD_EMOJI_SHORTCODE_DATASET, + ORCAD_RIPGREP_ARTIFACTS, + ORCAD_TEMPLATE_MANIFEST_FILENAME, + ORCAD_TEMPLATE_TARGETS_DIR, + ORCAD_VERSION_FILENAME, + orcadArtifactFilenames, + orcadTemplateCommonFilenames +} from '../../shared/orcad-artifacts' +import type { OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import { z } from 'zod' +import { readOrcadArtifactIdentity } from '../orcad/orcad-artifact-identity' +import { + assembleOrcadArtifact, + materializeOrcadArtifact, + resetOrcadArtifactMaterializationsForTests +} from './orcad-artifact-materializer' +import { materializeCachedOrcadBunRuntime } from './orcad-bun-runtime-materializer' +import type * as BunRuntimeMaterializer from './orcad-bun-runtime-materializer' + +vi.mock('./orcad-bun-runtime-materializer', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, materializeCachedOrcadBunRuntime: vi.fn() } +}) + +const TARGET = 'linux-x64-glibc' as const +const temporaryDirs: string[] = [] + +afterEach(() => { + resetOrcadArtifactMaterializationsForTests() + vi.clearAllMocks() + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function sha256(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex') +} + +function write(path: string, contents: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, contents) +} + +function createTemplate(target: OrcadBunTarget = TARGET): { + root: string + templateDir: string + cacheRoot: string + runtimePath: string +} { + const root = mkdtempSync(join(tmpdir(), 'orcad-artifact-template-')) + temporaryDirs.push(root) + const templateDir = join(root, 'template') + const cacheRoot = join(root, 'cache') + const runtimePath = join(root, 'bun-runtime') + const common: Record = { + ...Object.fromEntries(orcadTemplateCommonFilenames().map((filename) => [filename, filename])), + 'orcad.js': 'orcad-entry', + 'daemon-entry.js': 'daemon-entry', + 'profile-state-writer-worker-entry.js': 'writer-entry', + 'profile-state-backup-worker-entry.js': 'backup-entry', + 'windows-bun-pty-gate-entry.js': 'pty-gate-entry', + 'parcel-watcher-process-entry.js': 'watcher-process', + 'node_modules/@parcel/watcher/index.js': 'watcher-wrapper', + [ORCAD_EMOJI_SHORTCODE_DATASET]: '{}' + } + for (const [filename, contents] of Object.entries(common)) { + write(join(templateDir, filename), contents) + } + const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) + write(join(targetDir, ORCAD_BUILD_TARGET_FILENAME), `${target}\n`) + write(join(targetDir, 'watcher.node'), 'native-watcher') + write(join(targetDir, 'agent-browser-linux-x64'), 'browser') + write(runtimePath, 'bun-executable') + if (target.startsWith('win32-')) { + write(join(templateDir, 'windows-process-tree.node'), 'process-table') + } + write( + join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME), + JSON.stringify({ + schemaVersion: 2, + commonSha256: Object.fromEntries( + Object.keys(common).map((filename) => [filename, sha256(join(templateDir, filename))]) + ), + targets: { + [target]: { + targetSha256: sha256(join(targetDir, ORCAD_BUILD_TARGET_FILENAME)), + watcherSha256: sha256(join(targetDir, 'watcher.node')), + browserName: 'agent-browser-linux-x64', + browserSha256: sha256(join(targetDir, 'agent-browser-linux-x64')) + } + } + }) + ) + return { root, templateDir, cacheRoot, runtimePath } +} + +describe('assembleOrcadArtifact', () => { + it.skipIf(process.platform === 'win32')( + 'restores executable search modes from a template copied without them', + async () => { + const fixture = createTemplate() + const filenames = ORCAD_RIPGREP_ARTIFACTS.filter((filename) => filename.endsWith('/rg')) + for (const filename of filenames) { + chmodSync(join(fixture.templateDir, filename), 0o644) + } + const artifactDir = await assembleOrcadArtifact({ ...fixture, target: TARGET }) + for (const filename of filenames) { + expect(statSync(join(artifactDir, filename)).mode & 0o777).toBe(0o755) + } + } + ) + + it('gives Windows executable naming a new immutable slot identity', async () => { + const target = 'win32-x64' as const + const fixture = createTemplate(target) + const artifactDir = await assembleOrcadArtifact({ ...fixture, target }) + expect(readFileSync(join(artifactDir, 'bun-runtime.exe'), 'utf8')).toBe('bun-executable') + expect(existsSync(join(artifactDir, 'bun-runtime'))).toBe(false) + const oldHash = createHash('sha256') + for (const filename of orcadArtifactFilenames(target)) { + oldHash.update(readFileSync(join(artifactDir, filename))) + } + oldHash.update('browser') + const oldVersion = `0.1.0+${oldHash.digest('hex').slice(0, 12)}` + const oldDir = join(fixture.cacheRoot, target, oldVersion) + write(join(oldDir, 'bun-runtime'), 'legacy-slot-must-stay-unchanged') + expect(artifactDir).not.toBe(oldDir) + await expect(assembleOrcadArtifact({ ...fixture, target })).resolves.toBe(artifactDir) + expect(readFileSync(join(oldDir, 'bun-runtime'), 'utf8')).toBe( + 'legacy-slot-must-stay-unchanged' + ) + }) + + it('assembles a complete content-addressed target directory', async () => { + const fixture = createTemplate() + const artifactDir = await assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + + const version = readFileSync(join(artifactDir, ORCAD_VERSION_FILENAME), 'utf8').trim() + expect(version).toMatch(/^0\.1\.0\+[a-f0-9]{12}$/u) + expect(await readOrcadArtifactIdentity(artifactDir)).toBe(version) + write(join(artifactDir, 'orcad.js'), 'changed-installed-bytes') + expect(await readOrcadArtifactIdentity(artifactDir)).not.toBe(version) + expect(artifactDir).toBe(join(fixture.cacheRoot, TARGET, version)) + expect(readFileSync(join(artifactDir, ORCAD_BUILD_TARGET_FILENAME), 'utf8').trim()).toBe(TARGET) + for (const filename of orcadArtifactFilenames()) { + expect(readFileSync(join(artifactDir, filename)).byteLength).toBeGreaterThan(0) + } + expect(readFileSync(join(artifactDir, 'agent-browser-linux-x64'), 'utf8')).toBe('browser') + }) + + it('rejects a packaged native file that does not match its manifest', async () => { + const fixture = createTemplate() + write( + join(fixture.templateDir, ORCAD_TEMPLATE_TARGETS_DIR, TARGET, 'watcher.node'), + 'corrupted' + ) + + await expect( + assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + ).rejects.toThrow('watcher checksum mismatch') + }) + + it('rejects a self-consistent target marker for a different native slot', async () => { + const fixture = createTemplate() + const targetPath = join( + fixture.templateDir, + ORCAD_TEMPLATE_TARGETS_DIR, + TARGET, + ORCAD_BUILD_TARGET_FILENAME + ) + write(targetPath, 'linux-x64-musl\n') + const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = z + .object({ + targets: z.record(z.string(), z.object({ targetSha256: z.string() }).passthrough()) + }) + .passthrough() + .parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) + const target = manifest.targets[TARGET] + if (!target) { + throw new Error('Missing target fixture') + } + target.targetSha256 = sha256(targetPath) + write(manifestPath, JSON.stringify(manifest)) + + await expect( + assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + ).rejects.toThrow('target identity does not match') + }) + + it('repairs corrupt artifacts beside the old entry and reuses the repair', async () => { + const fixture = createTemplate() + const first = await assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + write(join(first, 'orcad.js'), 'corrupted-cache-entry') + + const repaired = await assembleOrcadArtifact({ ...fixture, target: TARGET }) + expect(repaired).not.toBe(first) + expect(readFileSync(join(repaired, 'orcad.js'))).toEqual( + readFileSync(join(fixture.templateDir, 'orcad.js')) + ) + expect(await assembleOrcadArtifact({ ...fixture, target: TARGET })).toBe(repaired) + expect(readFileSync(join(first, 'orcad.js'), 'utf8')).toBe('corrupted-cache-entry') + }) + + it('rejects an optional browser without a matching manifest checksum', async () => { + const fixture = createTemplate() + const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = z + .object({ targets: z.record(z.string(), z.record(z.string(), z.unknown())) }) + .passthrough() + .parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) + delete manifest.targets[TARGET]?.browserSha256 + write(manifestPath, JSON.stringify(manifest)) + + await expect( + assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + ).rejects.toThrow('browserName and browserSha256') + }) + + it('rejects a manifest that omits a required common artifact checksum', async () => { + const fixture = createTemplate() + const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME) + const manifest = z + .object({ commonSha256: z.record(z.string(), z.string()) }) + .passthrough() + .parse(JSON.parse(readFileSync(manifestPath, 'utf8'))) + delete manifest.commonSha256['orcad.js'] + write(manifestPath, JSON.stringify(manifest)) + + await expect( + assembleOrcadArtifact({ + templateDir: fixture.templateDir, + cacheRoot: fixture.cacheRoot, + target: TARGET, + runtimePath: fixture.runtimePath + }) + ).rejects.toThrow('manifest omits orcad.js') + }) +}) + +describe('materializeOrcadArtifact cancellation', () => { + it('detaches either cancelled caller without cancelling their shared cache fill', async () => { + const fixture = createTemplate() + let complete: (path: string) => void = () => {} + vi.mocked(materializeCachedOrcadBunRuntime).mockReturnValue( + new Promise((resolve) => { + complete = resolve + }) + ) + const first = new AbortController() + const second = new AbortController() + const one = materializeOrcadArtifact(TARGET, { ...fixture, signal: first.signal }) + const two = materializeOrcadArtifact(TARGET, { ...fixture, signal: second.signal }) + const three = materializeOrcadArtifact(TARGET, fixture) + const firstRejected = expect(one).rejects.toThrow('first cancelled') + const secondRejected = expect(two).rejects.toThrow('second cancelled') + await vi.waitFor(() => expect(materializeCachedOrcadBunRuntime).toHaveBeenCalledOnce()) + first.abort(new Error('first cancelled')) + second.abort(new Error('second cancelled')) + await Promise.all([firstRejected, secondRejected]) + complete(fixture.runtimePath) + const artifact = await three + expect(readFileSync(join(artifact, 'orcad.js'), 'utf8')).toBe('orcad-entry') + expect(materializeCachedOrcadBunRuntime).toHaveBeenCalledWith(TARGET, fixture.cacheRoot, { + fetcher: undefined + }) + }) + + it('refuses an already cancelled request before reading or fetching artifacts', async () => { + const controller = new AbortController() + controller.abort(new Error('cancelled')) + await expect(materializeOrcadArtifact(TARGET, { signal: controller.signal })).rejects.toThrow( + 'cancelled' + ) + expect(materializeCachedOrcadBunRuntime).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/orcad-artifact-materializer.ts b/src/main/ssh/orcad-artifact-materializer.ts new file mode 100644 index 00000000000..2efe902b504 --- /dev/null +++ b/src/main/ssh/orcad-artifact-materializer.ts @@ -0,0 +1,291 @@ +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream, existsSync } from 'node:fs' +import { chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { getAppEnvironment } from '../../shared/app-environment' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { + ORCAD_BUILD_TARGET_FILENAME, + orcadBunRuntimeFilename, + orcadArtifactHashPrefix, + ORCAD_TEMPLATE_MANIFEST_FILENAME, + ORCAD_TEMPLATE_TARGETS_DIR, + ORCAD_VERSION, + ORCAD_VERSION_FILENAME, + ORCAD_RIPGREP_ARTIFACTS, + orcadArtifactFilenames, + orcadTemplateCommonFilenames +} from '../../shared/orcad-artifacts' +import type { OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import { findOrcadCachePath } from './orcad-cache-path' +import { + fileSha256, + materializeCachedOrcadBunRuntime, + verifyFileSha256, + type OrcadBunRuntimeMaterializeOptions +} from './orcad-bun-runtime-materializer' + +const TemplateTargetSchema = z + .object({ + targetSha256: z.string().regex(/^[a-f0-9]{64}$/u), + watcherSha256: z.string().regex(/^[a-f0-9]{64}$/u), + browserName: z + .string() + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u) + .optional(), + browserSha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .optional() + }) + .refine((target) => Boolean(target.browserName) === Boolean(target.browserSha256), { + message: 'browserName and browserSha256 must either both be present or both be absent' + }) +const TemplateManifestSchema = z.object({ + schemaVersion: z.literal(2), + commonSha256: z.record(z.string(), z.string().regex(/^[a-f0-9]{64}$/u)), + targets: z.record(z.string(), TemplateTargetSchema) +}) + +type MaterializeOptions = OrcadBunRuntimeMaterializeOptions & { + templateDir?: string + cacheRoot?: string +} + +const materializations = new Map>() + +export async function materializeOrcadArtifact( + target: OrcadBunTarget, + options: MaterializeOptions = {} +): Promise { + options.signal?.throwIfAborted() + const templateDir = options.templateDir ?? resolveOrcadTemplateDir() + const cacheRoot = + options.cacheRoot ?? join(getAppEnvironment().getPath('userData'), 'orcad-artifacts') + const key = `${templateDir}\0${cacheRoot}\0${target}` + const existing = materializations.get(key) + if (existing) { + return waitForPromiseWithSignal(existing, options.signal) + } + // Cancellation detaches one caller; the bounded cache fill still serves other deployments. + const pending = materializeOrcadArtifactInner(target, templateDir, cacheRoot, { + fetcher: options.fetcher + }).finally(() => materializations.delete(key)) + materializations.set(key, pending) + return waitForPromiseWithSignal(pending, options.signal) +} + +async function materializeOrcadArtifactInner( + target: OrcadBunTarget, + templateDir: string, + cacheRoot: string, + options: MaterializeOptions +): Promise { + const manifest = await readTemplateManifest(templateDir) + await verifyTemplate(templateDir, target, manifest) + const runtimePath = await materializeCachedOrcadBunRuntime(target, cacheRoot, options) + return await assembleOrcadArtifact({ templateDir, cacheRoot, target, runtimePath, manifest }) +} + +export async function assembleOrcadArtifact(args: { + templateDir: string + cacheRoot: string + target: OrcadBunTarget + runtimePath: string + manifest?: z.infer +}): Promise { + const manifest = args.manifest ?? (await readTemplateManifest(args.templateDir)) + await verifyTemplate(args.templateDir, args.target, manifest) + const sources = artifactSources(args.templateDir, args.target, args.runtimePath, manifest) + const { fullVersion, sourceHashes } = await computeArtifactIdentity(sources, args.target) + const targetRoot = join(args.cacheRoot, args.target) + const cached = await findOrcadCachePath( + (attempt) => join(targetRoot, `${fullVersion}${attempt ? `.repair-${attempt}` : ''}`), + (path) => isCompleteArtifact(path, fullVersion, sources, sourceHashes) + ) + const targetDir = cached.path + if (cached.verified) { + return targetDir + } + await mkdir(targetRoot, { recursive: true }) + const stagingDir = join(targetRoot, `.staging-${process.pid}-${randomUUID()}`) + try { + for (const source of sources) { + const destination = join(stagingDir, source.filename) + await mkdir(dirname(destination), { recursive: true }) + await copyFile(source.path, destination) + if (source.executable && !args.target.startsWith('win32-')) { + await chmod(destination, 0o755) + } + } + await writeFile(join(stagingDir, ORCAD_VERSION_FILENAME), `${fullVersion}\n`, { mode: 0o600 }) + if (!(await isCompleteArtifact(stagingDir, fullVersion, sources, sourceHashes))) { + throw new Error('Orcad artifact sources changed while copying') + } + try { + await rename(stagingDir, targetDir) + } catch (error) { + if (!(await isCompleteArtifact(targetDir, fullVersion, sources, sourceHashes))) { + throw new Error(`Orcad artifact cache entry is unavailable or corrupted: ${targetDir}`, { + cause: error + }) + } + } + return targetDir + } finally { + await rm(stagingDir, { recursive: true, force: true }) + } +} + +function artifactSources( + templateDir: string, + target: OrcadBunTarget, + runtimePath: string, + manifest: z.infer +): { filename: string; path: string; executable?: boolean }[] { + const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) + const targetManifest = manifest.targets[target] + if (!targetManifest) { + throw new Error(`Packaged orcad template does not support ${target}`) + } + const required = orcadArtifactFilenames(target).map((filename) => ({ + filename, + path: + filename === orcadBunRuntimeFilename(target) + ? runtimePath + : filename === ORCAD_BUILD_TARGET_FILENAME + ? join(targetDir, ORCAD_BUILD_TARGET_FILENAME) + : filename.endsWith('watcher.node') + ? join(targetDir, 'watcher.node') + : join(templateDir, filename), + executable: + filename === orcadBunRuntimeFilename(target) || + ORCAD_RIPGREP_ARTIFACTS.some((artifact) => artifact === filename && artifact.endsWith('/rg')) + })) + if (!targetManifest.browserName) { + return required + } + return [ + ...required, + { + filename: targetManifest.browserName, + path: join(targetDir, targetManifest.browserName), + executable: true + } + ] +} + +async function computeArtifactIdentity( + sources: { filename: string; path: string }[], + target: OrcadBunTarget +): Promise<{ fullVersion: string; sourceHashes: Map }> { + const hash = createHash('sha256').update(orcadArtifactHashPrefix(target)) + const sourceHashes = new Map() + for (const source of sources) { + const sourceHash = createHash('sha256') + for await (const chunk of createReadStream(source.path)) { + hash.update(chunk) + sourceHash.update(chunk) + } + sourceHashes.set(source.filename, sourceHash.digest('hex')) + } + return { + fullVersion: `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}`, + sourceHashes + } +} + +async function isCompleteArtifact( + dir: string, + fullVersion: string, + sources: { filename: string }[], + sourceHashes: Map +): Promise { + try { + if ((await readFile(join(dir, ORCAD_VERSION_FILENAME), 'utf8')).trim() !== fullVersion) { + return false + } + for (const source of sources) { + if ((await fileSha256(join(dir, source.filename))) !== sourceHashes.get(source.filename)) { + return false + } + } + return true + } catch { + return false + } +} + +async function readTemplateManifest( + templateDir: string +): Promise> { + return TemplateManifestSchema.parse( + JSON.parse(await readFile(join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME), 'utf8')) + ) +} + +async function verifyTemplate( + templateDir: string, + target: OrcadBunTarget, + manifest: z.infer +): Promise { + const targetManifest = manifest.targets[target] + if (!targetManifest) { + throw new Error(`Packaged orcad template does not support ${target}`) + } + const commonFilenames = orcadTemplateCommonFilenames() + for (const filename of commonFilenames) { + const expected = manifest.commonSha256[filename] + if (!expected) { + throw new Error(`Packaged orcad template manifest omits ${filename}`) + } + await verifyFileSha256(join(templateDir, filename), expected, `orcad template ${filename}`) + } + const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target) + const targetIdentityPath = join(targetDir, ORCAD_BUILD_TARGET_FILENAME) + await verifyFileSha256(targetIdentityPath, targetManifest.targetSha256, `${target} build target`) + if ((await readFile(targetIdentityPath, 'utf8')).trim() !== target) { + throw new Error(`Packaged orcad template target identity does not match ${target}`) + } + await verifyFileSha256( + join(targetDir, 'watcher.node'), + targetManifest.watcherSha256, + `${target} watcher` + ) + if (targetManifest.browserName && targetManifest.browserSha256) { + await verifyFileSha256( + join(targetDir, targetManifest.browserName), + targetManifest.browserSha256, + `${target} browser` + ) + } +} + +export function getOrcadTemplateCandidates(): string[] { + const candidates: string[] = [] + if (process.env.ORCA_ORCAD_TEMPLATE_PATH) { + candidates.push(process.env.ORCA_ORCAD_TEMPLATE_PATH) + } + if (process.resourcesPath) { + candidates.push(join(process.resourcesPath, 'orcad-template')) + } + const appPath = getAppEnvironment().getAppPath() + candidates.push( + join(appPath, 'out', 'orcad-template'), + join(appPath, 'resources', 'orcad-template') + ) + return [...new Set(candidates)] +} + +function resolveOrcadTemplateDir(): string { + const found = getOrcadTemplateCandidates().find((candidate) => existsSync(candidate)) + if (!found) { + throw new Error('The packaged orcad deployment template is missing') + } + return found +} + +export function resetOrcadArtifactMaterializationsForTests(): void { + materializations.clear() +} diff --git a/src/main/ssh/orcad-bun-runtime-materializer.test.ts b/src/main/ssh/orcad-bun-runtime-materializer.test.ts new file mode 100644 index 00000000000..52963e98304 --- /dev/null +++ b/src/main/ssh/orcad-bun-runtime-materializer.test.ts @@ -0,0 +1,275 @@ +import { createHash } from 'node:crypto' +import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCAD_BUN_RUNTIME_FILENAME } from '../../shared/orcad-artifacts' +import { ORCAD_BUN_RELEASE_ASSETS, ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { setMainHttpClient } from '../network/http-client' +import { materializeCachedOrcadBunRuntime } from './orcad-bun-runtime-materializer' + +const extraction = vi.hoisted(() => ({ executable: new Uint8Array(), executableName: 'bun' })) + +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: vi.fn(async (spec: { args: string[] }) => { + const extracted = join(spec.args.at(-1)!, 'bun-linux-x64') + await mkdir(extracted, { recursive: true }) + await writeFile(join(extracted, extraction.executableName), extraction.executable) + return { code: 0, stdout: '', stderr: '' } + }) +})) + +const TARGET = 'linux-x64-glibc' as const +const originalAsset = { ...ORCAD_BUN_RELEASE_ASSETS[TARGET] } +let cacheRoot = '' + +function sha256(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function responseFetcher(body: Uint8Array, declaredLength = body.byteLength): typeof fetch { + return vi.fn( + async () => + new Response(Buffer.from(body), { + status: 200, + headers: { 'content-length': String(declaredLength) } + }) + ) +} + +beforeEach(async () => { + extraction.executableName = 'bun' + cacheRoot = await mkdtemp(join(tmpdir(), 'orca-bun-runtime-materializer-')) + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], originalAsset) +}) + +afterEach(async () => { + vi.useRealTimers() + setMainHttpClient(null) + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], originalAsset) + await rm(cacheRoot, { recursive: true, force: true }) +}) + +describe('materializeCachedOrcadBunRuntime', () => { + it('caches Windows PE files as .exe without renaming a legacy cache entry', async () => { + const target = 'win32-x64' as const + const savedAsset = { ...ORCAD_BUN_RELEASE_ASSETS[target] } + const archive = new TextEncoder().encode('windows archive') + const executable = new TextEncoder().encode('windows executable') + extraction.executable = executable + extraction.executableName = 'bun.exe' + Object.assign(ORCAD_BUN_RELEASE_ASSETS[target], { + sha256: sha256(archive), + executableSha256: sha256(executable) + }) + const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, target) + await mkdir(runtimeDir, { recursive: true }) + await writeFile(join(runtimeDir, 'bun-runtime'), 'legacy') + try { + const runtimePath = await materializeCachedOrcadBunRuntime(target, cacheRoot, { + fetcher: responseFetcher(archive) + }) + expect(runtimePath).toBe(join(runtimeDir, 'bun-runtime.exe')) + expect(await readFile(runtimePath)).toEqual(Buffer.from(executable)) + expect(await readFile(join(runtimeDir, 'bun-runtime'), 'utf8')).toBe('legacy') + } finally { + Object.assign(ORCAD_BUN_RELEASE_ASSETS[target], savedAsset) + } + }) + + it('reuses a checksum-valid cached runtime without fetching', async () => { + const runtime = new TextEncoder().encode('cached bun') + ORCAD_BUN_RELEASE_ASSETS[TARGET].executableSha256 = sha256(runtime) + const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET) + const runtimePath = join(runtimeDir, ORCAD_BUN_RUNTIME_FILENAME) + await mkdir(runtimeDir, { recursive: true }) + await writeFile(runtimePath, runtime) + const fetcher = vi.fn() + + await expect(materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).resolves.toBe( + runtimePath + ) + expect(fetcher).not.toHaveBeenCalled() + expect(await readFile(runtimePath)).toEqual(Buffer.from(runtime)) + }) + + it('downloads, verifies, extracts, and atomically caches the runtime', async () => { + const archive = new TextEncoder().encode('pinned archive') + const executable = new TextEncoder().encode('pinned bun executable') + extraction.executable = executable + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { + sha256: sha256(archive), + executableSha256: sha256(executable) + }) + const fetcher = responseFetcher(archive) + + const runtimePath = await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + + expect(await readFile(runtimePath)).toEqual(Buffer.from(executable)) + if (process.platform !== 'win32') { + expect((await stat(runtimePath)).mode & 0o111).toBe(0o111) + } + expect(fetcher).toHaveBeenCalledWith( + expect.stringContaining(`/bun-v${ORCAD_BUN_VERSION}/bun-linux-x64.zip`), + expect.objectContaining({ redirect: 'follow' }) + ) + expect((await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).sort()).toEqual( + [ORCAD_BUN_RUNTIME_FILENAME] + ) + }) + + it('refuses an oversized declared archive before reading its body', async () => { + const fetcher = responseFetcher(new Uint8Array([1]), 200 * 1024 * 1024 + 1) + + await expect(materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).rejects.toThrow( + 'Bun download exceeded the archive size limit' + ) + await expect( + access(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET, ORCAD_BUN_RUNTIME_FILENAME)) + ).rejects.toThrow() + expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + }) + + it('removes temporary data after an archive checksum mismatch', async () => { + const archive = new TextEncoder().encode('tampered archive') + const fetcher = responseFetcher(archive) + + await expect(materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).rejects.toThrow( + 'Bun archive checksum mismatch' + ) + expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + }) + + it('refuses an executable mismatch even when the archive matches its pin', async () => { + const archive = new TextEncoder().encode('pinned archive') + extraction.executable = new TextEncoder().encode('incorrect executable') + ORCAD_BUN_RELEASE_ASSETS[TARGET].sha256 = sha256(archive) + await expect( + materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher: responseFetcher(archive) }) + ).rejects.toThrow('Bun executable checksum mismatch') + expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) + }) + + it('cleans an aborted download before publishing any executable', async () => { + const controller = new AbortController() + controller.abort(new Error('deployment cancelled')) + await expect( + materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { + fetcher: responseFetcher(new Uint8Array([1])), + signal: controller.signal + }) + ).rejects.toThrow('deployment cancelled') + await expect(access(join(cacheRoot, 'bun'))).rejects.toThrow() + }) +}) + +it('publishes concurrent runtime downloads without removing or replacing the winning executable', async () => { + const archive = new TextEncoder().encode('pinned archive') + extraction.executable = new TextEncoder().encode('pinned runtime') + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { + sha256: sha256(archive), + executableSha256: sha256(extraction.executable) + }) + let finishSecond: (response: Response) => void = () => {} + const secondFetcher = vi.fn( + () => + new Promise((resolve) => { + finishSecond = resolve + }) + ) + const second = materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher: secondFetcher }) + await vi.waitFor(() => expect(secondFetcher).toHaveBeenCalledOnce()) + const firstPath = await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { + fetcher: responseFetcher(archive) + }) + const firstIdentity = await stat(firstPath) + finishSecond(new Response(Buffer.from(archive))) + expect(await second).toBe(firstPath) + expect((await stat(firstPath)).ino).toBe(firstIdentity.ino) + expect(await readFile(firstPath)).toEqual(Buffer.from(extraction.executable)) +}) + +it('repairs a corrupt published runtime beside the old inode and reuses the repair', async () => { + const archive = new TextEncoder().encode('pinned archive') + extraction.executable = new TextEncoder().encode('pinned runtime') + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { + sha256: sha256(archive), + executableSha256: sha256(extraction.executable) + }) + const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET) + const runtimePath = join(runtimeDir, ORCAD_BUN_RUNTIME_FILENAME) + await mkdir(runtimeDir, { recursive: true }) + await writeFile(runtimePath, 'corrupt') + const fetcher = responseFetcher(archive) + const repaired = await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + expect(repaired).not.toBe(runtimePath) + expect(await readFile(repaired)).toEqual(Buffer.from(extraction.executable)) + expect(await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher })).toBe(repaired) + expect(fetcher).toHaveBeenCalledOnce() + expect(await readFile(runtimePath, 'utf8')).toBe('corrupt') +}) + +it('uses the configured HTTP client for deployment downloads', async () => { + const archive = new TextEncoder().encode('proxy archive') + extraction.executable = new TextEncoder().encode('proxy runtime') + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { + sha256: sha256(archive), + executableSha256: sha256(extraction.executable) + }) + const fetcher = responseFetcher(archive) + setMainHttpClient({ fetch: fetcher, proxySession: () => null }) + await materializeCachedOrcadBunRuntime(TARGET, cacheRoot, {}) + expect(fetcher).toHaveBeenCalledOnce() +}) + +it('allows a progressing download to exceed two minutes', async () => { + vi.useFakeTimers() + const first = new TextEncoder().encode('first') + const second = new TextEncoder().encode('second') + extraction.executable = new TextEncoder().encode('slow runtime') + Object.assign(ORCAD_BUN_RELEASE_ASSETS[TARGET], { + sha256: sha256(Buffer.concat([first, second])), + executableSha256: sha256(extraction.executable) + }) + let stream: ReadableStreamDefaultController | undefined + let signal: AbortSignal | null | undefined + const fetcher = vi.fn(async (_url, options) => { + signal = options?.signal + return new Response( + new ReadableStream({ + start(controller) { + stream = controller + } + }) + ) + }) + const pending = materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(90_000) + stream!.enqueue(first) + await vi.waitFor(async () => { + const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET) + const temporary = (await readdir(runtimeDir)).find((entry) => entry.startsWith('.download-'))! + expect( + (await stat(join(runtimeDir, temporary, ORCAD_BUN_RELEASE_ASSETS[TARGET].filename))).size + ).toBe(first.length) + }) + await vi.advanceTimersByTimeAsync(90_000) + expect(signal?.aborted).toBe(false) + stream!.enqueue(second) + stream!.close() + expect(await readFile(await pending)).toEqual(Buffer.from(extraction.executable)) +}) + +it('aborts a stalled body and removes the unfinished download', async () => { + vi.useFakeTimers() + const cancel = vi.fn() + const fetcher = vi.fn(async () => new Response(new ReadableStream({ cancel }))) + const pending = materializeCachedOrcadBunRuntime(TARGET, cacheRoot, { fetcher }) + const rejected = expect(pending).rejects.toThrow('Bun download stalled') + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(120_000) + await rejected + expect(cancel).toHaveBeenCalledOnce() + expect(await readdir(join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, TARGET))).toEqual([]) +}) diff --git a/src/main/ssh/orcad-bun-runtime-materializer.ts b/src/main/ssh/orcad-bun-runtime-materializer.ts new file mode 100644 index 00000000000..9ed65ecdb02 --- /dev/null +++ b/src/main/ssh/orcad-bun-runtime-materializer.ts @@ -0,0 +1,199 @@ +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream, readdirSync } from 'node:fs' +import { chmod, link, mkdir, open, rm } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { getZipExtractorCommand } from '../../shared/zip-extractor-command' +import { getMainHttpClient, type MainHttpClient } from '../network/http-client' +import { findOrcadCachePath } from './orcad-cache-path' +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { + ORCAD_BUN_RELEASE_ASSETS, + ORCAD_BUN_VERSION, + orcadBunReleaseUrl, + type OrcadBunTarget +} from '../../shared/orcad-bun-runtime' + +const MAX_BUN_ARCHIVE_BYTES = 200 * 1024 * 1024 + +export type OrcadBunRuntimeMaterializeOptions = { + fetcher?: MainHttpClient['fetch'] + signal?: AbortSignal +} + +export async function materializeCachedOrcadBunRuntime( + target: OrcadBunTarget, + cacheRoot: string, + options: OrcadBunRuntimeMaterializeOptions +): Promise { + options.signal?.throwIfAborted() + const asset = ORCAD_BUN_RELEASE_ASSETS[target] + const runtimeDir = join(cacheRoot, 'bun', `v${ORCAD_BUN_VERSION}`, target) + await mkdir(runtimeDir, { recursive: true }) + const runtime = await findOrcadCachePath( + (attempt) => + join(runtimeDir, `${attempt ? `repair-${attempt}-` : ''}${orcadBunRuntimeFilename(target)}`), + async (path) => (await fileSha256(path)) === asset.executableSha256 + ) + const runtimePath = runtime.path + if (runtime.verified) { + if (!target.startsWith('win32-')) { + await chmod(runtimePath, 0o755) + } + return runtimePath + } + const temporaryDir = join(runtimeDir, `.download-${process.pid}-${randomUUID()}`) + await mkdir(temporaryDir, { recursive: true }) + try { + const archivePath = join(temporaryDir, basename(asset.filename)) + await downloadVerifiedArchive( + orcadBunReleaseUrl(asset), + archivePath, + asset.sha256, + options.fetcher ?? getMainHttpClient().fetch, + options.signal + ) + options.signal?.throwIfAborted() + const extractedDir = join(temporaryDir, 'extracted') + await mkdir(extractedDir) + const command = getZipExtractorCommand(archivePath, extractedDir) + const result = await runProcess({ + program: command.file, + args: command.args, + timeoutMs: 120_000, + signal: options.signal + }) + options.signal?.throwIfAborted() + if (result.code !== 0) { + throw new Error(`Bun archive extraction failed: ${result.stderr || result.stdout}`) + } + const executable = findExtractedBun(extractedDir, target) + await verifyFileSha256(executable, asset.executableSha256, `${target} Bun executable`) + if (!target.startsWith('win32-')) { + await chmod(executable, 0o755) + } + options.signal?.throwIfAborted() + try { + await link(executable, runtimePath) + } catch (error) { + if ((await fileSha256(runtimePath)) !== asset.executableSha256) { + throw new Error(`Bun runtime cache entry is unavailable or corrupted: ${runtimePath}`, { + cause: error + }) + } + } + await verifyFileSha256(runtimePath, asset.executableSha256, `${target} cached Bun executable`) + return runtimePath + } finally { + await rm(temporaryDir, { recursive: true, force: true }) + } +} + +async function downloadVerifiedArchive( + url: string, + destination: string, + expectedSha256: string, + fetcher: MainHttpClient['fetch'], + signal?: AbortSignal +): Promise { + const stall = new AbortController() + const downloadSignal = signal ? AbortSignal.any([signal, stall.signal]) : stall.signal + const stallTimer = setTimeout(() => stall.abort(new Error('Bun download stalled')), 120_000) + try { + const response = await fetcher(url, { redirect: 'follow', signal: downloadSignal }) + if (!response.ok || !response.body) { + await response.body?.cancel().catch(() => undefined) + throw new Error(`Bun download failed: ${response.status} ${response.statusText}`) + } + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > MAX_BUN_ARCHIVE_BYTES) { + await response.body.cancel().catch(() => undefined) + throw new Error('Bun download exceeded the archive size limit') + } + const handle = await open(destination, 'wx', 0o600).catch(async (error) => { + await response.body?.cancel().catch(() => undefined) + throw error + }) + const reader = response.body.getReader() + const hash = createHash('sha256') + let total = 0 + try { + for (;;) { + downloadSignal.throwIfAborted() + const chunk = await waitForPromiseWithSignal(reader.read(), downloadSignal) + if (chunk.done) { + break + } + if (chunk.value.byteLength > 0) { + stallTimer.refresh() + } + total += chunk.value.byteLength + if (total > MAX_BUN_ARCHIVE_BYTES) { + throw new Error('Bun download exceeded the archive size limit') + } + hash.update(chunk.value) + await writeAll(handle, chunk.value) + } + } catch (error) { + await reader.cancel().catch(() => undefined) + throw error + } finally { + await handle.close() + } + const actual = hash.digest('hex') + if (actual !== expectedSha256) { + throw new Error(`Bun archive checksum mismatch: expected ${expectedSha256}, got ${actual}`) + } + } finally { + clearTimeout(stallTimer) + } +} + +async function writeAll( + handle: Awaited>, + bytes: Uint8Array +): Promise { + let offset = 0 + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset) + if (bytesWritten === 0) { + throw new Error('Bun archive write made no progress') + } + offset += bytesWritten + } +} + +function findExtractedBun(root: string, target: OrcadBunTarget): string { + const expected = target.startsWith('win32-') ? 'bun.exe' : 'bun' + const entry = readdirSync(root, { recursive: true, withFileTypes: true }).find( + (candidate) => candidate.isFile() && candidate.name === expected + ) + if (!entry) { + throw new Error(`Downloaded Bun archive contained no ${expected}`) + } + return join(entry.parentPath, entry.name) +} + +export async function verifyFileSha256( + path: string, + expected: string, + label: string +): Promise { + const actual = await fileSha256(path) + if (actual !== expected) { + throw new Error(`${label} checksum mismatch: expected ${expected}, got ${actual ?? 'missing'}`) + } +} + +export async function fileSha256(path: string): Promise { + try { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) { + hash.update(chunk) + } + return hash.digest('hex') + } catch { + return null + } +} diff --git a/src/main/ssh/orcad-cache-path.ts b/src/main/ssh/orcad-cache-path.ts new file mode 100644 index 00000000000..2b31f634642 --- /dev/null +++ b/src/main/ssh/orcad-cache-path.ts @@ -0,0 +1,22 @@ +import { lstat } from 'node:fs/promises' + +/** Recover beside corrupt entries; a published path may still belong to another reader. */ +export async function findOrcadCachePath( + candidate: (attempt: number) => string, + isValid: (path: string) => Promise +): Promise<{ path: string; verified: boolean }> { + for (let attempt = 0; ; attempt++) { + const path = candidate(attempt) + if (await isValid(path)) { + return { path, verified: true } + } + try { + await lstat(path) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return { path, verified: false } + } + throw error + } + } +} diff --git a/src/main/ssh/orcad-deployment-target.test.ts b/src/main/ssh/orcad-deployment-target.test.ts new file mode 100644 index 00000000000..d833f560be9 --- /dev/null +++ b/src/main/ssh/orcad-deployment-target.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { parseOrcadLinuxLibc, resolveOrcadDeploymentTarget } from './orcad-deployment-target' +import { SshConnection } from './ssh-connection' +import { createCallbacks, createTarget } from './ssh-connection-test-fixtures' +import { execCommand } from './ssh-relay-deploy-helpers' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +beforeEach(() => vi.mocked(execCommand).mockReset()) + +describe('deployment C library selection', () => { + it.each([ + ['glibc 2.31', 'linux-x64-glibc'], + ['musl', 'linux-x64-musl'] + ])('uses host fallback evidence %j when ldd is unavailable', async (evidence, target) => { + vi.mocked(execCommand).mockResolvedValueOnce('ldd: not found').mockResolvedValueOnce(evidence) + const conn = new SshConnection(createTarget(), createCallbacks()) + await expect( + resolveOrcadDeploymentTarget({ conn, host: getRemoteHostPlatform('linux-x64') }) + ).resolves.toBe(target) + expect(execCommand).toHaveBeenLastCalledWith( + conn, + expect.stringContaining('getconf GNU_LIBC_VERSION'), + expect.anything() + ) + }) + + it('never guesses when neither probe identifies the host library', async () => { + vi.mocked(execCommand).mockResolvedValue('') + await expect( + resolveOrcadDeploymentTarget({ + conn: new SshConnection(createTarget(), createCallbacks()), + host: getRemoteHostPlatform('linux-x64') + }) + ).rejects.toThrow('Could not identify') + }) + + it.each([ + ['ldd (Ubuntu GLIBC 2.31-0ubuntu9) 2.31', 'glibc'], + ['ldd (GNU libc) 2.28', 'glibc'], + ['musl libc (x86_64)\nVersion 1.2.5', 'musl'] + ])('recognizes %s', (output, expected) => { + expect(parseOrcadLinuxLibc(output)).toBe(expected) + }) + + it.each(['', 'ldd: command not found', 'Linux x86_64'])( + 'refuses unproven target %j', + (output) => { + expect(() => parseOrcadLinuxLibc(output)).toThrow('Could not identify') + } + ) +}) diff --git a/src/main/ssh/orcad-deployment-target.ts b/src/main/ssh/orcad-deployment-target.ts new file mode 100644 index 00000000000..7d822cc6ac7 --- /dev/null +++ b/src/main/ssh/orcad-deployment-target.ts @@ -0,0 +1,39 @@ +import type { OrcadBunTarget } from '../../shared/orcad-bun-runtime' +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +export function parseOrcadLinuxLibc(output: string): 'glibc' | 'musl' { + if (/\bmusl\b/i.test(output)) { + return 'musl' + } + if (/\b(?:glibc|GNU libc|GNU C Library)\b/i.test(output)) { + return 'glibc' + } + throw new Error('Could not identify the host C library for the bundled Orca runtime') +} + +export async function resolveOrcadDeploymentTarget(options: { + conn: SshConnection + host: RemoteHostPlatform + signal?: AbortSignal +}): Promise { + const { host } = options + if (host.os !== 'linux') { + return `${host.os}-${host.arch}` + } + let output = await execCommand(options.conn, 'ldd --version 2>&1 || true', { + signal: options.signal + }) + try { + return `linux-${host.arch}-${parseOrcadLinuxLibc(output)}` + } catch { + output = await execCommand( + options.conn, + 'getconf GNU_LIBC_VERSION 2>/dev/null || ' + + 'for loader in /lib/ld-musl-*.so.1; do [ ! -e "$loader" ] || { echo musl; break; }; done', + { signal: options.signal } + ) + } + return `linux-${host.arch}-${parseOrcadLinuxLibc(output)}` +} diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 8fc387184f5..38b9fc951f9 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -1,4 +1,8 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn(), @@ -21,18 +25,23 @@ import { execCommand } from './ssh-relay-deploy-helpers' import { acquireInstallLock } from './ssh-relay-install-lock' import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy' +import { installOrcadBundle } from './orcad-remote-install' +import { + abandonInstall, + finalizeInstall, + isRemoteInstallComplete +} from './ssh-relay-versioned-install' import { emptyOrcadActivationRecord, withActivatedVersion } from './orcad-activation-record' import { getRemoteHostPlatform } from './ssh-remote-platform' -import { finalizeInstall } from './ssh-relay-versioned-install' import type { SshConnection } from './ssh-connection' const mockExec = vi.mocked(execCommand) -const NEW_VERSION = '0.2.0+bb01' +const NEW_VERSION = '0.2.0+bb0100000000' const OLD_VERSION = '0.1.0+aa01' vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ ...(await importOriginal>()), - readLocalFullVersion: () => '0.2.0+bb01', + readLocalFullVersion: () => '0.2.0+bb0100000000', isRemoteInstallComplete: vi.fn().mockResolvedValue(false), finalizeInstall: vi.fn().mockResolvedValue(undefined), abandonInstall: vi.fn().mockResolvedValue(undefined) @@ -82,9 +91,11 @@ type HostScript = { /** Readiness content per version dir, keyed by the version in the path. */ readiness: Record log: string[] + preflightResult?: string snapshotResult?: string comparisonResult?: string candidateStopResult?: string + readinessAtMs?: number } function scriptHost(script: HostScript): void { @@ -94,9 +105,27 @@ function scriptHost(script: HostScript): void { return script.activationRecord } if (text.includes('.orcad-readiness') && text.startsWith('cat ')) { + if (script.readinessAtMs !== undefined && Date.now() < script.readinessAtMs) { + return '' + } const version = Object.keys(script.readiness).find((v) => text.includes(v)) return version ? script.readiness[version] : '' } + if (text.includes('--orcad-profile-state-preflight')) { + script.log.push('preflight') + return ( + script.preflightResult ?? + JSON.stringify({ + type: 'orca_profile_state_ready', + nonce: text.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/)?.[0], + runtime: 'bun', + runtimeVersion: '1.4.2', + sqliteVersion: '3.51.0', + artifactVersion: NEW_VERSION, + revision: 1 + }) + ) + } if (text.includes('nohup')) { script.log.push(`launch:${text.includes(NEW_VERSION) ? NEW_VERSION : OLD_VERSION}`) return '9999' @@ -139,7 +168,105 @@ const ACTIVE_OLD = JSON.stringify( withActivatedVersion(emptyOrcadActivationRecord(), OLD_VERSION, null, new Date(0)) ) +describe('orcad install lock ownership', () => { + const remoteDir = `/home/u/.orca-remote/orcad-${NEW_VERSION}` + const install = (signal?: AbortSignal) => + installOrcadBundle( + { ...options({ signal }), localOrcadDir: '/local/out/orcad' }, + NEW_VERSION, + remoteDir + ) + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isRemoteInstallComplete).mockReset().mockResolvedValue(false) + }) + + it('leaves another install lock alone when the initial probe is complete', async () => { + vi.mocked(isRemoteInstallComplete).mockResolvedValueOnce(true) + await install() + expect(acquireInstallLock).not.toHaveBeenCalled() + expect(abandonInstall).not.toHaveBeenCalled() + expect(uploadRelayDirectory).not.toHaveBeenCalled() + }) + + it('releases its lock when another installer completed while acquisition waited', async () => { + vi.mocked(isRemoteInstallComplete).mockResolvedValueOnce(false).mockResolvedValueOnce(true) + await install() + expect(acquireInstallLock).toHaveBeenCalledOnce() + expect(abandonInstall).toHaveBeenCalledOnce() + expect(abandonInstall).toHaveBeenCalledWith(expect.anything(), remoteDir, options().host) + expect(uploadRelayDirectory).not.toHaveBeenCalled() + expect(finalizeInstall).not.toHaveBeenCalled() + }) + + it('publishes a complete install before releasing its lock once', async () => { + await install() + expect(uploadRelayDirectory).toHaveBeenCalledOnce() + expect(finalizeInstall).toHaveBeenCalledWith(expect.anything(), remoteDir, options().host, { + signal: undefined, + releaseLock: false + }) + expect(abandonInstall).toHaveBeenCalledOnce() + expect(vi.mocked(finalizeInstall).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(abandonInstall).mock.invocationCallOrder[0] + ) + }) + + it('releases a failed upload without publishing it or replacing its error', async () => { + const error = new Error('upload interrupted') + vi.mocked(uploadRelayDirectory).mockRejectedValueOnce(error) + await expect(install()).rejects.toBe(error) + expect(abandonInstall).toHaveBeenCalledOnce() + expect(finalizeInstall).not.toHaveBeenCalled() + }) + + it('releases its lock without reusing an aborted operation signal', async () => { + const controller = new AbortController() + const error = new Error('deployment canceled') + vi.mocked(uploadRelayDirectory).mockImplementationOnce(async () => { + controller.abort(error) + controller.signal.throwIfAborted() + }) + await expect(install(controller.signal)).rejects.toBe(error) + expect(abandonInstall).toHaveBeenCalledOnce() + expect(abandonInstall).toHaveBeenCalledWith(expect.anything(), remoteDir, options().host) + expect(finalizeInstall).not.toHaveBeenCalled() + }) + + it('does not release a lock when acquisition failed', async () => { + const error = new Error('lock held by another client') + vi.mocked(acquireInstallLock).mockRejectedValueOnce(error) + await expect(install()).rejects.toBe(error) + expect(abandonInstall).not.toHaveBeenCalled() + expect(uploadRelayDirectory).not.toHaveBeenCalled() + }) +}) + describe('deployOrcad', () => { + it.each(['', '{"type":"orca_profile_state_ready","revision":0}'])( + 'leaves the incumbent and shared state alone when preflight returns %j', + async (preflightResult) => { + const script: HostScript = { + activationRecord: ACTIVE_OLD, + readiness: { [NEW_VERSION]: readyLine({}) }, + log: [], + preflightResult + } + scriptHost(script) + expect(await deployOrcad(options())).toMatchObject({ + outcome: 'installed-not-activated', + code: 'orcad_candidate_preflight_failed' + }) + expect(script.log).toEqual(['preflight']) + expect( + vi + .mocked(writeRelayFile) + .mock.calls.some(([, , path]) => path.includes('orcad-active.json')) + ).toBe(false) + } + ) + beforeEach(() => { vi.clearAllMocks() }) @@ -173,6 +300,7 @@ describe('deployOrcad', () => { ) expect(chmod).toBeGreaterThanOrEqual(0) expect(mockExec.mock.calls[chmod]?.[1]).toContain(`/ripgrep/${platform}/rg'`) + expect(mockExec.mock.calls[chmod]?.[1]).toContain("/bun-runtime'") expect(vi.mocked(uploadRelayDirectory).mock.invocationCallOrder[0]).toBeLessThan( mockExec.mock.invocationCallOrder[chmod] ) @@ -184,13 +312,17 @@ describe('deployOrcad', () => { it('does not run chmod on a Windows remote', async () => { scriptHost({ activationRecord: '', readiness: {}, log: [] }) - await deployOrcad( - options({ + await installOrcadBundle( + { + conn: options().conn, host: getRemoteHostPlatform('win32-x64'), - remoteHome: 'C:/Users/u', - census: { liveSessions: 1, startedSinceActivation: 0 } - }) + localOrcadDir: '/local/out/orcad' + }, + NEW_VERSION, + `C:/Users/u/.orca-remote/orcad-${NEW_VERSION}` ) + expect(uploadRelayDirectory).toHaveBeenCalledOnce() + expect(finalizeInstall).toHaveBeenCalledOnce() expect(mockExec.mock.calls.some(([, command]) => String(command).startsWith('chmod '))).toBe( false ) @@ -207,6 +339,73 @@ describe('deployOrcad', () => { expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() }) + it.skipIf(process.platform === 'win32').each([undefined, 'linux-x64', 'linux-musl-x64'])( + 'restores uploaded executable modes with optional browser %s', + async (browserTarget) => { + const directory = mkdtempSync(join(tmpdir(), 'orcad-install-modes-')) + const binaries = ['bun-runtime', 'ripgrep/linux-x64/rg'] + if (browserTarget) { + binaries.push(`agent-browser-${browserTarget}`) + } + try { + for (const filename of binaries) { + const path = join(directory, filename) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, 'uploaded executable') + chmodSync(path, 0o644) + } + mockExec.mockImplementation(async (_conn, command) => { + const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) + if (result.code !== 0) { + throw new Error(result.stderr) + } + return result.stdout + }) + await installOrcadBundle( + { + conn: options().conn, + host: getRemoteHostPlatform('linux-x64'), + localOrcadDir: directory + }, + NEW_VERSION, + directory + ) + expect(finalizeInstall).toHaveBeenCalledOnce() + for (const filename of binaries) { + expect(statSync(join(directory, filename)).mode & 0o777).toBe(0o755) + } + } finally { + mockExec.mockReset() + rmSync(directory, { recursive: true, force: true }) + } + } + ) + + it('allows startup time after a slow bundled preflight', async () => { + let elapsedMs = 0 + const clock = vi.spyOn(Date, 'now').mockImplementation(() => elapsedMs) + scriptHost({ + activationRecord: '', + readiness: { [NEW_VERSION]: readyLine({}) }, + readinessAtMs: 100_000, + log: [] + }) + try { + const result = await deployOrcad( + options({ + readinessTimeoutMs: undefined, + sleep: async () => { + elapsedMs += 50_000 + } + }) + ) + expect(result.outcome).toBe('installed-and-activated') + expect(elapsedMs).toBe(100_000) + } finally { + clock.mockRestore() + } + }) + it('activates a healthy candidate and records the outgoing version as the rollback target', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, @@ -286,6 +485,7 @@ describe('deployOrcad', () => { const result = await deployOrcad(options()) expect(result).toMatchObject({ outcome: 'installed-not-activated' }) expect(script.log).toEqual([ + 'preflight', `stop:${OLD_VERSION}`, 'snapshot', `launch:${NEW_VERSION}`, @@ -313,6 +513,7 @@ describe('deployOrcad', () => { expect(result).toMatchObject({ outcome: 'installed-not-activated' }) expect(script.log).toEqual([ + 'preflight', `stop:${OLD_VERSION}`, 'snapshot', `launch:${NEW_VERSION}`, @@ -341,7 +542,12 @@ describe('deployOrcad', () => { scriptHost(script) await expect(deployOrcad(options())).rejects.toThrow('incumbent was stopped') - expect(script.log).toEqual([`stop:${OLD_VERSION}`, 'snapshot', `launch:${OLD_VERSION}`]) + expect(script.log).toEqual([ + 'preflight', + `stop:${OLD_VERSION}`, + 'snapshot', + `launch:${OLD_VERSION}` + ]) }) it.each(['NO_PID', 'STILL_RUNNING', 'SIGNAL_FAILED', ''])( @@ -358,6 +564,7 @@ describe('deployOrcad', () => { await deployOrcad(options()) expect(script.log).toEqual([ + 'preflight', `stop:${OLD_VERSION}`, 'snapshot', `launch:${NEW_VERSION}`, diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index be9837ff31e..f82b4014d57 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -4,18 +4,11 @@ * preserve current state and the prelaunch snapshot for explicit recovery. */ import type { SshConnection } from './ssh-connection' +import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { execCommand } from './ssh-relay-deploy-helpers' -import { shellEscape } from './ssh-connection-utils' import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' -import { - abandonInstall, - computeRemoteInstallDir, - finalizeInstall, - isRemoteInstallComplete, - readLocalFullVersion -} from './ssh-relay-versioned-install' +import { writeRelayFile } from './ssh-relay-install-transfers' +import { computeRemoteInstallDir, readLocalFullVersion } from './ssh-relay-versioned-install' import { RELAY_REMOTE_DIR } from './relay-protocol' import { ORCAD_STATE_SNAPSHOT_DIR, @@ -46,13 +39,18 @@ import { } from './orcad-remote-process-control' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' +import { preflightInstalledOrcad } from './orcad-remote-preflight' +import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { installOrcadBundle } from './orcad-remote-install' +import { materializeOrcadArtifact } from './orcad-artifact-materializer' +import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' export type OrcadDeployOptions = { conn: SshConnection host: RemoteHostPlatform remoteHome: string - /** Local `out/orcad`, containing the artifacts and the `.version` marker. */ - localOrcadDir: string + /** An already assembled bundle; otherwise materialize the packaged template for this host. */ + localOrcadDir?: string nodePath: string userDataDir: string bindHost: string @@ -75,7 +73,6 @@ export type OrcadDeployResult = | { outcome: 'already-active'; fullVersion: string } | { outcome: 'installed-not-activated'; fullVersion: string; code: string; reason: string } -const DEFAULT_READINESS_TIMEOUT_MS = 90_000 const READINESS_POLL_MS = 500 const STOP_WAIT_SECONDS = 20 @@ -90,53 +87,6 @@ function baseDir(options: OrcadDeployOptions): string { return joinRemotePath(options.host, options.remoteHome, RELAY_REMOTE_DIR) } -/** Install the bytes under `orcad-/`, using the relay's install transaction. */ -async function installOrcadBundle( - options: OrcadDeployOptions, - fullVersion: string, - remoteDir: string -): Promise { - if ( - await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { - signal: options.signal - }) - ) { - return - } - await acquireInstallLock(options.conn, remoteDir, options.host, { signal: options.signal }) - try { - // Re-probe under the lock: a sibling deploy may have finished while we waited. - if ( - await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { - signal: options.signal - }) - ) { - return - } - await uploadRelayDirectory(options.conn, options.localOrcadDir, remoteDir, options.host, { - signal: options.signal - }) - const { host } = options - if (host.os !== 'win32') { - // SFTP creates uploaded files with 0644 even when the source binary is executable. - const binaryPath = joinRemotePath(host, remoteDir, 'ripgrep', host.relayPlatform, 'rg') - await exec(options, `chmod 755 ${shellEscape(binaryPath)}`) - } - await writeRelayFile( - options.conn, - options.host, - joinRemotePath(options.host, remoteDir, ORCAD_INSTALL_MODEL.versionFilename), - fullVersion, - { signal: options.signal } - ) - await finalizeInstall(options.conn, remoteDir, options.host, { signal: options.signal }) - } catch (error) { - // Leave a recoverable partial rather than a dir that probes complete. - await abandonInstall(options.conn, remoteDir, options.host) - throw error - } -} - async function captureSnapshot( options: OrcadDeployOptions, fullVersion: string, @@ -186,7 +136,7 @@ async function launchAndAwaitReadiness( options, orcadLaunchCommand(options.host, { ...options, remoteInstallDir, fullVersion }) ) - const deadline = Date.now() + (options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS) + const deadline = Date.now() + (options.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) let last = parseOrcadReadinessOutput('') while (Date.now() < deadline) { @@ -247,7 +197,16 @@ async function restoreIncumbent( } /** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */ -export async function deployOrcad(options: OrcadDeployOptions): Promise { +export async function deployOrcad(input: OrcadDeployOptions): Promise { + assertPosixOrcadHost(input.host) + const options = { + ...input, + localOrcadDir: + input.localOrcadDir ?? + (await materializeOrcadArtifact(await resolveOrcadDeploymentTarget(input), { + signal: input.signal + })) + } const now = options.now ?? ((): Date => new Date()) const fullVersion = readLocalFullVersion(options.localOrcadDir) const remoteDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, fullVersion) @@ -273,6 +232,24 @@ export async function deployOrcad(options: OrcadDeployOptions): Promise/`, using the relay's install transaction. */ +export async function installOrcadBundle( + options: { + conn: SshConnection + host: RemoteHostPlatform + localOrcadDir: string + signal?: AbortSignal + }, + fullVersion: string, + remoteDir: string +): Promise { + if ( + await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { + signal: options.signal + }) + ) { + return + } + await acquireInstallLock(options.conn, remoteDir, options.host, { signal: options.signal }) + try { + // Re-probe under the lock: a sibling deploy may have finished while we waited. + if ( + await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, { + signal: options.signal + }) + ) { + return + } + await uploadRelayDirectory(options.conn, options.localOrcadDir, remoteDir, options.host, { + signal: options.signal + }) + if (options.host.os !== 'win32') { + await execCommand(options.conn, executablePermissionsCommand(options.host, remoteDir), { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }) + } + await writeRelayFile( + options.conn, + options.host, + joinRemotePath(options.host, remoteDir, ORCAD_INSTALL_MODEL.versionFilename), + fullVersion, + { signal: options.signal } + ) + await finalizeInstall(options.conn, remoteDir, options.host, { + signal: options.signal, + releaseLock: false + }) + } finally { + await abandonInstall(options.conn, remoteDir, options.host) + } +} + +function executablePermissionsCommand(host: RemoteHostPlatform, directory: string): string { + const required = [ + joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os)), + joinRemotePath(host, directory, 'ripgrep', host.relayPlatform, 'rg') + ] + const browsers = new Set( + (['glibc', 'musl'] as const).map((libc) => + shellEscape( + joinRemotePath(host, directory, orcadAgentBrowserNativeName(host.os, host.arch, libc)) + ) + ) + ) + // SFTP drops executable modes; missing optional browser binaries remain a supported install. + return ( + `chmod 755 ${required.map(shellEscape).join(' ')} && ` + + `for executable in ${[...browsers].join(' ')}; do ` + + 'if [ -f "$executable" ]; then chmod 755 "$executable" || exit $?; fi; done' + ) +} diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index 3abd43ed1d9..d6c9640ad33 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -20,6 +20,7 @@ import { posixProcessAliveShellFunction } from './orcad-remote-host-support' import type { ServeReadiness } from '../server/serve-readiness' +import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' /** Stdout of the launched candidate: exactly one `orca_server_ready` line, then nothing. */ export const ORCAD_READINESS_FILENAME = '.orcad-readiness' @@ -56,6 +57,7 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, 'orcad.js')) return [ `cd ${dir} &&`, + `${selectOrcadSlotRuntimeCommand(host, spec.remoteInstallDir, spec.nodePath)} &&`, // Why truncate: a re-launch into a dir that already holds a previous readiness line would // otherwise let the deploy activate on the OLD process's health payload. `: > ${readiness} &&`, @@ -63,7 +65,7 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, // Keep $! equal to the runtime PID rather than a waiting shell's PID. - `exec nohup ${shellEscape(spec.nodePath)} ${entry}`, + `exec nohup "$orcad_runtime" ${entry}`, `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, `> ${readiness} 2>> ${log} < /dev/null &`, `echo $! > ${pidFile} && cat ${pidFile}` diff --git a/src/main/ssh/orcad-remote-preflight.ts b/src/main/ssh/orcad-remote-preflight.ts new file mode 100644 index 00000000000..5bba386d461 --- /dev/null +++ b/src/main/ssh/orcad-remote-preflight.ts @@ -0,0 +1,45 @@ +import { randomUUID } from 'node:crypto' +import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { + ORCAD_PROFILE_PREFLIGHT_FLAG, + ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS, + parseOrcadProfilePreflight +} from '../../shared/orcad-profile-preflight' +import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { execCommand } from './ssh-relay-deploy-helpers' +import { shellEscape } from './ssh-connection-utils' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import type { SshConnection } from './ssh-connection' + +export function orcadProfilePreflightCommand( + host: RemoteHostPlatform, + directory: string, + nonce: string +): string { + assertPosixOrcadHost(host) + return [ + 'ORCA_BACKGROUND_LAUNCH=1', + shellEscape(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os))), + shellEscape(joinRemotePath(host, directory, 'orcad.js')), + ORCAD_PROFILE_PREFLIGHT_FLAG, + shellEscape(nonce) + ].join(' ') +} + +/** Failure leaves the incumbent and its data untouched, including an unconfirmed SSH exit. */ +export async function preflightInstalledOrcad(options: { + conn: SshConnection + host: RemoteHostPlatform + remoteInstallDir: string + fullVersion: string + signal?: AbortSignal +}): Promise { + const nonce = randomUUID() + const output = await execCommand( + options.conn, + orcadProfilePreflightCommand(options.host, options.remoteInstallDir, nonce), + { signal: options.signal, timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS } + ) + parseOrcadProfilePreflight(output, nonce, ORCAD_BUN_VERSION, options.fullVersion) +} diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index c7f78331bd5..33fd877ba41 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -1,16 +1,13 @@ /** * Stopping a running orcad on the host without taking its terminals with it. * - * `SIGKILL` is absent on purpose. orcad's own escalation contract (`orcad-entry.ts`) is - * SIGTERM, then a second SIGTERM meaning "your deadline elapsed, exit now"; a kill skips the - * teardown that releases the instance lock and disconnects — rather than shuts down — the - * terminal daemon. The daemon is detached and would survive a kill, but a stop that leaves - * the lock file behind makes the successor refuse to start with - * `orcad_data_root_shared`, so the update turns into an outage for no gain. + * SIGTERM starts one bounded durable shutdown. If it outlasts this wait, preserve the + * current owner; SIGKILL would skip flushing state and releasing the instance lock. */ import { shellEscape } from './ssh-connection-utils' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' +import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { assertPosixOrcadHost as assertPosixHost, ORCAD_PID_FILENAME, @@ -48,7 +45,8 @@ export function stopOrcadCommand( ...(options.justLaunched ? [] : [ - `runtime_pid=$(${shellEscape(options.nodePath)} -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, + `runtime_pid=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + + `"$orcad_runtime" -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, '[ "$pid" = "$runtime_pid" ] || { echo UNKNOWN; exit 0; };' ]), 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts index b9c74158a6d..4d2f9150a75 100644 --- a/src/main/ssh/orcad-remote-rollback.test.ts +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -68,7 +68,10 @@ function readyLine(version: string): string { }) } -function scriptHost(log: string[], overrides: { restore?: string } = {}): void { +function scriptHost( + log: string[], + overrides: { restore?: string; readinessAtMs?: number } = {} +): void { mockExec.mockImplementation(async (_conn, command: string) => { const text = String(command) if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { @@ -90,6 +93,9 @@ function scriptHost(log: string[], overrides: { restore?: string } = {}): void { return '9999' } if (text.startsWith('cat ') && text.includes('.orcad-readiness')) { + if (overrides.readinessAtMs !== undefined && Date.now() < overrides.readinessAtMs) { + return '' + } return readyLine(TARGET) } return '' @@ -130,6 +136,28 @@ describe('rollbackOrcad', () => { expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) }) + it('allows rollback startup time after a slow bundled preflight', async () => { + let elapsedMs = 0 + const clock = vi.spyOn(Date, 'now').mockImplementation(() => elapsedMs) + const log: string[] = [] + scriptHost(log, { readinessAtMs: 100_000 }) + try { + const result = await rollbackOrcad( + options({ + readinessTimeoutMs: undefined, + sleep: async () => { + elapsedMs += 50_000 + } + }) + ) + expect(result.outcome).toBe('rolled-back') + expect(elapsedMs).toBe(100_000) + expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) + } finally { + clock.mockRestore() + } + }) + it('refuses before touching anything when terminals started after activation', async () => { const log: string[] = [] scriptHost(log) diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts index 985bc05a7b3..412021c2f22 100644 --- a/src/main/ssh/orcad-remote-rollback.ts +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -14,6 +14,7 @@ * failure this is meant to avoid, arrived at from the other side. */ import type { SshConnection } from './ssh-connection' +import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { execCommand } from './ssh-relay-deploy-helpers' import { ORCAD_INSTALL_MODEL } from './remote-install-model' import { computeRemoteInstallDir } from './ssh-relay-versioned-install' @@ -71,7 +72,6 @@ export type OrcadRollbackResult = | { outcome: 'refused'; code: string; reason: string } | { outcome: 'failed'; code: string; reason: string } -const DEFAULT_READINESS_TIMEOUT_MS = 90_000 const READINESS_POLL_MS = 500 const STOP_WAIT_SECONDS = 20 @@ -196,7 +196,7 @@ export async function rollbackOrcad(options: OrcadRollbackOptions): Promise new Promise((r) => setTimeout(r, ms))) let parsed = parseOrcadReadinessOutput('') while (Date.now() < deadline && parsed.state === 'pending') { diff --git a/src/main/ssh/orcad-remote-runtime.test.ts b/src/main/ssh/orcad-remote-runtime.test.ts new file mode 100644 index 00000000000..637ab61e670 --- /dev/null +++ b/src/main/ssh/orcad-remote-runtime.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' +import { stopOrcadCommand } from './orcad-remote-process-control' + +const directories: string[] = [] +const host = getRemoteHostPlatform('linux-x64') + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function fixture(): string { + const directory = mkdtempSync(join(tmpdir(), "orca 'quoted' $slot-")) + directories.push(directory) + return directory +} + +function launch(directory: string, nodePath: string) { + return runProcessSync({ + program: '/bin/sh', + args: [ + '-c', + `${selectOrcadSlotRuntimeCommand(host, directory, nodePath)}; ` + + '"$orcad_runtime" -e \'process.stdout.write("selected")\'' + ] + }) +} + +describe.skipIf(process.platform === 'win32')('POSIX slot runtime selection', () => { + it('returns an unverifiable stop result when a bundled runtime cannot execute', () => { + const directory = fixture() + writeFileSync(join(directory, '.build-target'), 'linux-x64-glibc') + writeFileSync(join(directory, '.orcad-pid'), String(process.pid)) + const result = runProcessSync({ + program: '/bin/sh', + args: [ + '-c', + stopOrcadCommand(host, directory, { waitSeconds: 1, nodePath: process.execPath }) + ] + }) + expect(result).toMatchObject({ code: 0, stdout: 'UNKNOWN\n' }) + }) + + it('uses the bundled executable when host Node does not exist', () => { + const directory = fixture() + writeFileSync(join(directory, '.build-target'), 'linux-x64-glibc') + symlinkSync(process.execPath, join(directory, 'bun-runtime')) + expect(launch(directory, '/missing-host-node')).toMatchObject({ code: 0, stdout: 'selected' }) + }) + + it('refuses an incomplete Bun slot before invoking a working host Node', () => { + const directory = fixture() + writeFileSync(join(directory, '.build-target'), 'linux-x64-glibc') + expect(launch(directory, process.execPath)).toMatchObject({ code: 78, stdout: '' }) + }) + + it('retains the original runtime for a legacy slot', () => { + expect(launch(fixture(), process.execPath)).toMatchObject({ code: 0, stdout: 'selected' }) + }) +}) diff --git a/src/main/ssh/orcad-remote-runtime.ts b/src/main/ssh/orcad-remote-runtime.ts new file mode 100644 index 00000000000..505965620a0 --- /dev/null +++ b/src/main/ssh/orcad-remote-runtime.ts @@ -0,0 +1,20 @@ +import { ORCAD_BUILD_TARGET_FILENAME, orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' +import { assertPosixOrcadHost } from './orcad-remote-host-support' +import { shellEscape } from './ssh-connection-utils' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' + +/** Only legacy slots may use host Node; an incomplete Bun slot must not change runtimes. */ +export function selectOrcadSlotRuntimeCommand( + host: RemoteHostPlatform, + directory: string, + legacyNodePath: string +): string { + assertPosixOrcadHost(host) + const runtime = shellEscape(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os))) + const target = shellEscape(joinRemotePath(host, directory, ORCAD_BUILD_TARGET_FILENAME)) + return ( + `if [ -e ${target} ] || [ -e ${runtime} ]; then ` + + `[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; ` + + `else orcad_runtime=${shellEscape(legacyNodePath)}; fi` + ) +} diff --git a/src/main/ssh/remote-install-model.test.ts b/src/main/ssh/remote-install-model.test.ts index a12b2e8342f..fa32ea81940 100644 --- a/src/main/ssh/remote-install-model.test.ts +++ b/src/main/ssh/remote-install-model.test.ts @@ -21,6 +21,18 @@ import { const RELAY_DIRS = ['relay-0.1.0+abcdef123456', 'relay-v0.1.0', 'relay-1.2.3'] const ORCAD_DIRS = ['orcad-0.1.0+abcdef123456', 'orcad-v0.1.0', 'orcad-1.2.3'] +it.each([false, true])( + 'requires the executable and both profile workers on Windows=%s', + (isWindows) => { + const artifacts = ORCAD_INSTALL_MODEL.requiredArtifacts(isWindows) + expect(artifacts).toContain(isWindows ? 'bun-runtime.exe' : 'bun-runtime') + expect(artifacts).not.toContain(isWindows ? 'bun-runtime' : 'bun-runtime.exe') + expect(artifacts).toContain('profile-state-writer-worker-entry.js') + expect(artifacts).toContain('profile-state-backup-worker-entry.js') + expect(artifacts.includes('windows-process-tree.node')).toBe(isWindows) + } +) + describe('remote install namespace', () => { it('names each model its own version dir', () => { expect(remoteInstallDirName(RELAY_INSTALL_MODEL, '0.1.0+aa')).toBe('relay-0.1.0+aa') @@ -30,7 +42,9 @@ describe('remote install namespace', () => { it('requires every shipped search binary in a completed standalone runtime install', () => { const required = ORCAD_INSTALL_MODEL.requiredArtifacts(false) expect(required).toEqual(expect.arrayContaining([...ORCAD_RIPGREP_ARTIFACTS])) - expect(ORCAD_INSTALL_MODEL.requiredArtifacts(true)).toEqual(required) + expect(ORCAD_INSTALL_MODEL.requiredArtifacts(true)).toEqual( + expect.arrayContaining([...ORCAD_RIPGREP_ARTIFACTS]) + ) }) it.skipIf(process.platform === 'win32')('rejects an install missing its search binary', () => { diff --git a/src/main/ssh/remote-install-model.ts b/src/main/ssh/remote-install-model.ts index 54be330a55f..e2a46fda547 100644 --- a/src/main/ssh/remote-install-model.ts +++ b/src/main/ssh/remote-install-model.ts @@ -53,9 +53,7 @@ export const ORCAD_INSTALL_MODEL: RemoteInstallModel = { nativeDepsPackageName: 'orca-orcad', versionFilename: ORCAD_VERSION_FILENAME, installCompleteFilename: ORCAD_INSTALL_COMPLETE_FILENAME, - // Why the parameter is ignored: orcad's forked children are the same three .js files on - // every host. The Windows-only console-list agent patch is a relay/node-pty concern. - requiredArtifacts: () => orcadArtifactFilenames() + requiredArtifacts: (isWindows) => orcadArtifactFilenames(isWindows ? 'win32' : '') } export const REMOTE_INSTALL_MODELS: readonly RemoteInstallModel[] = [ diff --git a/src/main/windows/windows-pty-job.ts b/src/main/windows/windows-pty-job.ts index 169db375f3b..814f2dbaa8a 100644 --- a/src/main/windows/windows-pty-job.ts +++ b/src/main/windows/windows-pty-job.ts @@ -1,4 +1,6 @@ import type { IPty } from 'node-pty' +import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities' +import { loadWindowsBunPtyJobNative } from '../daemon/pty-subprocess/windows-bun-pty-native' import { createRequire } from 'node:module' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' @@ -33,6 +35,19 @@ type ConptyNative = { assignCurrentProcessToJob: () => boolean } +type SelfOwnedPty = IPty & { + jobRootProcessIsWrapper?: true + shellProcessId?: number + terminateOwnedTree?: () => JobTerminationOutcome + listOwnedProcessIds?: () => readonly number[] | null +} + +/** The gate's pid never proves that its user shell remains alive. */ +export function ptyShellProcessId(proc: IPty): number | undefined { + const owned: SelfOwnedPty = proc + return owned.jobRootProcessIsWrapper ? owned.shellProcessId : proc.pid +} + let cachedNative: ConptyNative | null | undefined let nativeLoader: () => ConptyNative | null = loadConptyNative @@ -89,6 +104,23 @@ export type JobTerminationOutcome = 'terminated' | 'unavailable' * to be misread as "nothing to kill". */ export function terminatePtyJob(proc: IPty): JobTerminationOutcome { + const owned: SelfOwnedPty = proc + if (typeof owned.terminateOwnedTree === 'function') { + let outcome: JobTerminationOutcome + try { + outcome = owned.terminateOwnedTree() + } catch { + return 'unavailable' + } + if (outcome === 'terminated') { + recordSelfInitiatedTreeKill({ + pid: proc.pid, + site: 'windows-pty-job-teardown', + scope: 'win-pty-job' + }) + } + return outcome + } const target = ptyJobTarget(proc) const native = nativeLoader() if (!target || !native) { @@ -132,6 +164,14 @@ export function terminatePtyJob(proc: IPty): JobTerminationOutcome { * including children that detached from the console. */ export function listPtyJobProcessIds(proc: IPty): readonly number[] | null { + const owned: SelfOwnedPty = proc + if (typeof owned.listOwnedProcessIds === 'function') { + try { + return owned.listOwnedProcessIds() + } catch { + return null + } + } const target = ptyJobTarget(proc) const native = nativeLoader() if (!target || !native) { @@ -193,7 +233,7 @@ function assignHostProcessOnce(): boolean { /** Whether this build can own PTY trees with job objects at all. */ export function isPtyJobOwnershipAvailable(): boolean { - return nativeLoader() !== null + return canUseBunPty() ? loadWindowsBunPtyJobNative() !== null : nativeLoader() !== null } /** Test-only: substitute the native module (it is resolved via createRequire). */ diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index b4941cc1ed8..ed21cc84dd5 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -130,7 +130,6 @@ src/main/providers/process-cwd.ts src/main/providers/windows-console-attached-processes.ts src/main/pty-descendant-termination.ts src/main/pty/posix-pty-foreground-group.ts -src/main/pty/posix-pty-process-groups.ts src/main/pty/windows-environment-path.ts src/main/rate-limits/codex-fetcher.ts src/main/rate-limits/gemini-cli-oauth-extractor.ts diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index 3f303f571d1..6de7701b81b 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -37,7 +37,6 @@ main/providers/macos-login-session-pty-probe.ts main/providers/process-cwd.ts main/pty-descendant-termination.ts main/pty/posix-pty-foreground-group.ts -main/pty/posix-pty-process-groups.ts main/pty/windows-environment-path.ts main/rate-limits/codex-fetcher.ts main/runtime/tls-certificate.ts diff --git a/src/shared/child-process/child-process-import-boundary.test.ts b/src/shared/child-process/child-process-import-boundary.test.ts index 2ae150cc8be..917a8a58beb 100644 --- a/src/shared/child-process/child-process-import-boundary.test.ts +++ b/src/shared/child-process/child-process-import-boundary.test.ts @@ -29,7 +29,7 @@ const CHILD_PROCESS_IMPORT_ALLOWLIST: readonly string[] = readFileSync( * May only ever be DECREASED, and only by migrating a file off * `node:child_process`. Raising it is never the fix. */ -const DIRECT_IMPORTER_PIN = 152 +const DIRECT_IMPORTER_PIN = 151 const IMPORT_PATTERN = /(?:from\s+['"]node:child_process['"]|from\s+['"]child_process['"]|require\(\s*['"]node:child_process['"]|require\(\s*['"]child_process['"])/ diff --git a/src/shared/child-process/windows-command-line.ts b/src/shared/child-process/windows-command-line.ts index e3402f93c19..a7821b5a05c 100644 --- a/src/shared/child-process/windows-command-line.ts +++ b/src/shared/child-process/windows-command-line.ts @@ -83,6 +83,15 @@ export function quoteWindowsCmdArgument(value: string): string { return quoteWindows(value, true) } +export function validateWindowsCmdArguments(values: readonly string[]): void { + for (const value of values) { + // cmd ends a command at CR/LF regardless of quoting. + if (/[\r\n]/.test(value)) { + throw new Error('cmd.exe cannot receive an argument containing a line break') + } + } +} + /** * Build the argv Node should spawn to run `program` with `args` through * `cmd.exe`, for targets cmd must interpret (`.cmd`, `.bat`). @@ -99,19 +108,7 @@ export function quoteWindowsCmdArgument(value: string): string { * outer quote pair and treat the rest verbatim. */ export function buildWindowsCmdShimCommandLine(program: string, args: readonly string[]): string { - // Why reject rather than encode: cmd's line parser ends the command at a raw - // CR or LF whatever the quote state, so there is no escape for it -- quoting - // does not survive a line break. Encoding one anyway truncates the argument - // and can leave the remainder to be interpreted as a further command. Agent - // prompts are the motivating input here and can contain newlines, so this - // has to fail loudly rather than silently mangle. Recognised npm/pnpm shims - // no longer reach this line at all — windows-cmd-shim-resolution.ts spawns - // their target directly, where a newline is just another character. - for (const value of [program, ...args]) { - if (/[\r\n]/.test(value)) { - throw new Error('cmd.exe cannot receive an argument containing a line break') - } - } + validateWindowsCmdArguments([program, ...args]) // The program path needs the same treatment as the arguments: it is just as // likely to contain `%USERNAME%`, and cmd expands it just the same. const inner = [program, ...args].map(quoteWindowsCmdArgument).join(' ') diff --git a/src/shared/child-process/windows-console-visibility.test.ts b/src/shared/child-process/windows-console-visibility.test.ts index 8331f18892a..0f92d943d29 100644 --- a/src/shared/child-process/windows-console-visibility.test.ts +++ b/src/shared/child-process/windows-console-visibility.test.ts @@ -34,7 +34,7 @@ const ALLOWLIST: readonly string[] = readAllowlist( * the allowlist does not bound this: a swap (one file fixed and delisted, one * new file added with its entry) satisfies both membership assertions. */ -const UNHIDDEN_SPAWNER_PIN = 62 +const UNHIDDEN_SPAWNER_PIN = 61 const CHILD_PROCESS_IMPORT = /from\s+['"](?:node:)?child_process['"]|require\(\s*['"](?:node:)?child_process['"]/ diff --git a/src/shared/orcad-agent-browser-name.ts b/src/shared/orcad-agent-browser-name.ts new file mode 100644 index 00000000000..5cd22beaa50 --- /dev/null +++ b/src/shared/orcad-agent-browser-name.ts @@ -0,0 +1,10 @@ +export function orcadAgentBrowserNativeName( + platformName: NodeJS.Platform, + architecture: string, + linuxLibc: 'glibc' | 'musl' = 'glibc' +): string { + const ext = platformName === 'win32' ? '.exe' : '' + const platformToken = + platformName === 'linux' && linuxLibc === 'musl' ? 'linux-musl' : platformName + return `agent-browser-${platformToken}-${architecture}${ext}` +} diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index aa737ea4aed..fea71dc63c4 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -8,6 +8,27 @@ * Keep this file erasable-only TypeScript — build-orcad.mjs imports it directly under * Node's type stripping, which rejects enums, namespaces and parameter properties. */ +export const ORCAD_BUN_RUNTIME_FILENAME = 'bun-runtime' +export const ORCAD_WINDOWS_BUN_RUNTIME_FILENAME = 'bun-runtime.exe' +export const ORCAD_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node' + +export function orcadBunRuntimeFilename(target: string): string { + return target === 'win32' || target.startsWith('win32-') + ? ORCAD_WINDOWS_BUN_RUNTIME_FILENAME + : ORCAD_BUN_RUNTIME_FILENAME +} + +/** Keep renamed Windows executables in a new content-addressed slot. */ +export function orcadArtifactHashPrefix(target: string): string { + return orcadBunRuntimeFilename(target) === ORCAD_WINDOWS_BUN_RUNTIME_FILENAME + ? `${ORCAD_WINDOWS_BUN_RUNTIME_FILENAME}\0` + : '' +} +export const ORCAD_BUILD_TARGET_FILENAME = '.build-target' +export const ORCAD_PARCEL_WATCHER_ENTRY = 'node_modules/@parcel/watcher/index.js' +export const ORCAD_PARCEL_WATCHER_NATIVE = 'node_modules/@parcel/watcher/watcher.node' +export const ORCAD_EMOJI_SHORTCODE_DATASET = + 'node_modules/emojibase-data/en/shortcodes/emojibase.json' export const ORCAD_VERSION = '0.1.0' @@ -49,20 +70,45 @@ export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ { filename: 'parcel-watcher-process-entry.js' }, // Forked so PTYs outlive the runtime process; its absence makes every restart destructive. { filename: 'daemon-entry.js' }, + { filename: 'windows-bun-pty-gate-entry.js' }, { filename: 'profile-state-writer-worker-entry.js' }, { filename: 'profile-state-backup-worker-entry.js' }, + // Target-specific even when the JavaScript bundle is shared across packaged slots. + { filename: ORCAD_BUILD_TARGET_FILENAME }, + // orcad never depends on a host runtime or host-installed native module. + { filename: ORCAD_BUN_RUNTIME_FILENAME }, + { filename: ORCAD_PARCEL_WATCHER_ENTRY }, + { filename: ORCAD_PARCEL_WATCHER_NATIVE }, + { filename: ORCAD_EMOJI_SHORTCODE_DATASET }, ...ORCAD_RIPGREP_ARTIFACTS.map((filename) => ({ filename })), ...ORCAD_RIPGREP_LICENSE_ARTIFACTS.map((filename) => ({ filename })) ] /** Written after the artifacts, so it is never an input to its own hash. */ export const ORCAD_VERSION_FILENAME = '.version' +export const ORCAD_TEMPLATE_MANIFEST_FILENAME = 'orcad-template.json' +export const ORCAD_TEMPLATE_TARGETS_DIR = 'targets' /** Written last by the installer; its absence means a torn install. */ export const ORCAD_INSTALL_COMPLETE_FILENAME = '.install-complete' -export function orcadArtifactFilenames(): string[] { - return ORCAD_ARTIFACTS.filter((artifact) => !artifact.optional).map( - (artifact) => artifact.filename +export function orcadArtifactFilenames(target = ''): string[] { + const filenames = ORCAD_ARTIFACTS.filter((artifact) => !artifact.optional).map((artifact) => + artifact.filename === ORCAD_BUN_RUNTIME_FILENAME + ? orcadBunRuntimeFilename(target) + : artifact.filename + ) + if (target === 'win32' || target.startsWith('win32-')) { + filenames.push(ORCAD_WINDOWS_PROCESS_TREE_FILENAME) + } + return filenames +} + +export function orcadTemplateCommonFilenames(): string[] { + return orcadArtifactFilenames().filter( + (filename) => + filename !== ORCAD_BUN_RUNTIME_FILENAME && + filename !== ORCAD_BUILD_TARGET_FILENAME && + filename !== ORCAD_PARCEL_WATCHER_NATIVE ) } diff --git a/src/shared/orcad-bun-runtime.ts b/src/shared/orcad-bun-runtime.ts new file mode 100644 index 00000000000..76fc03258c8 --- /dev/null +++ b/src/shared/orcad-bun-runtime.ts @@ -0,0 +1,72 @@ +export const ORCAD_BUN_VERSION = '1.4.2' + +export const ORCAD_BUN_TARGETS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64-glibc', + 'linux-x64-glibc', + 'linux-arm64-musl', + 'linux-x64-musl', + 'win32-arm64', + 'win32-x64' +] as const + +export type OrcadBunTarget = (typeof ORCAD_BUN_TARGETS)[number] + +// Managed SSH deployment supports POSIX hosts; Windows uses standalone builds. +export const ORCAD_TEMPLATE_TARGETS = ORCAD_BUN_TARGETS.filter( + (target) => !target.startsWith('win32-') +) + +export type OrcadBunReleaseAsset = { + filename: string + sha256: string + executableSha256: string +} + +export const ORCAD_BUN_RELEASE_ASSETS: Record = { + 'darwin-arm64': { + filename: 'bun-darwin-aarch64.zip', + sha256: '90987a3a16d7db556d886ac3d551e7b6d3edf0a1cf43acaed622e8676be1d12f', + executableSha256: '35d20dd0263e5c950194434b925454fdfa9ba6e4467da960410fa05b08a7a5b5' + }, + 'darwin-x64': { + filename: 'bun-darwin-x64.zip', + sha256: '80520d7e17526308c9185d261679ac6d27798d3803a0e9f7ff9121ab8affb012', + executableSha256: '2fa513af22ac59e03aae640cad302e73cb1ddb0f6398501e2ddccf7dcd613596' + }, + 'linux-arm64-glibc': { + filename: 'bun-linux-aarch64.zip', + sha256: '54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7', + executableSha256: '616f267a34278ff5ac282df37ffdfba1d7141f4f6926bca99af2cd6ef3ad32b1' + }, + 'linux-x64-glibc': { + filename: 'bun-linux-x64.zip', + sha256: '36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913', + executableSha256: 'a83d263767d839e4d2649ca8e35d07159c7afc99afdc96d731ced29e056dda0c' + }, + 'linux-arm64-musl': { + filename: 'bun-linux-aarch64-musl.zip', + sha256: '71760b6c8ea30623b81a4907cb815d48e2ea266f2e73e751534a44a0607950df', + executableSha256: '1101cd0aa92ea214c2aaf4bb3761ca3c76a90aae0e6f94c07efb4e8c4f18a8fc' + }, + 'linux-x64-musl': { + filename: 'bun-linux-x64-musl.zip', + sha256: '4835eca59d6da70f4674f5642f6e459dcadab773695b2ed9922d131057989742', + executableSha256: '16b72935ffd7a503b978c186874539c92aade4e3515b70a5abf5db2581fdef7d' + }, + 'win32-arm64': { + filename: 'bun-windows-aarch64.zip', + sha256: 'a7a16b876a305fd1029c66dbd27007b4f6112ae896532f675878731a21e50cfd', + executableSha256: '3d7e98d3201c55c3bde6c069a5dfa0d34da9edc9145187c76594f685f8aa54c6' + }, + 'win32-x64': { + filename: 'bun-windows-x64.zip', + sha256: 'ce4c17497b2f29712a99d3d53f028de28cd42e3bacb8589599e7f000e49b6405', + executableSha256: '15277c59ccd6c6c20f8dc9716c2b59c1776320d606b6a8658f70be8799519ca4' + } +} + +export function orcadBunReleaseUrl(asset: OrcadBunReleaseAsset): string { + return `https://github.com/oven-sh/bun/releases/download/bun-v${ORCAD_BUN_VERSION}/${asset.filename}` +} diff --git a/src/shared/orcad-profile-preflight.test.ts b/src/shared/orcad-profile-preflight.test.ts new file mode 100644 index 00000000000..54dd7d819d3 --- /dev/null +++ b/src/shared/orcad-profile-preflight.test.ts @@ -0,0 +1,49 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { parseOrcadProfilePreflight } from './orcad-profile-preflight' + +const response = { + type: 'orca_profile_state_ready', + nonce: randomUUID(), + runtime: 'bun', + runtimeVersion: '1.4.2', + artifactVersion: '0.1.0+123456789abc', + sqliteVersion: '3.51.0', + revision: 1 +} + +function parse(value: unknown) { + return parseOrcadProfilePreflight( + JSON.stringify(value), + response.nonce, + response.runtimeVersion, + response.artifactVersion + ) +} + +describe('candidate profile readiness', () => { + it('admits an acknowledged write and backup under the expected installed runtime', () => { + expect(parse(response)).toEqual(response) + }) + + it.each([ + { nonce: randomUUID() }, + { runtime: 'node' }, + { runtimeVersion: '1.4.0' }, + { artifactVersion: '0.1.0+000000000000' }, + { revision: 0 }, + { sqliteVersion: '' } + ])('refuses stale or incomplete evidence: %j', (change) => { + expect(() => parse({ ...response, ...change })).toThrow() + }) + + it('does not choose a successful line out of contradictory output', () => { + expect(() => + parseOrcadProfilePreflight( + `${JSON.stringify(response)}\n${JSON.stringify({ ...response, revision: 0 })}`, + response.nonce, + response.runtimeVersion + ) + ).toThrow() + }) +}) diff --git a/src/shared/orcad-profile-preflight.ts b/src/shared/orcad-profile-preflight.ts new file mode 100644 index 00000000000..2541a09ebde --- /dev/null +++ b/src/shared/orcad-profile-preflight.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' + +export const ORCAD_PROFILE_PREFLIGHT_FLAG = '--orcad-profile-state-preflight' +export const ORCAD_STARTUP_PREFLIGHT_FLAG = '--orcad-startup-preflight' +export const ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS = 90_000 +// Server startup follows the disposable native/SQLite probe on every bundled launch. +export const ORCAD_STARTUP_READINESS_TIMEOUT_MS = ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS + 90_000 + +export const orcadProfilePreflightResponseSchema = z.object({ + type: z.literal('orca_profile_state_ready'), + nonce: z.string().uuid(), + runtime: z.enum(['node', 'bun']), + runtimeVersion: z.string().min(1), + artifactVersion: z.string().regex(/^\d+\.\d+\.\d+\+[a-f0-9]{12}$/), + sqliteVersion: z.string().min(1), + revision: z.number().int().positive() +}) + +export type OrcadProfilePreflightResponse = z.infer + +/** A fresh challenge prevents stale or unrelated output from admitting a candidate. */ +export function parseOrcadProfilePreflight( + output: string, + nonce: string, + runtimeVersion: string, + artifactVersion?: string +): OrcadProfilePreflightResponse { + const response = orcadProfilePreflightResponseSchema.parse(JSON.parse(output.trim())) + if ( + response.nonce !== nonce || + response.runtime !== 'bun' || + response.runtimeVersion !== runtimeVersion || + (artifactVersion !== undefined && response.artifactVersion !== artifactVersion) + ) { + throw new Error('Profile preflight did not run under the expected candidate runtime') + } + return response +} diff --git a/src/shared/zip-extractor-command.ts b/src/shared/zip-extractor-command.ts new file mode 100644 index 00000000000..1f6564e22f6 --- /dev/null +++ b/src/shared/zip-extractor-command.ts @@ -0,0 +1,20 @@ +import { join } from 'node:path' + +/** The destination must already exist; callers extract only checksum-verified archives. */ +export function getZipExtractorCommand( + zipPath: string, + extractDir: string +): { file: string; args: string[]; label: string } { + if (process.platform === 'win32' && !process.env.ORCA_UNZIP_BIN) { + return { + file: join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe'), + args: ['-xf', zipPath, '-C', extractDir], + label: 'tar' + } + } + return { + file: process.env.ORCA_UNZIP_BIN || 'unzip', + args: ['-q', zipPath, '-d', extractDir], + label: 'unzip' + } +} diff --git a/tests/e2e/daemon-running-work-probe.unit.test.ts b/tests/e2e/daemon-running-work-probe.unit.test.ts new file mode 100644 index 00000000000..39bf29323f8 --- /dev/null +++ b/tests/e2e/daemon-running-work-probe.unit.test.ts @@ -0,0 +1,92 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { Session } from '../../src/main/daemon/session' +import { inspectTerminalHostProcess } from '../../src/main/daemon/terminal-host-process-inspection' +import type * as SnapshotReader from '../../src/shared/process-table-snapshot-reader' +import type { ProcessTableRow } from '../../src/shared/process-table-snapshot' +import { probePtyRunningWork } from '../../src/renderer/src/components/terminal/pty-running-work-probe' + +const { readSnapshot, inspectRuntime } = vi.hoisted(() => ({ + readSnapshot: vi.fn(), + inspectRuntime: vi.fn() +})) +vi.mock('../../src/shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getStrictProcessTableSnapshotWithAge: readSnapshot +})) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntime +})) + +afterEach(() => vi.restoreAllMocks()) + +it.each(['stopped', 'background', 'idle', 'unreadable'] as const)( + 'carries daemon child evidence through the real close guard for %s work', + async (state) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const root: ProcessTableRow = { + pid: 100, + ppid: 1, + pgid: 100, + tpgid: 101, + tty: 'ttys001', + startTime: 'Thu Sep 3 16:02:01 2026', + stat: 'Ss', + command: 'login -fp user' + } + const rows = [root, { ...root, pid: 101, ppid: 100, pgid: 101, stat: 'S+', command: '-zsh' }] + if (state === 'background' || state === 'stopped') { + rows.push({ + ...root, + pid: 102, + ppid: 101, + pgid: 102, + stat: state === 'stopped' ? 'T' : 'S', + command: 'vim draft.txt' + }) + } + if (state === 'unreadable') { + readSnapshot.mockRejectedValue(new Error('unreadable')) + } else { + readSnapshot.mockResolvedValue({ rows, capturedAgeMs: 0 }) + } + const session = new Session({ + sessionId: 'close-guard', + cols: 80, + rows: 24, + scrollback: 10, + shellReadySupported: false, + subprocess: { + pid: 100, + processNameIsSpawnFile: true, + getForegroundProcess: () => 'zsh', + write() {}, + resize() {}, + kill() {}, + forceKill() {}, + signal() {}, + dispose() {}, + onData() {}, + onExit() {}, + terminateOwnedTree: () => 'unavailable' + } + }) + try { + inspectRuntime.mockImplementation(() => + inspectTerminalHostProcess({ + sessionId: session.sessionId, + session, + authorityGeneration: 'owner', + nextObservationEpoch: () => 1 + }) + ) + const [result] = await probePtyRunningWork(null, ['remote:owner:close-guard'], { + timeoutMs: 1000 + }) + expect(result.verdict).toBe( + state === 'idle' ? 'exited' : state === 'unreadable' ? 'unverifiable' : 'live' + ) + } finally { + session.dispose() + } + } +)