Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .github/workflows/bun-profile-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 18 additions & 20 deletions config/scripts/build-orcad.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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/')
Expand Down Expand Up @@ -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)}`
Expand All @@ -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` +
Expand All @@ -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)}`
Expand All @@ -304,7 +303,6 @@ if (graphErrors.length > 0) {
}

try {
await smokeProfileStateWorkers(OUT_DIR)
if (targetIsCurrent) {
await smokeProfileStateWorkers(OUT_DIR, { runtimePath: bunRuntimeOutput })
}
Expand Down
4 changes: 3 additions & 1 deletion config/scripts/bun-profile-change-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion config/scripts/bun-profile-test-paths.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export function bunProfileTestPaths({ artifact = false } = {}) {
return [
'src/shared/bun-owned-runtime-args.integration.test.ts',
'src/shared/child-process/fork-process-bun.integration.test.ts',
'src/main/persistence/profile-state',
'src/main/persistence/loading-store/profile-state',
'src/main/sqlite',
Expand All @@ -17,7 +19,9 @@ export function bunProfileTestPaths({ artifact = false } = {}) {
'src/main/providers/local-pty-bun-artifact.integration.test.ts',
'src/main/providers/agent-foreground-process-git-bash.win32.test.ts',
'src/main/orcad/orcad-bun-launcher.integration.test.ts',
'config/scripts/zip-extractor-command.test.mjs'
'config/scripts/zip-extractor-command.test.mjs',
'config/scripts/orcad-launcher-build.test.mjs',
'src/main/orcad/orcad-bundle-native-load-order.test.ts'
]
: [])
]
Expand Down
21 changes: 19 additions & 2 deletions config/scripts/orcad-entry-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -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'
})
}
111 changes: 111 additions & 0 deletions config/scripts/orcad-launcher-build.test.mjs
Original file line number Diff line number Diff line change
@@ -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')
})
})
9 changes: 8 additions & 1 deletion config/scripts/run-bun-profile-tests.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { bunOwnedRuntimeArgs } from '../../src/shared/bun-owned-runtime-args.ts'
import { join, resolve } from 'node:path'
import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
Expand Down Expand Up @@ -42,7 +43,12 @@ if (artifact) {
const nonce = randomUUID()
const result = runProcessSync({
program: runtimePath,
args: [join(runtimeDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce],
args: [
...bunOwnedRuntimeArgs(),
join(runtimeDir, 'orcad.js'),
ORCAD_PROFILE_PREFLIGHT_FLAG,
nonce
],
cwd: root,
env,
timeoutMs: 90_000
Expand All @@ -66,6 +72,7 @@ if (artifact) {
])
}
run(runtimePath, [
...bunOwnedRuntimeArgs(),
join(root, 'node_modules/vitest/vitest.mjs'),
'run',
'--config',
Expand Down
17 changes: 14 additions & 3 deletions config/scripts/runtime-serve-terminal-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions src/cli/runtime/serve-stop-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createServeStopRequest } from '../../shared/serve-supervisor-control'
Loading
Loading