diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml index e579782c6514..90051a97c1cb 100644 --- a/.github/workflows/bun-profile-tests.yml +++ b/.github/workflows/bun-profile-tests.yml @@ -71,15 +71,6 @@ jobs: # build:orcad reuses the host addon validated by native-runtime preparation. - 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: needs: changes diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index b015f52ea2e6..51a397f29c17 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -4,6 +4,8 @@ import { fork, spawnSync } from 'node:child_process' import { build } from 'esbuild' import { buildOrcadEntry, + buildOrcadLauncher, + ORCAD_BUN_TARGET, externalNativeAddons, ORCAD_EXTERNAL_MODULES, ORCAD_CHILD_ENTRY_POINTS @@ -88,7 +90,7 @@ async function stageParcelWatcher(target) { }, bundle: true, platform: 'node', - target: 'node18', + target: ORCAD_BUN_TARGET, format: 'cjs', outfile: join(OUT_DIR, ORCAD_PARCEL_WATCHER_ENTRY), external: ['./watcher.node'], @@ -159,7 +161,7 @@ function buildForkedChild(entryPoint, outfile) { entryPoints: [entryPoint], bundle: true, platform: 'node', - target: 'node18', + target: ORCAD_BUN_TARGET, format: 'cjs', outfile, external: ORCAD_EXTERNAL_MODULES, @@ -186,10 +188,11 @@ const childResults = await Promise.all([ ) ]) -const result = await buildOrcadEntry(OUT_FILE) +const launcher = await buildOrcadLauncher(OUT_FILE) +const result = await buildOrcadEntry(join(OUT_DIR, 'orcad-app.js')) const output = Object.values(result.metafile.outputs).find( - (o) => o.entryPoint === 'src/main/orcad/main.ts' + (o) => o.entryPoint === 'src/main/orcad/orcad-app.ts' ) // 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 @@ -211,7 +214,11 @@ function collectImporters(metafiles, matches) { return importers } -const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)] +const metafiles = [ + launcher.metafile, + result.metafile, + ...childResults.map((child) => child.metafile) +] const electronImporters = collectImporters( metafiles, (specifier) => specifier === 'electron' || specifier.startsWith('electron/') @@ -241,24 +248,16 @@ if (graphErrors.length > 0) { // install the PTY controller. Once orcad ships, it should become a ratchet entry // point so the two numbers cannot drift. process.exitCode = 1 -} else { - // Why smoke-load and not just read the metafile: the import scan proves no module - // *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. - // Why an exit code and not a message match: these bundles are minified onto one line, so - // Node's uncaught-exception report echoes that whole line — which contains every string - // literal in the bundle. A crash therefore "matches" any expected message, and a textual - // assertion passes against a bundle that never loaded. - const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], { +} else if (targetIsCurrent) { + // Exercise the validated entry and application with the shipped runtime. + const smoke = spawnSync(bunRuntimeOutput, [OUT_FILE, '--orcad-smoke-load-check'], { encoding: 'utf8', timeout: 60_000 }) const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}` if (smoke.error || smoke.signal || smoke.status !== 0) { console.error( - `[build-orcad] the bundle lost Node load compatibility.\n` + + `[build-orcad] the entry failed to load under bundled Bun.\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)}` @@ -271,7 +270,7 @@ if (graphErrors.length > 0) { // token and a PTY — `smoke:orcad-terminal` does that end to end, through orcad. // The verdict is carried by the exit code for the same minification reason as above. const daemonSmoke = spawnSync( - process.execPath, + bunRuntimeOutput, [ '-e', `const mod = require(${JSON.stringify(DAEMON_OUT_FILE)})\n` + @@ -287,7 +286,7 @@ 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 lost Node load compatibility.\n` + + `[build-orcad] the daemon child failed to load under bundled Bun.\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)}` @@ -304,7 +303,6 @@ if (graphErrors.length > 0) { } try { - await smokeProfileStateWorkers(OUT_DIR) if (targetIsCurrent) { await smokeProfileStateWorkers(OUT_DIR, { runtimePath: bunRuntimeOutput }) } diff --git a/config/scripts/bun-profile-change-scope.mjs b/config/scripts/bun-profile-change-scope.mjs index c30c4a282ac3..da2af7fc262f 100644 --- a/config/scripts/bun-profile-change-scope.mjs +++ b/config/scripts/bun-profile-change-scope.mjs @@ -5,7 +5,8 @@ import { pathToFileURL } from 'node:url' import { externalNativeAddons, ORCAD_CHILD_ENTRY_POINTS, - ORCAD_ENTRY_POINT + ORCAD_ENTRY_POINT, + ORCAD_LAUNCHER_ENTRY_POINT } from './orcad-entry-build.mjs' import { bunProfileTestPaths } from './bun-profile-test-paths.mjs' @@ -59,6 +60,7 @@ export function discoverBunProfileTests(root = ROOT) { export async function collectBunProfileInputs({ root = ROOT, entryPoints } = {}) { const entries = entryPoints ?? [ ORCAD_ENTRY_POINT, + ORCAD_LAUNCHER_ENTRY_POINT, ...Object.values(ORCAD_CHILD_ENTRY_POINTS), ...BUILD_SCRIPTS, ...discoverBunProfileTests(root) diff --git a/config/scripts/bun-profile-test-paths.mjs b/config/scripts/bun-profile-test-paths.mjs index 23c8f423acd2..ccdedef3ce6f 100644 --- a/config/scripts/bun-profile-test-paths.mjs +++ b/config/scripts/bun-profile-test-paths.mjs @@ -1,5 +1,7 @@ export function bunProfileTestPaths({ artifact = false } = {}) { return [ + 'src/shared/bun-owned-runtime-args.integration.test.ts', + 'src/shared/child-process/fork-process-bun.integration.test.ts', 'src/main/persistence/profile-state', 'src/main/persistence/loading-store/profile-state', 'src/main/sqlite', @@ -17,7 +19,9 @@ export function bunProfileTestPaths({ artifact = false } = {}) { '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' + 'config/scripts/zip-extractor-command.test.mjs', + 'config/scripts/orcad-launcher-build.test.mjs', + 'src/main/orcad/orcad-bundle-native-load-order.test.ts' ] : []) ] diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index fa2c83050ac8..91c8a93964ed 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -3,7 +3,9 @@ import { join } from 'node:path' const root = join(import.meta.dirname, '..', '..') -export const ORCAD_ENTRY_POINT = 'src/main/orcad/main.ts' +export const ORCAD_ENTRY_POINT = 'src/main/orcad/orcad-app.ts' +export const ORCAD_LAUNCHER_ENTRY_POINT = 'src/main/orcad/main.ts' +export const ORCAD_BUN_TARGET = 'es2024' export const ORCAD_CHILD_ENTRY_POINTS = { watcher: 'src/main/ipc/parcel-watcher-process-entry.ts', daemon: 'src/main/daemon/daemon-entry.ts', @@ -44,7 +46,7 @@ export function buildOrcadEntry(outfile) { entryPoints: [join(root, ORCAD_ENTRY_POINT)], bundle: true, platform: 'node', - target: 'node18', + target: ORCAD_BUN_TARGET, format: 'cjs', outfile, external: ORCAD_EXTERNAL_MODULES, @@ -56,3 +58,18 @@ export function buildOrcadEntry(outfile) { logLevel: 'error' }) } + +export function buildOrcadLauncher(outfile) { + return build({ + entryPoints: [join(root, ORCAD_LAUNCHER_ENTRY_POINT)], + bundle: true, + platform: 'node', + target: ORCAD_BUN_TARGET, + format: 'cjs', + outfile, + external: ['./orcad-app'], + metafile: true, + minify: true, + logLevel: 'error' + }) +} diff --git a/config/scripts/orcad-launcher-build.test.mjs b/config/scripts/orcad-launcher-build.test.mjs new file mode 100644 index 000000000000..b1f89a701ebd --- /dev/null +++ b/config/scripts/orcad-launcher-build.test.mjs @@ -0,0 +1,111 @@ +import { existsSync, realpathSync, statSync } from 'node:fs' +import { copyFile, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildOrcadLauncher } from './orcad-entry-build.mjs' +import { orcadBunRuntimeFilename } from '../../src/shared/orcad-artifacts' +import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime' +import { runProcess } from '../../src/shared/child-process/run-process' + +const bundledRuntime = resolve('out/orcad', orcadBunRuntimeFilename(process.platform)) +const nodeRuntime = + process.env.ORCA_TEST_NODE_EXECUTABLE ?? (process.versions.bun ? 'node' : process.execPath) +let directory +let launcher +let buildResult + +beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca launcher boundary ')) + launcher = join(directory, 'orcad.js') + buildResult = await buildOrcadLauncher(launcher) +}) + +afterAll(async () => { + if (directory) { + await rm(directory, { recursive: true, force: true }) + } +}) + +it('keeps the application graph outside the validated Bun entry', () => { + const inputs = Object.keys(buildResult.metafile.inputs) + expect(inputs.some((input) => input.endsWith('/orcad/main.ts'))).toBe(true) + expect(inputs.some((input) => input.endsWith('/orcad/orcad-app.ts'))).toBe(false) + expect(inputs.some((input) => input.includes('/persistence/'))).toBe(false) + expect( + Object.values(buildResult.metafile.outputs).flatMap((output) => output.imports) + ).toContainEqual(expect.objectContaining({ path: './orcad-app', external: true })) +}) + +it('rejects Node without loading application code', async () => { + const result = await runProcess({ program: nodeRuntime, args: [launcher], timeoutMs: 10_000 }) + expect(result.code, result.stderr).toBe(78) + expect(result.stderr).toContain('not Node') +}) + +describe.skipIf(!existsSync(bundledRuntime))('bundled launcher application boundary', () => { + beforeAll(async () => { + await copyFile(bundledRuntime, join(directory, orcadBunRuntimeFilename(process.platform))) + await writeFile(join(directory, '.build-target'), `${process.platform}-${process.arch}\n`) + await writeFile( + join(directory, 'orcad-app.js'), + ` + const { Database } = require('bun:sqlite') + const db = new Database(':memory:') + const value = db.query('SELECT 42 AS value').get().value + db.close() + console.log(JSON.stringify({ bun: process.versions.bun, value, args: process.argv.slice(2), entry: process.argv[1] })) + ` + ) + }) + + it('loads a Bun-only application directly without changing arguments', async () => { + const result = await runProcess({ + program: join(directory, orcadBunRuntimeFilename(process.platform)), + args: [launcher, '--data-dir', 'a path with spaces'], + timeoutMs: 10_000 + }) + expect(result.code, result.stderr).toBe(0) + const { entry, ...response } = JSON.parse(result.stdout) + expect(response).toEqual({ + bun: ORCAD_BUN_VERSION, + value: 42, + args: ['--data-dir', 'a path with spaces'] + }) + const actual = statSync(entry, { bigint: true }) + const expected = statSync(launcher, { bigint: true }) + expect(actual.ino).toBe(expected.ino) + expect(actual.dev).toBe(expected.dev) + }) + + it.skipIf(process.platform === 'win32')( + 'resolves a directly launched symlink before locating application assets', + async () => { + const links = await mkdtemp(join(tmpdir(), 'orca launcher link ')) + try { + const link = join(links, 'orcad.js') + await symlink(launcher, link) + const result = await runProcess({ + program: join(directory, orcadBunRuntimeFilename(process.platform)), + args: [link], + timeoutMs: 10_000 + }) + expect(result.code, result.stderr).toBe(0) + expect(JSON.parse(result.stdout).entry).toBe(realpathSync(launcher)) + } finally { + await rm(links, { recursive: true, force: true }) + } + } + ) + + it('reports a missing application as a configuration fault', async () => { + await rm(join(directory, 'orcad-app.js')) + const result = await runProcess({ + program: join(directory, orcadBunRuntimeFilename(process.platform)), + args: [launcher], + timeoutMs: 10_000 + }) + expect(result.code, result.stderr).toBe(78) + expect(result.stderr).toContain('application could not load') + }) +}) diff --git a/config/scripts/run-bun-profile-tests.mjs b/config/scripts/run-bun-profile-tests.mjs index debd895675b0..34a0bb403dd5 100644 --- a/config/scripts/run-bun-profile-tests.mjs +++ b/config/scripts/run-bun-profile-tests.mjs @@ -1,3 +1,4 @@ +import { bunOwnedRuntimeArgs } from '../../src/shared/bun-owned-runtime-args.ts' import { join, resolve } from 'node:path' import { randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' @@ -42,7 +43,12 @@ if (artifact) { const nonce = randomUUID() const result = runProcessSync({ program: runtimePath, - args: [join(runtimeDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce], + args: [ + ...bunOwnedRuntimeArgs(), + join(runtimeDir, 'orcad.js'), + ORCAD_PROFILE_PREFLIGHT_FLAG, + nonce + ], cwd: root, env, timeoutMs: 90_000 @@ -66,6 +72,7 @@ if (artifact) { ]) } run(runtimePath, [ + ...bunOwnedRuntimeArgs(), join(root, 'node_modules/vitest/vitest.mjs'), 'run', '--config', diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index a03c85983d8a..c3d5c792c423 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -28,6 +28,8 @@ import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { randomBytes } from 'node:crypto' import process from 'node:process' +import { orcadBunRuntimeFilename } from '../../src/shared/orcad-artifacts.ts' +import { createServeStopRequest } from '../../src/shared/serve-supervisor-control.ts' const projectDir = resolve(import.meta.dirname, '../..') const serveEntry = join(projectDir, 'out', 'main', 'index.js') @@ -175,7 +177,8 @@ function resolveLaunch(userDataDir) { if (target === 'orcad') { return { label: `orcad (${ORCAD_ENTRY})`, - command: process.execPath, + controlIpc: true, + command: join(projectDir, 'out', 'orcad', orcadBunRuntimeFilename(process.platform)), args: [ORCAD_ENTRY, '--port', String(PORT), '--json'], env: { ORCA_USER_DATA: userDataDir } } @@ -231,10 +234,14 @@ async function main() { let pairing = null const child = spawn(launch.command, launch.args, { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: launch.controlIpc ? ['ignore', 'pipe', 'pipe', 'ipc'] : ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...launch.env } }) + const stopControl = createServeStopRequest(child) + child.on('message', stopControl.handleMessage) + child.once('exit', () => child.off('message', stopControl.handleMessage)) + try { const ready = await waitForReady(child) log(`ready: ${ready.advertisedEndpoint}`) @@ -385,7 +392,11 @@ async function main() { // waiting for a second 'exit' that will never fire reported a bogus shutdown failure // stacked on top of the real error. if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGTERM') + if (launch.controlIpc) { + stopControl.request() + } else { + child.kill('SIGTERM') + } const exited = await Promise.race([ new Promise((r) => child.on('exit', () => r(true))), new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS)) diff --git a/src/cli/runtime/serve-stop-request.ts b/src/cli/runtime/serve-stop-request.ts new file mode 100644 index 000000000000..ec269b2cb108 --- /dev/null +++ b/src/cli/runtime/serve-stop-request.ts @@ -0,0 +1 @@ +export { createServeStopRequest } from '../../shared/serve-supervisor-control' diff --git a/src/main/ai-vault/session-scanner-service-spawn.test.ts b/src/main/ai-vault/session-scanner-service-spawn.test.ts index 5c64a13ecb40..a1a3cca8060b 100644 --- a/src/main/ai-vault/session-scanner-service-spawn.test.ts +++ b/src/main/ai-vault/session-scanner-service-spawn.test.ts @@ -1,5 +1,10 @@ import type { ChildProcess } from 'node:child_process' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) const forkMock = vi.hoisted(() => vi.fn()) @@ -14,10 +19,26 @@ function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } { describe('spawnAiVaultServiceProcess', () => { beforeEach(() => { + vi.stubGlobal('process', { ...process, versions: { ...process.versions, bun: undefined } }) forkMock.mockReset() forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess) }) + it.each(['darwin', 'linux', 'win32'])('isolates Bun configuration on %s', (platform) => { + vi.stubGlobal('process', { + ...process, + platform, + versions: { ...process.versions, bun: '1.4.2' } + }) + spawnAiVaultServiceProcess() + expect(forkOptions().execArgv).toEqual([ + '--max-old-space-size=384', + '--no-env-file', + platform === 'win32' ? '--config=NUL' : '--config=/dev/null', + '--no-install' + ]) + }) + it('keeps NODE_OPTIONS out of the child so the heap cap and loader stand', () => { vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js') spawnAiVaultServiceProcess() diff --git a/src/main/ai-vault/session-scanner-service-spawn.ts b/src/main/ai-vault/session-scanner-service-spawn.ts index a923e904023a..9dbc4195ca0e 100644 --- a/src/main/ai-vault/session-scanner-service-spawn.ts +++ b/src/main/ai-vault/session-scanner-service-spawn.ts @@ -1,3 +1,4 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { localAiVaultScanRoots } from './cached-session-list' import type { SessionSearchHostScope } from '../ai-vault-search/session-search-service' import { fork, type ChildProcess } from 'node:child_process' @@ -36,7 +37,7 @@ export function spawnAiVaultServiceProcess(): ChildProcess { } const child = fork(entryPath, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - execArgv: ['--max-old-space-size=384'], + execArgv: ['--max-old-space-size=384', ...(process.versions.bun ? bunOwnedRuntimeArgs() : [])], env: buildAiVaultServiceEnv(), ...(process.platform === 'win32' ? { windowsHide: true } : {}) }) diff --git a/src/main/codex/codex-app-server-posix-supervisor.test.ts b/src/main/codex/codex-app-server-posix-supervisor.test.ts index f51157a443c7..d5bd7b339e22 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.test.ts +++ b/src/main/codex/codex-app-server-posix-supervisor.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { CodexAppServerLaunch } from './codex-app-server-connection' import { createProviderSpawnSpec, @@ -13,8 +13,11 @@ const launch: CodexAppServerLaunch = { env: { CODEX_HOME: '/tmp/codex' } } +afterEach(() => vi.unstubAllGlobals()) + describe('structured provider supervision', () => { it('wraps POSIX launches in a detached supervisor and preserves the launch spec', () => { + vi.stubGlobal('process', { ...process, versions: { ...process.versions, bun: undefined } }) const childEnv = { PATH: '/bin', CODEX_HOME: '/tmp/codex' } const spec = supervisedPosixLaunch(launch, childEnv) @@ -59,3 +62,23 @@ describe('structured provider supervision', () => { }) }) }) + +it('isolates a Bun-hosted supervisor without changing the provider command', () => { + vi.stubGlobal('process', { + ...process, + platform: 'linux', + versions: { ...process.versions, bun: '1.4.2' } + }) + const spec = createProviderSpawnSpec(launch, { PATH: '/bin' }, 'linux') + expect(spec.args).toEqual([ + '--no-env-file', + '--config=/dev/null', + '--no-install', + '-e', + POSIX_PROVIDER_SUPERVISOR_SCRIPT + ]) + expect(spec.cwd).toBe(launch.cwd) + expect( + JSON.parse(Buffer.from(spec.env.ORCA_PROVIDER_SUPERVISOR_SPEC!, 'base64').toString()).command + ).toBe(launch.command) +}) diff --git a/src/main/codex/codex-app-server-posix-supervisor.ts b/src/main/codex/codex-app-server-posix-supervisor.ts index f83ba6f374bc..a4e3078bb3d0 100644 --- a/src/main/codex/codex-app-server-posix-supervisor.ts +++ b/src/main/codex/codex-app-server-posix-supervisor.ts @@ -1,3 +1,4 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import type { CodexAppServerLaunch } from './codex-app-server-connection' /** Inline supervisor source kept dependency-free for the spawned Node child. */ @@ -101,7 +102,11 @@ export function supervisedPosixLaunch( ).toString('base64') return { command: process.execPath, - args: ['-e', POSIX_PROVIDER_SUPERVISOR_SCRIPT], + args: [ + ...(process.versions.bun ? bunOwnedRuntimeArgs() : []), + '-e', + POSIX_PROVIDER_SUPERVISOR_SCRIPT + ], // Electron's executable needs Node mode for the inline supervisor. The // marker is removed above so providers never inherit Electron semantics. env: { diff --git a/src/main/daemon/daemon-folder-access-probe.ts b/src/main/daemon/daemon-folder-access-probe.ts index a65b33a29327..716158a38595 100644 --- a/src/main/daemon/daemon-folder-access-probe.ts +++ b/src/main/daemon/daemon-folder-access-probe.ts @@ -3,6 +3,7 @@ // forked the child, so only a fresh child of the current app binary can tell the user whether // restarting the terminal service is the remedy or whether they must re-allow Orca first. +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { isAbsolute } from 'node:path' import { runProcess } from '../../shared/child-process/run-process' import type { DirectoryEnumerationOutcome } from './directory-enumeration-probe' @@ -72,7 +73,7 @@ export async function probeFolderAccessForFreshDaemon( try { const result = await runProcess({ program: process.execPath, - args: ['-e', PROBE_SCRIPT, path], + args: [...(process.versions.bun ? bunOwnedRuntimeArgs() : []), '-e', PROBE_SCRIPT, path], env: probeEnvironment(), timeoutMs: PROBE_DEADLINE_MS, maxOutputBytes: PROBE_MAX_OUTPUT_BYTES diff --git a/src/main/daemon/daemon-launched-child-spawn.test.ts b/src/main/daemon/daemon-launched-child-spawn.test.ts index a3b5d0d42520..1b636261fa11 100644 --- a/src/main/daemon/daemon-launched-child-spawn.test.ts +++ b/src/main/daemon/daemon-launched-child-spawn.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { spawnDaemonChildProcess } from './daemon-launched-child-spawn' const { spawn, fork } = vi.hoisted(() => ({ spawn: vi.fn(), fork: vi.fn() })) @@ -20,7 +21,11 @@ const options = { macosLoginSessionWatch: false } -afterEach(() => vi.clearAllMocks()) +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) describe('daemon launch scope ownership', () => { it('only arms lifetime cleanup through the private scope launcher', () => { @@ -49,3 +54,47 @@ describe('daemon launch scope ownership', () => { ) }) }) + +describe('headless Bun daemon launch', () => { + function useBun(): void { + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, bun: '1.4.2' }) + vi.stubEnv('NODE_OPTIONS', '--require=untrusted.js') + vi.stubEnv('NODE_PATH', '/untrusted') + vi.stubEnv('BUN_OPTIONS', '--preload=untrusted.js') + } + + it('isolates scoped Bun launches before the daemon entry', () => { + useBun() + spawnDaemonChildProcess(options, true) + const call = spawn.mock.calls[0][0] + const executableIndex = call.args.indexOf(process.execPath) + expect(call.args.slice(executableIndex + 1, executableIndex + 5)).toEqual([ + ...bunOwnedRuntimeArgs(), + options.forkEntryPath + ]) + for (const key of ['ELECTRON_RUN_AS_NODE', 'NODE_OPTIONS', 'NODE_PATH', 'BUN_OPTIONS']) { + expect(call.env[key]).toBeUndefined() + } + }) + + it('scrubs direct Bun forks while preserving the selected ConPTY library', () => { + useBun() + vi.stubEnv('BUN_CONPTY_LIBRARY', '/verified/conpty.dll') + spawnDaemonChildProcess(options, false) + expect(fork.mock.calls[0][0].env).toEqual( + expect.objectContaining({ + BUN_CONPTY_LIBRARY: '/verified/conpty.dll', + ORCA_USER_DATA_PATH: options.userDataPath + }) + ) + expect(fork.mock.calls[0][0].env.NODE_OPTIONS).toBeUndefined() + }) + + it('preserves an explicitly selected Node executable', () => { + useBun() + spawnDaemonChildProcess({ ...options, relocatedExecPath: '/alternate/node' }, true) + const call = spawn.mock.calls[0][0] + expect(call.args).not.toContain('--no-install') + expect(call.env.ELECTRON_RUN_AS_NODE).toBe('1') + }) +}) diff --git a/src/main/daemon/daemon-launched-child-spawn.ts b/src/main/daemon/daemon-launched-child-spawn.ts index 887241e2daab..7dbae59c330f 100644 --- a/src/main/daemon/daemon-launched-child-spawn.ts +++ b/src/main/daemon/daemon-launched-child-spawn.ts @@ -1,5 +1,6 @@ import { forkProcess, type ForkSpec } from '../../shared/child-process/fork-process' import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { getAppEnvironment } from '../../shared/app-environment' import { buildDurableDaemonScopeCommand } from './daemon-cgroup-scope' import { daemonLogArgs } from './daemon-launch-paths' @@ -57,13 +58,22 @@ export function spawnDaemonChildProcess( ): SpawnedProcess { const { forkEntryPath, relocatedExecPath, userDataPath, launchNonce } = options const scriptArgs = buildDaemonScriptArgs(options) + const usesBun = Boolean( + process.versions.bun && (!relocatedExecPath || relocatedExecPath === process.execPath) + ) // Why: run as plain Node so Electron's GPU/display init can't interfere with node-pty's posix_spawn of the spawn-helper. - const daemonEnv = { + const daemonEnv: NodeJS.ProcessEnv = { ...process.env, ELECTRON_RUN_AS_NODE: '1', // Why: the detached plain-Node daemon has no AppEnvironment, but shell rcfiles must live outside swept tmp. ORCA_USER_DATA_PATH: userDataPath } + if (usesBun) { + delete daemonEnv.ELECTRON_RUN_AS_NODE + delete daemonEnv.NODE_OPTIONS + delete daemonEnv.NODE_PATH + delete daemonEnv.BUN_OPTIONS + } // Why cwd: detached daemons outlive dev worktrees; userData keeps process.cwd() valid after a repo/worktree is deleted. // Why detached/stdio: detached+unref outlives Electron; stdout 'ignore' (else blocks exit), stderr 'pipe' captures startup crashes lost in v1.4.129-rc.1. const childOptions: Pick = { @@ -83,7 +93,12 @@ export function spawnDaemonChildProcess( } const scoped = buildDurableDaemonScopeCommand( relocatedExecPath ?? process.execPath, - [forkEntryPath, ...scriptArgs, '--fresh-daemon-scope'], + [ + ...(usesBun ? bunOwnedRuntimeArgs() : []), + forkEntryPath, + ...scriptArgs, + '--fresh-daemon-scope' + ], launchNonce, daemonEnv ) diff --git a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts index dda83ad021a2..fa68118f8f96 100644 --- a/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts +++ b/src/main/daemon/pty-subprocess/windows-bun-pty-launch.ts @@ -128,6 +128,7 @@ export function createWindowsBunPtyLaunch( command: [ deps.runtimePath ?? process.execPath, '--no-env-file', + '--no-install', `--config=${configPath}`, `--cwd=${directory}`, workerPath, diff --git a/src/main/native-chat/wsl-transcript-fs-process-spawn.test.ts b/src/main/native-chat/wsl-transcript-fs-process-spawn.test.ts new file mode 100644 index 000000000000..8547ad2e6016 --- /dev/null +++ b/src/main/native-chat/wsl-transcript-fs-process-spawn.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { forkWslTranscriptFsProcess } from './wsl-transcript-fs-process-spawn' + +const fork = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ fork })) +vi.mock('node:fs', () => ({ existsSync: () => true })) + +afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('WSL transcript reader runtime isolation', () => { + it.each(['darwin', 'linux', 'win32'])('isolates owned Bun reads on %s', (platform) => { + vi.stubGlobal('process', { + ...process, + platform, + versions: { ...process.versions, bun: '1.4.2' } + }) + forkWslTranscriptFsProcess() + expect(fork).toHaveBeenCalledWith( + expect.any(String), + [], + expect.objectContaining({ + execArgv: [ + '--no-env-file', + platform === 'win32' ? '--config=NUL' : '--config=/dev/null', + '--no-install' + ] + }) + ) + }) + + it('preserves the Node and Electron launch policy', () => { + vi.stubGlobal('process', { ...process, versions: { ...process.versions, bun: undefined } }) + forkWslTranscriptFsProcess() + expect(fork).toHaveBeenCalledWith( + expect.any(String), + [], + expect.objectContaining({ + execArgv: [], + env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }) + }) + ) + }) +}) diff --git a/src/main/native-chat/wsl-transcript-fs-process-spawn.ts b/src/main/native-chat/wsl-transcript-fs-process-spawn.ts index 5d67cba8bedc..7b7fcd8b0cb9 100644 --- a/src/main/native-chat/wsl-transcript-fs-process-spawn.ts +++ b/src/main/native-chat/wsl-transcript-fs-process-spawn.ts @@ -1,6 +1,7 @@ import { fork, type ChildProcess } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { pickAllowedEnv, RUNTIME_ENV_ALLOWLIST } from '../ai-vault/session-scanner-service-env' const PROCESS_ENTRY_FILENAME = 'wsl-transcript-fs-process-entry.js' @@ -50,7 +51,7 @@ export function forkWslTranscriptFsProcess(): ChildProcess { } return fork(entryPath, [], { env: wslTranscriptFsProcessForkEnv(), - execArgv: [], + execArgv: process.versions.bun ? bunOwnedRuntimeArgs() : [], serialization: 'advanced', stdio: ['ignore', 'ignore', 'ignore', 'ipc'], ...(process.platform === 'win32' ? { windowsHide: true } : {}) diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index e746be8cc5e6..7effd4ce42dc 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -14,7 +14,6 @@ const { order, profileProbe } = vi.hoisted(() => { return { order, profileProbe: vi.fn(async () => {}) } }) -vi.mock('./orcad-bundled-runtime', () => ({ handoffToBundledOrcad: () => false })) vi.mock('./orcad-profile-preflight', () => ({ preflightBundledOrcadStartup: async () => { order.push('profile-admission') @@ -24,6 +23,7 @@ vi.mock('./orcad-profile-preflight', () => ({ beforeEach(() => { vi.resetModules() + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, bun: 'test' }) order.length = 0 }) afterEach(() => { @@ -52,14 +52,14 @@ describe('orcad entry', () => { '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') + await import('./orcad-app') expect(profileProbe).toHaveBeenCalledExactlyOnceWith('nonce', { nativeFeatures }) expect(order).toEqual([]) } ) it('runs the native preflight before starting the runtime', async () => { - await import('./main') + await import('./orcad-app') await vi.waitFor(() => expect(order).toContain('main')) expect(order).toEqual(['profile-admission', 'preflight', 'main']) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 79fc667fd22a..20ef1d834943 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -1,54 +1,24 @@ -/** Executable entry for `orcad`. See `./orcad-entry.ts`. */ -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' +/** Validate the runtime before loading the application or opening a profile. */ +import { realpathSync } from 'node:fs' +import { assertBundledOrcadRuntime, OrcadBundledRuntimeError } 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 -// starting a server to prove it would bind a port and take a data-root lock on a build -// machine. -if (process.argv.includes('--orcad-smoke-load-check')) { - process.exit(0) -} - -// Why here and not inside startOrcad: this must run before anything requires node-pty, -// and `orcad-entry` reaches it through `await import('../ipc/pty')`. Static imports are -// 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. 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)) + process.exit(error instanceof OrcadBundledRuntimeError ? 78 : 1) } 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) - } + assertBundledOrcadRuntime() + const entry = process.argv[1] + if (!entry) { + throw new OrcadBundledRuntimeError('The Orca entry is missing') } + process.argv[1] = realpathSync(entry) + void import('./orcad-app').catch((cause: unknown) => { + failStartup( + new OrcadBundledRuntimeError('The bundled Orca application could not load', { cause }) + ) + }) } catch (error) { failStartup(error) } diff --git a/src/main/orcad/orcad-app.ts b/src/main/orcad/orcad-app.ts new file mode 100644 index 000000000000..e1a0bde70ec5 --- /dev/null +++ b/src/main/orcad/orcad-app.ts @@ -0,0 +1,45 @@ +/** Bun application entry; the public launcher owns runtime selection. */ +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' + +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 (!process.versions.bun) { + throw new Error('orcad requires its bundled Bun runtime') + } + // Load-check evaluates the Bun application graph without opening profile state. + if (process.argv.includes('--orcad-smoke-load-check')) { + process.exit(0) + } + 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-bun-launcher.integration.test.ts b/src/main/orcad/orcad-bun-launcher.integration.test.ts index 8ee18d53293f..e19ed242f96e 100644 --- a/src/main/orcad/orcad-bun-launcher.integration.test.ts +++ b/src/main/orcad/orcad-bun-launcher.integration.test.ts @@ -1,18 +1,16 @@ +import { SERVE_STOP_READY, SERVE_STOP_REQUEST } from '../../shared/serve-supervisor-control' import { build } from 'esbuild' -import { existsSync, readFileSync } from 'node:fs' +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() @@ -23,49 +21,16 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { await copyFile(runtimePath, join(directory, orcadBunRuntimeFilename(process.platform))) await writeFile(join(directory, '.build-target'), `${process.platform}-${process.arch}\n`) await build({ - // Record entry before imports without relying on either process's stdout. - banner: { - js: ` - function traceLauncherPhase(phase) { - const runtime = process.versions.bun ? 'bun' : 'node' - try { - require('node:fs').appendFileSync(process.env.ORCA_TEST_PHASES + '.' + runtime, - JSON.stringify({ at: Date.now(), phase, pid: process.pid, runtime, arch: process.arch }) + '\\n') - } catch (error) { - console.error('launcher fixture trace failed:', error) - } - } - traceLauncherPhase('entry') - process.once('exit', code => traceLauncherPhase('exit:' + code)) - ` - }, 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) { - traceLauncherPhase('before-handoff') - if (!handoffToBundledOrcad()) throw new Error('Missing bundled runtime') - traceLauncherPhase('after-handoff') - process.on('message', signal => process.emit(signal)) - } else { - traceLauncherPhase('before-booting') + { console.log('booting:' + process.pid) console.log('runtime:' + process.versions.bun) - console.log('channel-env:' + (process.env.ORCA_BUNDLED_LAUNCHER_CHANNEL ?? 'absent')) - traceLauncherPhase('booting-written') 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) @@ -75,11 +40,7 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { }, 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() + void install() } `, resolveDir: process.cwd(), @@ -88,7 +49,7 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { outfile: join(directory, 'orcad.js'), bundle: true, platform: 'node', - target: 'node18', + target: 'es2024', format: 'cjs' }) }) @@ -109,23 +70,11 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { function launch( options: { - direct?: boolean nohup?: boolean - delay?: boolean stall?: boolean - failStartup?: boolean } = {} ) { - const runtime = options.direct - ? join(directory, orcadBunRuntimeFilename(process.platform)) - : nodePath - const startedAt = Date.now() - const events: { at: number; event: string }[] = [] - const record = (event: string): void => { - if (events.length < 16) { - events.push({ at: Date.now(), event }) - } - } + const runtime = join(directory, orcadBunRuntimeFilename(process.platform)) const child = spawnProcess({ program: options.nohup ? 'nohup' : runtime, args: [...(options.nohup ? [runtime] : []), join(directory, 'orcad.js')], @@ -133,19 +82,14 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_TEST_DONE: join(directory, 'done'), - ORCA_TEST_PHASES: join(directory, 'phases'), - ORCA_TEST_DELAY_INSTALL: options.delay ? '1' : '0', - ORCA_TEST_STALL: options.stall ? '1' : '0', - ORCA_TEST_FAIL_STARTUP: options.failStartup ? '1' : '0' + ORCA_TEST_STALL: options.stall ? '1' : '0' }, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }) children.add(child) - child.once('spawn', () => record('spawn')) let closed = false child.once('close', () => { - record('close') closed = true }) let output = '' @@ -162,137 +106,62 @@ describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => { ['stdout', child.stdout], ['stderr', child.stderr] ] as const - for (const [name, stream] of streams) { + for (const [, stream] of streams) { stream.on('data', capture) - stream.once('data', () => record(`${name}:data`)) - stream.once('end', () => record(`${name}:end`)) - stream.once('close', () => record(`${name}:close`)) } const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( (resolve, reject) => { child.once('error', (error) => { - record(`error:${error.message}`) reject(error) }) child.once('exit', (code, signal) => { - record(`exit:${code}:${signal}`) children.delete(child) resolve({ code, signal }) }) } ) - const diagnostics = () => ({ - startedAt, - elapsedMs: Date.now() - startedAt, - events, - pid: child.pid, - exitCode: child.exitCode, - signalCode: child.signalCode, - connected: child.connected, - closed, - streams: streams.map(([name, stream]) => ({ - name, - ended: stream.readableEnded, - destroyed: stream.destroyed, - error: stream.errored?.message, - bufferedBytes: stream.readableLength - })), - phases: ['node', 'bun'].map((runtime) => { - try { - return { - runtime, - trace: readFileSync(join(directory, `phases.${runtime}`), 'utf8').slice(0, 4096) - } - } catch (error) { - return { runtime, error: String(error) } + return { child, output: () => output, exit, isClosed: () => closed } + } + + it('drains through supervisor IPC without force-killing the runtime', async () => { + const h = launch() + const ready = new Promise((resolve) => { + h.child.on('message', (message) => { + if (message === SERVE_STOP_READY) { + resolve() } }) }) - return { child, output: () => output, exit, isClosed: () => closed, diagnostics } - } - - 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') - expect(h.output()).toContain(`runtime:${ORCAD_BUN_VERSION}`) - expect(h.output()).toContain('channel-env:absent') - }, - { timeout: 5_000 } - ) - 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 }) - } - ) + await ready + h.child.send(SERVE_STOP_REQUEST) + h.child.send(SERVE_STOP_REQUEST) + 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) + }) - 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.skipIf(process.platform === 'win32')('survives nohup hangups and drains on TERM', async () => { + const h = launch({ 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 () => { + it.skipIf(process.platform === 'win32')('bounds a stalled durable shutdown', 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')) + h.child.kill('SIGTERM') + expect(await h.exit).toEqual({ code: 1, signal: null }) 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 }) - try { - await vi.waitFor(() => expect(h.output()).toContain('booting:'), { timeout: 5_000 }) - } catch (error) { - console.error('Bun launcher startup diagnostics:', JSON.stringify(h.diagnostics())) - throw error - } - 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-bundle-native-load-order.test.ts b/src/main/orcad/orcad-bundle-native-load-order.test.ts index 5ed9085fa369..7629e0a67373 100644 --- a/src/main/orcad/orcad-bundle-native-load-order.test.ts +++ b/src/main/orcad/orcad-bundle-native-load-order.test.ts @@ -1,12 +1,14 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, expect, it } from 'vitest' +import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' import { runProcessSync } from '../../shared/child-process/run-process' const REPO_ROOT = join(__dirname, '..', '..', '..') const directories: string[] = [] +const bunRuntime = join(REPO_ROOT, 'out/orcad', orcadBunRuntimeFilename(process.platform)) afterEach(() => { for (const directory of directories.splice(0)) { @@ -14,57 +16,53 @@ afterEach(() => { } }) -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({ +it.skipIf(!existsSync(bunRuntime))( + '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, - // The production load-check exits after module evaluation, before runtime handoff or probes. - args: ['--require', preload, ...extraArgs, bundle, '--orcad-smoke-load-check'], + 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: 30_000 + 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 nativeDirectory = join(directory, 'node_modules', 'node-pty') + mkdirSync(nativeDirectory, { recursive: true }) + writeFileSync( + join(nativeDirectory, 'index.js'), + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'node-pty'); +` + `throw new Error('native PTY required before preflight')` + ) + const run = (extraArgs: string[] = []) => + runProcessSync({ + program: bunRuntime, + // The application load-check exits after module evaluation, before probes. + args: [...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) + 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) + // 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 deleted file mode 100644 index 9db0d5000c97..000000000000 --- a/src/main/orcad/orcad-bundled-runtime.integration.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -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 index 5541314057e7..ff6212289d93 100644 --- a/src/main/orcad/orcad-bundled-runtime.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.test.ts @@ -1,6 +1,5 @@ -import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { handoffToBundledOrcad } from './orcad-bundled-runtime' +import { assertBundledOrcadRuntime } from './orcad-bundled-runtime' import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' import { ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts' @@ -12,22 +11,13 @@ const fixture = vi.hoisted(() => ({ 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() + vi.spyOn(process, 'versions', 'get').mockReturnValue({ + ...process.versions, + bun: ORCAD_BUN_VERSION + }) fixture.exists.mockReturnValue(true) fixture.realpath.mockImplementation((path) => path) - fixture.spawn.mockReturnValue(child) vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('test process exit') }) @@ -37,39 +27,32 @@ beforeEach(() => { }) 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', () => { +describe('bundled Orca runtime validation', () => { it('leaves nonpackaged entries on their existing runtime', () => { fixture.exists.mockReturnValue(false) - expect(handoffToBundledOrcad()).toBe(false) + expect(assertBundledOrcadRuntime()).toBeUndefined() 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(() => assertBundledOrcadRuntime()).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(() => assertBundledOrcadRuntime()).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(() => assertBundledOrcadRuntime()).toThrow('bundled Orca runtime target is missing') expect(fixture.realpath).toHaveBeenCalledExactlyOnceWith('/slot/orcad.js') expect(fixture.spawn).not.toHaveBeenCalled() }) @@ -80,97 +63,25 @@ describe('bundled Orca runtime handoff', () => { ...process.versions, bun: ORCAD_BUN_VERSION }) - expect(handoffToBundledOrcad()).toBe(false) + expect(assertBundledOrcadRuntime()).toBeUndefined() 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)) - } + expect(() => assertBundledOrcadRuntime()).toThrow(`must be Bun ${ORCAD_BUN_VERSION}`) }) - 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('rejects Node before inspecting or opening the application', () => { + vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, bun: undefined }) + expect(() => assertBundledOrcadRuntime()).toThrow('not Node') + expect(fixture.exists).not.toHaveBeenCalled() + expect(fixture.spawn).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() + it('rejects an external Bun executable instead of handing off', () => { + expect(() => assertBundledOrcadRuntime()).toThrow('Start orcad with its bundled runtime') + expect(fixture.spawn).not.toHaveBeenCalled() }) }) diff --git a/src/main/orcad/orcad-bundled-runtime.ts b/src/main/orcad/orcad-bundled-runtime.ts index 05b86387124b..196f9866fba0 100644 --- a/src/main/orcad/orcad-bundled-runtime.ts +++ b/src/main/orcad/orcad-bundled-runtime.ts @@ -1,7 +1,5 @@ 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, @@ -10,13 +8,15 @@ import { 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 { +/** Reject the wrong runtime before application imports can touch a profile. */ +export function assertBundledOrcadRuntime(): void { + if (!process.versions.bun) { + throw new OrcadBundledRuntimeError('Start orcad with its bundled bun-runtime, not Node') + } const script = process.argv[1] if (!script) { - return false + return } const entry = realpathSync(script) const directory = dirname(entry) @@ -24,7 +24,7 @@ export function handoffToBundledOrcad(): boolean { const hasTarget = existsSync(join(directory, ORCAD_BUILD_TARGET_FILENAME)) const hasRuntime = existsSync(runtime) if (!hasTarget && !hasRuntime && !existsSync(join(directory, ORCAD_VERSION_FILENAME))) { - return false + return } if (!hasTarget) { throw new OrcadBundledRuntimeError('The bundled Orca runtime target is missing') @@ -38,53 +38,7 @@ export function handoffToBundledOrcad(): boolean { `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) - } + return } - 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 + throw new OrcadBundledRuntimeError(`Start orcad with its bundled runtime: ${runtime}`) } diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts index 70e02b37e878..f21ea22f92e4 100644 --- a/src/main/orcad/orcad-health.test.ts +++ b/src/main/orcad/orcad-health.test.ts @@ -1,4 +1,6 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { computeLocalOrcadBuildHash } from '../ssh/orcad-local-build-hash' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -172,3 +174,48 @@ describe('computeOrcadBuildHash', () => { expect(computeOrcadBuildHash('')).toBe('unknown') }) }) + +describe('shared orcad build identity', () => { + let directory: string + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'orcad-build-identity-')) + }) + afterEach(() => rmSync(directory, { recursive: true, force: true })) + + it('changes matching host and client hashes when only the application changes', () => { + const entry = join(directory, 'orcad.js') + writeFileSync(entry, 'import("./orcad-app")') + writeFileSync(join(directory, 'orcad-app.js'), 'application-a') + const first = computeLocalOrcadBuildHash(directory) + expect(computeOrcadBuildHash(entry)).toBe(first) + writeFileSync(join(directory, 'orcad-app.js'), 'application-b') + const second = computeLocalOrcadBuildHash(directory) + expect(second).not.toBe(first) + expect(computeOrcadBuildHash(entry)).toBe(second) + writeFileSync(entry, '// launcher changed\nimport("./orcad-app")') + expect(computeLocalOrcadBuildHash(directory)).not.toBe(second) + expect(computeOrcadBuildHash(entry)).toBe(computeLocalOrcadBuildHash(directory)) + }) + + it('preserves the original single-bundle hash for older deployments', () => { + const entry = join(directory, 'orcad.js') + const legacy = 'original single-bundle application' + writeFileSync(entry, legacy) + const expected = createHash('sha256').update(legacy).digest('hex').slice(0, 16) + expect(computeLocalOrcadBuildHash(directory)).toBe(expected) + expect(computeOrcadBuildHash(entry)).toBe(expected) + }) + + it.each(['missing', 'unreadable'])( + 'refuses a %s application instead of hashing only the launcher', + (kind) => { + const entry = join(directory, 'orcad.js') + writeFileSync(entry, 'import("./orcad-app")') + if (kind === 'unreadable') { + mkdirSync(join(directory, 'orcad-app.js')) + } + expect(() => computeLocalOrcadBuildHash(directory)).toThrow() + expect(computeOrcadBuildHash(entry)).toBe('unknown') + } + ) +}) diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index 0c3a3525171f..2517d4f37fad 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -8,8 +8,7 @@ * to cross the process boundary: orcad drives it, the daemon performs it, and the verdict * travels back over the daemon's socket. */ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' +import { readOrcadBuildHash } from '../../shared/orcad-build-hash' import process from 'node:process' import { checkDaemonHealth, type DaemonHealth } from '../daemon/daemon-health' import { @@ -81,7 +80,7 @@ export function computeOrcadBuildHash(entryPath = process.argv[1]): string { return 'unknown' } try { - return createHash('sha256').update(readFileSync(entryPath)).digest('hex').slice(0, 16) + return readOrcadBuildHash(entryPath) } catch { return 'unknown' } diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index bfff3b7f14ed..7c462c733040 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -1,16 +1,13 @@ +import { registerServeSupervisorControl } from '../../shared/serve-supervisor-control' 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 () => { @@ -21,7 +18,7 @@ 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. */ +/** Repeated service stop signals must not interrupt the durable shutdown. */ export function installOrcadShutdownSignals( stop: () => Promise, deadlineMs = ORCAD_SHUTDOWN_DEADLINE_MS @@ -45,16 +42,11 @@ export function installOrcadShutdownSignals( } process.on('SIGINT', () => shutdown('SIGINT')) process.on('SIGTERM', () => shutdown('SIGTERM')) + registerServeSupervisorControl(process, () => shutdown('supervisor')) // 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( diff --git a/src/main/orcad/orcad-profile-preflight.test.ts b/src/main/orcad/orcad-profile-preflight.test.ts index 9c51e1a349ea..1b6952226e16 100644 --- a/src/main/orcad/orcad-profile-preflight.test.ts +++ b/src/main/orcad/orcad-profile-preflight.test.ts @@ -55,7 +55,7 @@ beforeEach(() => { 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])) + fixture.run.mockImplementation(async (spec) => readyResult(spec.args?.at(-1))) }) afterEach(() => { @@ -72,7 +72,14 @@ describe('bundled Orca startup readiness', () => { 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)], + args: [ + '--no-env-file', + platform === 'win32' ? '--config=NUL' : '--config=/dev/null', + '--no-install', + join('/slot', 'orcad.js'), + ORCAD_STARTUP_PREFLIGHT_FLAG, + expect.any(String) + ], env: expect.objectContaining({ ORCA_BACKGROUND_LAUNCH: '1' }), timeoutMs: 90_000, maxOutputBytes: 64 * 1024, @@ -137,7 +144,7 @@ describe('bundled Orca startup readiness', () => { }) await vi.waitFor(() => expect(fixture.run).toHaveBeenCalledOnce()) expect(admitted).toBe(false) - exit.resolve(readyResult(fixture.run.mock.calls[0]?.[0].args?.[2])) + exit.resolve(readyResult(fixture.run.mock.calls[0]?.[0].args?.at(-1))) await startup expect(admitted).toBe(true) }) @@ -146,7 +153,7 @@ describe('bundled Orca startup readiness', () => { 'refuses a failed child even if it emitted a valid readiness reply: %j', async (failure) => { fixture.run.mockImplementation(async (spec) => ({ - ...readyResult(spec.args?.[2]), + ...readyResult(spec.args?.at(-1)), ...failure, stderr: 'native probe failed' })) @@ -155,7 +162,10 @@ describe('bundled Orca startup readiness', () => { ) it('preserves configuration exit status from the isolated child', async () => { - fixture.run.mockImplementation(async (spec) => ({ ...readyResult(spec.args?.[2]), code: 78 })) + fixture.run.mockImplementation(async (spec) => ({ + ...readyResult(spec.args?.at(-1)), + code: 78 + })) const failure = await preflightBundledOrcadStartup().catch((error: unknown) => error) expect(resolveOrcadExitCode(failure)).toBe(78) }) @@ -180,7 +190,7 @@ describe('bundled Orca startup readiness', () => { it('rechecks the child artifact identity against the verified installed version', async () => { fixture.run.mockImplementation(async (spec) => { - const result = readyResult(spec.args?.[2]) + const result = readyResult(spec.args?.at(-1)) return { ...result, stdout: result.stdout.replace(identity, '0.1.0+bbbbbbbbbbbb') } }) await expect(preflightBundledOrcadStartup()).rejects.toThrow('invalid readiness identity') diff --git a/src/main/orcad/orcad-profile-preflight.ts b/src/main/orcad/orcad-profile-preflight.ts index eeb5bb611dd9..4a1bf1da68ae 100644 --- a/src/main/orcad/orcad-profile-preflight.ts +++ b/src/main/orcad/orcad-profile-preflight.ts @@ -1,3 +1,4 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { z } from 'zod' import { randomUUID } from 'node:crypto' import { readFile } from 'node:fs/promises' @@ -29,7 +30,12 @@ export async function preflightBundledOrcadStartup(): Promise { // 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], + args: [ + ...bunOwnedRuntimeArgs(), + 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, diff --git a/src/main/ssh/orcad-local-build-hash.ts b/src/main/ssh/orcad-local-build-hash.ts index 3740ea6d55c5..6de7dc211bed 100644 --- a/src/main/ssh/orcad-local-build-hash.ts +++ b/src/main/ssh/orcad-local-build-hash.ts @@ -1,22 +1,9 @@ -/** - * The build identity the deploy expects the host to answer with. - * - * It must be computed the same way `computeOrcadBuildHash` computes it on the host — - * sha256 of `orcad.js`, first 16 hex characters — or the activation gate would reject every - * healthy candidate. Keeping the two in one comment is deliberate: they are one contract - * split across a network, and the version string cannot stand in for it, because - * `ORCA_VERSION` is whatever the launch command exported and two builds can carry one value. - */ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' import { join } from 'node:path' +import { readOrcadBuildHash } from '../../shared/orcad-build-hash' -export const ORCAD_BUILD_HASH_LENGTH = 16 +export { ORCAD_BUILD_HASH_LENGTH } from '../../shared/orcad-build-hash' +/** The activation gate compares this with the host's hash of the same installed bytes. */ export function computeLocalOrcadBuildHash(localOrcadDir: string): string { - const entry = join(localOrcadDir, 'orcad.js') - return createHash('sha256') - .update(readFileSync(entry)) - .digest('hex') - .slice(0, ORCAD_BUILD_HASH_LENGTH) + return readOrcadBuildHash(join(localOrcadDir, 'orcad.js')) } diff --git a/src/main/ssh/orcad-remote-preflight.ts b/src/main/ssh/orcad-remote-preflight.ts index 5bba386d4618..6e24666b7c31 100644 --- a/src/main/ssh/orcad-remote-preflight.ts +++ b/src/main/ssh/orcad-remote-preflight.ts @@ -1,3 +1,4 @@ +import { bunOwnedRuntimeArgs } from '../../shared/bun-owned-runtime-args' import { randomUUID } from 'node:crypto' import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime' import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts' @@ -21,6 +22,7 @@ export function orcadProfilePreflightCommand( return [ 'ORCA_BACKGROUND_LAUNCH=1', shellEscape(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os))), + ...bunOwnedRuntimeArgs('linux'), shellEscape(joinRemotePath(host, directory, 'orcad.js')), ORCAD_PROFILE_PREFLIGHT_FLAG, shellEscape(nonce) diff --git a/src/main/ssh/orcad-remote-runtime.test.ts b/src/main/ssh/orcad-remote-runtime.test.ts index 637ab61e670a..cb86f19131e7 100644 --- a/src/main/ssh/orcad-remote-runtime.test.ts +++ b/src/main/ssh/orcad-remote-runtime.test.ts @@ -65,3 +65,25 @@ describe.skipIf(process.platform === 'win32')('POSIX slot runtime selection', () expect(launch(fixture(), process.execPath)).toMatchObject({ code: 0, stdout: 'selected' }) }) }) + +it.skipIf(process.platform === 'win32')( + 'limits dotenv isolation flags to bundled Bun slots, preserving legacy Node', + () => { + for (const bundled of [false, true]) { + const directory = fixture() + if (bundled) { + writeFileSync(join(directory, '.build-target'), 'linux-x64-glibc') + symlinkSync(process.execPath, join(directory, 'bun-runtime')) + } + const result = runProcessSync({ + program: '/bin/sh', + args: [ + '-c', + `${selectOrcadSlotRuntimeCommand(host, directory, process.execPath)}; printf '%s' "$orcad_runtime_flags"` + ] + }) + expect(result.code).toBe(0) + expect(result.stdout).toBe(bundled ? '--no-env-file --config=/dev/null --no-install' : '') + } + } +) diff --git a/src/main/ssh/orcad-remote-runtime.ts b/src/main/ssh/orcad-remote-runtime.ts index 505965620a0e..d957edb30eca 100644 --- a/src/main/ssh/orcad-remote-runtime.ts +++ b/src/main/ssh/orcad-remote-runtime.ts @@ -14,7 +14,7 @@ export function selectOrcadSlotRuntimeCommand( 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` + `[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; orcad_runtime_flags='--no-env-file --config=/dev/null --no-install'; ` + + `else orcad_runtime=${shellEscape(legacyNodePath)}; orcad_runtime_flags=; fi` ) } diff --git a/src/shared/bun-owned-runtime-args.integration.test.ts b/src/shared/bun-owned-runtime-args.integration.test.ts new file mode 100644 index 000000000000..eacbb2edffde --- /dev/null +++ b/src/shared/bun-owned-runtime-args.integration.test.ts @@ -0,0 +1,107 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { bunOwnedRuntimeArgs } from './bun-owned-runtime-args' +import { runProcess } from './child-process/run-process' + +const runtime = process.env.BUN_EXECUTABLE + +it.skipIf(!runtime)( + 'loads installed packages but never retrieves missing packages in owned script or eval launches', + async () => { + if (!runtime) { + throw new Error('BUN_EXECUTABLE is required') + } + const root = await mkdtemp(join(tmpdir(), 'orca-owned-bun-packages-')) + const requests: string[] = [] + const registry = createServer((request, response) => { + requests.push(request.url ?? '') + response.writeHead(404, { 'Content-Type': 'application/json' }) + response.end('{"error":"controlled missing package"}') + }) + try { + await new Promise((resolve, reject) => { + registry.once('error', reject) + registry.listen(0, '127.0.0.1', resolve) + }) + const address = registry.address() + if (!address || typeof address === 'string') { + throw new Error('The private package registry did not bind a TCP port') + } + const installed = join(root, 'installed') + const missing = join(root, 'missing') + const home = join(root, 'home') + const packageDirectory = join(installed, 'node_modules', 'orca-installed-fixture') + await Promise.all([mkdir(packageDirectory, { recursive: true }), mkdir(missing), mkdir(home)]) + await writeFile( + join(packageDirectory, 'package.json'), + JSON.stringify({ name: 'orca-installed-fixture', version: '1.0.0', main: 'index.js' }) + ) + await writeFile(join(packageDirectory, 'index.js'), 'module.exports = "installed-package"') + const missingName = 'orca-missing-owned-runtime-fixture' + await writeFile( + join(missing, 'package.json'), + JSON.stringify({ name: 'probe', dependencies: { [missingName]: '1.0.0' } }) + ) + const registryUrl = `http://127.0.0.1:${address.port}` + const npmrc = join(home, '.npmrc') + await writeFile(npmrc, `registry=${registryUrl}\n`) + await writeFile(join(missing, '.npmrc'), `registry=${registryUrl}\n`) + const run = async (cwd: string, source: string, evalMode: boolean, owned = true) => { + await writeFile(join(cwd, 'entry.cjs'), source) + return runProcess({ + program: runtime, + args: [ + ...bunOwnedRuntimeArgs().filter((arg) => owned || arg !== '--no-install'), + ...(evalMode ? ['-e', source] : ['entry.cjs']) + ], + cwd, + env: { + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, + WINDIR: process.env.WINDIR, + TEMP: root, + TMP: root, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: home, + XDG_CACHE_HOME: join(root, 'cache'), + BUN_INSTALL_CACHE_DIR: join(root, 'bun-cache'), + npm_config_registry: registryUrl, + npm_config_userconfig: npmrc, + ORCA_BACKGROUND_LAUNCH: '1' + }, + timeoutMs: 10_000 + }) + } + for (const evalMode of [false, true]) { + const present = await run( + installed, + 'console.log(require("orca-installed-fixture"))', + evalMode + ) + expect(present.code, present.stderr).toBe(0) + expect(present.stdout.trim()).toBe('installed-package') + const absent = await run(missing, `require('${missingName}')`, evalMode) + expect(absent.timedOut).toBe(false) + expect(absent.code).toBe(1) + expect(absent.stderr).toContain(`Cannot find package '${missingName}'`) + expect(requests).toEqual([]) + } + // Prove the same missing import reaches this registry without the owned runtime policy. + const control = await run(missing, `require('${missingName}')`, false, false) + expect(control.timedOut).toBe(false) + expect(control.code).toBe(1) + expect(requests).toContain(`/${missingName}`) + } finally { + registry.closeAllConnections() + if (registry.listening) { + await new Promise((resolve) => registry.close(() => resolve())) + } + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + } + }, + 60_000 +) diff --git a/src/shared/bun-owned-runtime-args.ts b/src/shared/bun-owned-runtime-args.ts new file mode 100644 index 000000000000..3ead55f5eecc --- /dev/null +++ b/src/shared/bun-owned-runtime-args.ts @@ -0,0 +1,8 @@ +/** Owned tools must not execute project bunfig preloads or load project dotenv files. */ +export function bunOwnedRuntimeArgs(platform: NodeJS.Platform = process.platform): string[] { + return [ + '--no-env-file', + platform === 'win32' ? '--config=NUL' : '--config=/dev/null', + '--no-install' + ] +} diff --git a/src/shared/child-process/fork-process-bun.integration.test.ts b/src/shared/child-process/fork-process-bun.integration.test.ts new file mode 100644 index 000000000000..1d89e7cb5432 --- /dev/null +++ b/src/shared/child-process/fork-process-bun.integration.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { build } from 'esbuild' +import { expect, it } from 'vitest' +import { runProcess } from './run-process' + +const runtime = process.env.BUN_EXECUTABLE +it.skipIf(!runtime)( + 'isolates same-runtime Bun forks from workspace dotenv and preload code', + async () => { + if (!runtime) { + throw new Error('BUN_EXECUTABLE is required') + } + const root = await mkdtemp(join(tmpdir(), 'orca-bun-fork-env-')) + try { + const parent = join(root, 'parent.cjs') + const child = join(root, 'child.cjs') + await build({ + stdin: { + contents: `import { forkProcess } from './src/shared/child-process/fork-process'; + process.execArgv.length=0; + forkProcess({modulePath:process.argv[2], cwd:process.argv[3], stdio:['ignore','inherit','inherit','ipc']}) + .on('error', error => { console.error(error);process.exitCode=1 }) + .on('exit', code => { process.exitCode=code??1 })`, + loader: 'ts', + resolveDir: process.cwd() + }, + platform: 'node', + format: 'cjs', + bundle: true, + outfile: parent + }) + await writeFile(join(root, 'bunfig.toml'), 'preload=["./preload.cjs"]\n') + await writeFile(join(root, 'preload.cjs'), 'console.log("UNTRUSTED_PRELOAD")') + await writeFile(join(root, '.env'), 'ORCA_USER_DATA_PATH=foreign-profile\n') + await writeFile( + child, + 'console.log(JSON.stringify(process.env.ORCA_USER_DATA_PATH??null));process.disconnect?.()' + ) + const env: NodeJS.ProcessEnv = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + delete env.ORCA_USER_DATA_PATH + const result = await runProcess({ + program: runtime, + args: ['--no-env-file', parent, child, root], + cwd: process.cwd(), + env, + timeoutMs: 10_000 + }) + expect(result.code, result.stderr).toBe(0) + expect(result.timedOut).toBe(false) + expect(JSON.parse(result.stdout)).toBeNull() + } finally { + await rm(root, { recursive: true, force: true }) + } + } +) diff --git a/src/shared/child-process/fork-process-runtime.test.ts b/src/shared/child-process/fork-process-runtime.test.ts new file mode 100644 index 000000000000..dc84f2279a3d --- /dev/null +++ b/src/shared/child-process/fork-process-runtime.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +const { fork } = vi.hoisted(() => ({ fork: vi.fn() })) +vi.mock('node:child_process', () => ({ fork })) +import { forkProcess } from './fork-process' +afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() +}) +describe('owned fork dotenv isolation', () => { + it('disables dotenv in same-runtime Bun helpers while preserving other arguments', () => { + vi.stubGlobal('process', { + ...process, + versions: { ...process.versions, bun: '1.4.2' }, + execArgv: ['--inspect', '--no-env-file', '--config=foreign.toml'] + }) + forkProcess({ modulePath: '/helper.js' }) + expect(fork).toHaveBeenCalledWith( + '/helper.js', + [], + expect.objectContaining({ + execArgv: [ + '--inspect', + '--no-env-file', + process.platform === 'win32' ? '--config=NUL' : '--config=/dev/null', + '--no-install' + ] + }) + ) + }) + it.each(['--config', '-c'])( + 'replaces an inherited %s path without dropping later flags', + (flag) => { + vi.stubGlobal('process', { + ...process, + versions: { ...process.versions, bun: '1.4.2' }, + execArgv: [flag, 'foreign.toml', '--inspect'] + }) + forkProcess({ modulePath: '/helper.js' }) + expect(fork.mock.calls[0][2].execArgv).toEqual([ + '--inspect', + '--no-env-file', + process.platform === 'win32' ? '--config=NUL' : '--config=/dev/null', + '--no-install' + ]) + } + ) + it('does not pass Bun flags to an explicitly selected Node or Electron binary', () => { + vi.stubGlobal('process', { ...process, versions: { ...process.versions, bun: '1.4.2' } }) + forkProcess({ modulePath: '/helper.js', execPath: '/other/node' }) + expect(fork.mock.calls[0][2]).not.toHaveProperty('execArgv') + }) + it('preserves ordinary Node fork argument inheritance', () => { + vi.stubGlobal('process', { ...process, versions: { ...process.versions, bun: undefined } }) + forkProcess({ modulePath: '/helper.js' }) + expect(fork.mock.calls[0][2]).not.toHaveProperty('execArgv') + }) +}) diff --git a/src/shared/child-process/fork-process.ts b/src/shared/child-process/fork-process.ts index f718f2a30382..0ef4bb99f883 100644 --- a/src/shared/child-process/fork-process.ts +++ b/src/shared/child-process/fork-process.ts @@ -1,3 +1,4 @@ +import { bunOwnedRuntimeArgs } from '../bun-owned-runtime-args' // `fork` arm of Orca's child-process chokepoint. Kept beside `run-process.ts` for the same // reason that file exists: callers outside this directory must not import `node:child_process`, // and a guard test enforces that against a shrinking allowlist. @@ -33,6 +34,25 @@ export type ForkSpec = { export function forkProcess(spec: ForkSpec): SpawnedProcess { const options: ForkOptions = { + // Child cwd must not choose configuration before the helper starts. + ...(process.versions.bun && (!spec.execPath || spec.execPath === process.execPath) + ? { + execArgv: [ + ...process.execArgv.filter( + (arg, index, args) => + arg !== '--no-env-file' && + arg !== '--no-install' && + arg !== '--config' && + arg !== '-c' && + !arg.startsWith('--config=') && + !arg.startsWith('-c=') && + args[index - 1] !== '--config' && + args[index - 1] !== '-c' + ), + ...bunOwnedRuntimeArgs() + ] + } + : {}), cwd: spec.cwd, env: spec.env, detached: spec.detached, diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index fea71dc63c4c..a37d4a458096 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -66,6 +66,7 @@ export type OrcadArtifact = { export const ORCAD_ARTIFACTS: readonly OrcadArtifact[] = [ { filename: 'orcad.js' }, + { filename: 'orcad-app.js' }, // Forked so a native @parcel/watcher fault kills the child, not the server. { filename: 'parcel-watcher-process-entry.js' }, // Forked so PTYs outlive the runtime process; its absence makes every restart destructive. diff --git a/src/shared/orcad-build-hash.ts b/src/shared/orcad-build-hash.ts new file mode 100644 index 000000000000..1e8d50ad74aa --- /dev/null +++ b/src/shared/orcad-build-hash.ts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +export const ORCAD_BUILD_HASH_LENGTH = 16 +const APPLICATION_FILENAME = 'orcad-app.js' + +/** Keep legacy identities stable; split builds must identify the application as well. */ +export function readOrcadBuildHash(entryPath: string): string { + const entry = readFileSync(entryPath) + let application: Buffer + try { + application = readFileSync(join(dirname(entryPath), APPLICATION_FILENAME)) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' && + !entry.includes('orcad-app') + ) { + return createHash('sha256').update(entry).digest('hex').slice(0, ORCAD_BUILD_HASH_LENGTH) + } + throw error + } + return createHash('sha256') + .update(`orcad-split-build\0${entry.length}\0`) + .update(entry) + .update(`\0${application.length}\0`) + .update(application) + .digest('hex') + .slice(0, ORCAD_BUILD_HASH_LENGTH) +} diff --git a/src/shared/serve-supervisor-control.ts b/src/shared/serve-supervisor-control.ts new file mode 100644 index 000000000000..aa7b689938c7 --- /dev/null +++ b/src/shared/serve-supervisor-control.ts @@ -0,0 +1,65 @@ +export const SERVE_STOP_READY = 'orca:serve-stop-ready' +export const SERVE_STOP_REQUEST = 'orca:serve-stop' + +export type ServeSupervisorChannel = { + on(event: 'message', listener: (message: unknown) => void): unknown + send?: (message: string, callback: (error: Error | null) => void) => boolean +} + +export function registerServeSupervisorControl( + channel: ServeSupervisorChannel, + stop: () => void +): void { + channel.on('message', (message) => { + if (message === SERVE_STOP_REQUEST) { + stop() + } + }) + try { + channel.send?.(SERVE_STOP_READY, () => undefined) + } catch { + // Console signals remain available if the supervisor has disconnected. + } +} + +type ServeControlChild = { + readonly connected: boolean + send(message: string, callback: (error: Error | null) => void): boolean +} + +/** Keep early shutdown requests until the child installs graceful quit handling. */ +export function createServeStopRequest(child: ServeControlChild): { + request(): void + handleMessage(value: unknown): void +} { + let ready = false + let pending = false + const reportError = (error: unknown): void => { + if (error) { + console.error('[serve] could not request graceful shutdown:', error) + } + } + const send = (): void => { + if (!ready || !pending || !child.connected) { + return + } + pending = false + try { + child.send(SERVE_STOP_REQUEST, reportError) + } catch (error) { + reportError(error) + } + } + return { + request() { + pending = true + send() + }, + handleMessage(value) { + if (value === SERVE_STOP_READY) { + ready = true + send() + } + } + } +}