diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml index e579782c651..90051a97c1c 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 b015f52ea2e..51a397f29c1 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 c30c4a282ac..da2af7fc262 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 23c8f423acd..e9a4da22437 100644 --- a/config/scripts/bun-profile-test-paths.mjs +++ b/config/scripts/bun-profile-test-paths.mjs @@ -17,7 +17,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 fa2c83050ac..91c8a93964e 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 00000000000..b1f89a701eb --- /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/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index a03c85983d8..c3d5c792c42 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 00000000000..ec269b2cb10 --- /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/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index e746be8cc5e..7effd4ce42d 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 79fc667fd22..20ef1d83494 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 00000000000..e1a0bde70ec --- /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 8ee18d53293..e19ed242f96 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 } - } + 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.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 }) + 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.skipIf(process.platform === 'win32').each([false, true])( - 'survives nohup hangups and drains on TERM (direct Bun: %s)', - async (direct) => { - const h = launch({ direct, nohup: true }) - await vi.waitFor(() => expect(h.output()).toContain('ready'), { timeout: 5_000 }) - if (!h.child.pid) { - throw new Error('Missing launcher pid') - } - process.kill(-h.child.pid, 'SIGHUP') - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(h.child.exitCode).toBeNull() - expect(h.child.signalCode).toBeNull() - expect(h.output()).not.toContain('flushing') - h.child.kill('SIGTERM') - expect(await h.exit).toEqual({ code: 0, signal: null }) - expect(await readFile(join(directory, 'done'), 'utf8')).toBe('flushed') - await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 }) - } - ) - - it('keeps an unfinished shutdown alive until its failure deadline', async () => { + 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 5ed9085fa36..7629e0a6737 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 loaded = run() - expect(loaded.code, loaded.stderr.slice(0, 2_000)).toBe(0) - expect(existsSync(marker)).toBe(false) + 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 + }) - // 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) + const loaded = run() + expect(loaded.code, loaded.stderr.slice(0, 2_000)).toBe(0) + expect(existsSync(marker)).toBe(false) + + // Prove the interception works without relying on minified source echoed in an error. + const eagerNative = join(directory, 'eager-native.cjs') + writeFileSync(eagerNative, "require('node-pty')") + expect(run(['--require', eagerNative]).code).not.toBe(0) + expect(existsSync(marker)).toBe(true) + }, + 90_000 +) diff --git a/src/main/orcad/orcad-bundled-runtime.integration.test.ts b/src/main/orcad/orcad-bundled-runtime.integration.test.ts deleted file mode 100644 index 9db0d5000c9..00000000000 --- 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 5541314057e..ff6212289d9 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}`) + expect(() => assertBundledOrcadRuntime()).toThrow(`must be Bun ${ORCAD_BUN_VERSION}`) }) - it.each(['linux', 'darwin', 'win32'] as const)( - 'hands off arguments and respects %s signal delivery', - (platform) => { - vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) - expect(handoffToBundledOrcad()).toBe(true) - expect(fixture.spawn).toHaveBeenCalledWith({ - program: expect.stringMatching(/bun-runtime(?:\.exe)?$/), - args: ['/slot/orcad.js', '--port', '0'], - env: expect.objectContaining({ ORCA_BUNDLED_LAUNCHER_CHANNEL: '1' }), - detached: true, - stdio: ['inherit', 'inherit', 'inherit', 'ipc'] - }) - for (const signal of signalNames) { - const listener = process - .rawListeners(signal) - .find((candidate) => !oldListeners.get(signal)?.includes(candidate)) - if (signal === 'SIGHUP' && platform === 'win32') { - expect(listener).toBeUndefined() - continue - } - expect(listener).toBeDefined() - if (listener) { - listener.call(process, signal) - } - if (signal === 'SIGHUP') { - expect(child.kill).not.toHaveBeenCalledWith('SIGHUP') - } else if (platform === 'win32') { - expect(child.kill).not.toHaveBeenCalled() - expect(child.disconnect).toHaveBeenCalled() - } else { - expect(child.kill).toHaveBeenLastCalledWith(signal) - } - } - } - ) - - it('propagates a child exit code and removes every signal listener', () => { - handoffToBundledOrcad() - expect(() => child.emit('exit', 23, null)).toThrow('test process exit') - expect(process.exit).toHaveBeenCalledWith(23) - expect(process.kill).not.toHaveBeenCalled() - for (const signal of signalNames) { - expect(process.rawListeners(signal)).toEqual(oldListeners.get(signal)) - } + it('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('locates the runtime beside the resolved entry rather than its symlink', () => { - fixture.realpath.mockImplementation((path) => - path === '/slot/orcad.js' ? '/real/slot/orcad.js' : path - ) - handoffToBundledOrcad() - expect(fixture.spawn).toHaveBeenCalledWith( - expect.objectContaining({ - program: expect.stringMatching(/real\/slot\/bun-runtime(?:\.exe)?$/), - args: ['/real/slot/orcad.js', '--port', '0'] - }) - ) - }) - - it('reports failed spawn as a configuration failure and removes listeners', () => { - handoffToBundledOrcad() - expect(() => child.emit('error', new Error('ENOENT'))).toThrow('test process exit') - expect(process.exit).toHaveBeenCalledWith(78) - for (const signal of signalNames) { - expect(process.rawListeners(signal)).toEqual(oldListeners.get(signal)) - } - }) - - it('mirrors a POSIX signal exit without exiting before the signal is delivered', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') - handoffToBundledOrcad() - child.emit('exit', null, 'SIGTERM') - expect(process.kill).toHaveBeenCalledWith(process.pid, 'SIGTERM') - expect(process.exit).not.toHaveBeenCalled() - }) - - it('preserves a signal exit without sending unsupported signals on Windows', () => { - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') - handoffToBundledOrcad() - expect(() => child.emit('exit', null, 'SIGTERM')).toThrow('test process exit') - expect(process.exit).toHaveBeenCalledWith(143) - expect(process.kill).not.toHaveBeenCalled() + 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 05b86387124..196f9866fba 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 + return } - const child = spawnProcess({ - program: runtime, - args: [entry, ...process.argv.slice(2)], - env: { ...process.env, [ORCAD_BUNDLED_LAUNCHER_ENV]: '1' }, - // Windows' default child job kills Bun before it can drain on launcher disconnect. - detached: true, - stdio: ['inherit', 'inherit', 'inherit', 'ipc'] - }) - // Node resets nohup's disposition; headless runtimes stop through INT/TERM or owner loss. - const ignoreHangup = (): void => {} - if (process.platform !== 'win32') { - process.on('SIGHUP', ignoreHangup) - } - const forwards = (['SIGINT', 'SIGTERM'] as const).map((signal) => { - const forward = (): void => { - if (process.platform === 'win32') { - // Detached Windows children have a separate console; kill() skips durable shutdown. - if (child.connected) { - child.disconnect() - } - } else { - child.kill(signal) - } - } - process.on(signal, forward) - return { signal, forward } - }) - const cleanup = (): void => { - process.off('SIGHUP', ignoreHangup) - for (const { signal, forward } of forwards) { - process.off(signal, forward) - } - } - child.once('error', (error) => { - cleanup() - console.error('orcad: could not start the bundled runtime:', error.message) - process.exit(78) - }) - child.once('exit', (code, signal) => { - cleanup() - if (signal && process.platform !== 'win32') { - process.kill(process.pid, signal) - return - } - process.exit(code ?? (signal ? 128 + constants.signals[signal] : 1)) - }) - return true + throw new OrcadBundledRuntimeError(`Start orcad with its bundled runtime: ${runtime}`) } diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index bfff3b7f14e..7c462c73304 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/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index fea71dc63c4..a37d4a45809 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/serve-supervisor-control.ts b/src/shared/serve-supervisor-control.ts new file mode 100644 index 00000000000..aa7b689938c --- /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() + } + } + } +}