mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
feat: validate direct bundled Bun startup before loading orcad
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { createServeStopRequest } from '../../shared/serve-supervisor-control'
|
||||
@@ -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'])
|
||||
|
||||
+14
-44
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<ReturnType<typeof spawnProcess>>()
|
||||
const runtimes = new Set<number>()
|
||||
@@ -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<void>((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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<ReturnType<typeof spawnProcess>>()
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<NodeJS.Signals, ReturnType<typeof process.rawListeners>>
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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<void>): () => Promise<void> {
|
||||
let completion: Promise<void> | null = null
|
||||
return () => {
|
||||
@@ -21,7 +18,7 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise<void>): () => 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<void>,
|
||||
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<T extends object>(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user