Bundle Bun for headless Orca and profile persistence (#22635)

Bundle a pinned, verified Bun runtime for headless Orca so existing Node launch commands can hand off before opening a profile. Keep desktop execution on Electron.

Add the Bun SQLite adapter and terminal backend, bounded shutdown, process inspection and cross-platform artifact qualification. Keep future managed SSH deployment separate from current production launch paths.
This commit is contained in:
OrcaWin
2026-09-25 22:49:06 -07:00
committed by GitHub
parent 38bcdf76ac
commit 6fc3cdcad6
173 changed files with 13419 additions and 948 deletions
+106
View File
@@ -0,0 +1,106 @@
name: Bun profile persistence
on:
pull_request:
paths:
- 'src/main/persistence/**'
- 'src/main/sqlite/**'
- 'src/main/worker-thread-entry-path.ts'
- 'src/main/orcad/**'
- 'src/main/daemon/pty-subprocess/**'
- 'src/main/providers/**'
- 'src/shared/**'
- 'config/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/actions/install-node-dependencies/**'
- '.github/workflows/bun-profile-tests.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: bun-profile-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
persistence:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, ubuntu-24.04-arm, macos-14, macos-15-intel, windows-2022, windows-11-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Build the native Windows process reader
if: runner.os == 'Windows'
run: node config/scripts/build-windows-process-tree-relay-addon.mjs
- run: pnpm build:orcad
- run: pnpm test:bun:profile --artifact
- uses: actions/setup-node@v6
if: runner.arch == 'X64'
with:
node-version: '18'
- name: Verify Node 18 loads and hands off to bundled Bun
if: runner.arch == 'X64'
run: |
node out/orcad/orcad.js --orcad-smoke-load-check
node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018
linux_glibc_floor:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
container: ubuntu:20.04
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- name: Install Ubuntu 20.04 prerequisites
run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Trust the checked-out workspace
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- uses: ./.github/actions/install-node-dependencies
- run: pnpm build:orcad
- run: pnpm test:bun:profile --artifact
linux_musl:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Verify native Alpine artifact and persistence
run: |
docker run --rm --init -i \
-e ORCA_BACKGROUND_LAUNCH=1 \
-v "$GITHUB_WORKSPACE:/work" -w /work \
node:24-alpine3.23 sh -s <<'BUN_QUALIFICATION'
set -eu
apk add --no-cache bash git libstdc++ python3 make g++
git config --global --add safe.directory /work
npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")"
pnpm install --frozen-lockfile --ignore-scripts
pnpm build:orcad
pnpm test:bun:profile --artifact
BUN_QUALIFICATION
+3
View File
@@ -188,6 +188,9 @@ module.exports = {
// Why: these repo-only inputs are either bundled into out/ or copied via
// extraResources. Shipping them in app.asar bloats the desktop bundle.
'!src{,/**/*}',
'!out/orcad{,/**/*}',
'!out/orcad-template{,/**/*}',
'!out/.orcad-*{,/**/*}',
'!config{,/**/*}',
'!docs{,/**/*}',
'!mobile{,/**/*}',
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import { orcadBunRuntimeFilename } from '../../src/shared/orcad-artifacts.ts'
import {
ORCAD_BUN_RELEASE_ASSETS,
ORCAD_BUN_VERSION,
orcadBunReleaseUrl
} from '../../src/shared/orcad-bun-runtime.ts'
import { runProcessSync } from './script-child-process.mjs'
import { getZipExtractorCommand } from './zip-extractor-command.mjs'
const root = resolve(import.meta.dirname, '../..')
const cacheRoot = join(root, 'out', '.orcad-bun-runtime', `v${ORCAD_BUN_VERSION}`)
export function currentTarget() {
if (process.platform === 'darwin') {
return `darwin-${process.arch}`
}
if (process.platform === 'win32') {
return `win32-${process.arch}`
}
if (process.platform !== 'linux') {
throw new Error(`Unsupported Bun platform: ${process.platform}`)
}
const glibc = process.report?.getReport()?.header?.glibcVersionRuntime
return `linux-${process.arch}-${glibc ? 'glibc' : 'musl'}`
}
function argument(name) {
const index = process.argv.indexOf(name)
return index === -1 ? null : process.argv[index + 1]
}
async function download(url, destination) {
const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(120_000) })
if (!response.ok) {
await response.body?.cancel()
throw new Error(`Bun download failed: ${response.status} ${response.statusText}`)
}
writeFileSync(destination, new Uint8Array(await response.arrayBuffer()))
}
function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
export function bunExecutableName(target) {
return target.startsWith('win32-') ? 'bun.exe' : 'bun'
}
export function findBunExecutable(rootDir, target) {
const expected = bunExecutableName(target)
const entries = readdirSync(rootDir, { recursive: true, withFileTypes: true })
const entry = entries.find((candidate) => candidate.isFile() && candidate.name === expected)
if (!entry) {
throw new Error(`Downloaded archive contained no ${expected}`)
}
return join(entry.parentPath, entry.name)
}
function verifyRuntime(path) {
const result = runProcessSync({ program: path, args: ['--version'] })
if (result.code !== 0 || result.stdout.trim() !== ORCAD_BUN_VERSION) {
throw new Error(
`Expected Bun ${ORCAD_BUN_VERSION} at ${path}, got ${result.stdout.trim() || result.stderr.trim()}`
)
}
}
async function materializeRuntime(target, outputPath) {
const asset = ORCAD_BUN_RELEASE_ASSETS[target]
if (!asset) {
throw new Error(`Unsupported Bun target: ${target}`)
}
const cached = join(cacheRoot, target, orcadBunRuntimeFilename(target))
if (existsSync(cached) && sha256(cached) !== asset.executableSha256) {
rmSync(cached, { force: true })
}
if (!existsSync(cached)) {
const temporary = mkdtempSync(join(tmpdir(), 'orca-bun-download-'))
try {
const zipPath = join(temporary, basename(asset.filename))
await download(orcadBunReleaseUrl(asset), zipPath)
const actual = sha256(zipPath)
if (actual !== asset.sha256) {
throw new Error(`Bun checksum mismatch for ${asset.filename}: ${actual}`)
}
const extracted = join(temporary, 'extracted')
mkdirSync(extracted)
// Node 24.16 can leave extract-zip's stream promise unsettled with no active handles.
const command = getZipExtractorCommand(zipPath, extracted)
const result = runProcessSync({
program: command.file,
args: command.args,
timeoutMs: 120_000
})
if (result.code !== 0) {
throw new Error(
`Bun archive extraction failed with exit ${result.code}: ${result.stderr || result.stdout}`
)
}
mkdirSync(join(cacheRoot, target), { recursive: true })
copyFileSync(findBunExecutable(extracted, target), cached)
if (!target.startsWith('win32-')) {
chmodSync(cached, 0o755)
}
} finally {
rmSync(temporary, { recursive: true, force: true })
}
}
const executableHash = sha256(cached)
if (executableHash !== asset.executableSha256) {
throw new Error(`Bun executable checksum mismatch for ${target}: ${executableHash}`)
}
if (target === currentTarget()) {
verifyRuntime(cached)
}
mkdirSync(resolve(outputPath, '..'), { recursive: true })
if (resolve(cached) !== resolve(outputPath)) {
copyFileSync(cached, outputPath)
}
if (!target.startsWith('win32-')) {
chmodSync(outputPath, 0o755)
}
}
async function main() {
const target = argument('--target') ?? currentTarget()
const outputDir = argument('--out-dir')
const cachedRuntimePath = join(cacheRoot, target, orcadBunRuntimeFilename(target))
const runtimePath =
process.argv.includes('--runtime-only') && outputDir
? join(resolve(outputDir), orcadBunRuntimeFilename(target))
: cachedRuntimePath
await materializeRuntime(target, runtimePath)
if (process.argv.includes('--runtime-only')) {
process.stdout.write(`${runtimePath}\n`)
return
}
const result = runProcessSync({
program: process.execPath,
args: [join(root, 'config/scripts/build-orcad.mjs')],
cwd: root,
env: {
...process.env,
ORCAD_BUILD_TARGET: target,
ORCAD_BUILD_TARGET_IS_CURRENT: target === currentTarget() ? '1' : '0',
ORCAD_BUN_RUNTIME_PATH: runtimePath,
...(outputDir ? { ORCAD_OUT_DIR: resolve(outputDir) } : {})
},
stdio: 'inherit',
timeoutMs: null
})
if (result.code !== 0) {
process.exit(result.code ?? 1)
}
}
if (process.argv[1]?.endsWith('build-orcad-bun.mjs')) {
await main()
}
+39
View File
@@ -0,0 +1,39 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import { bunExecutableName, findBunExecutable } from './build-orcad-bun.mjs'
const temporaryDirs = []
afterEach(() => {
for (const dir of temporaryDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function archiveTree(filename) {
const root = mkdtempSync(join(tmpdir(), 'orcad-bun-archive-'))
temporaryDirs.push(root)
const nested = join(root, 'bun-release')
mkdirSync(nested)
writeFileSync(join(nested, filename), '')
return root
}
describe('orcad Bun archive extraction', () => {
it('selects bun.exe for a Windows target on a non-Windows builder', () => {
const root = archiveTree('bun.exe')
expect(findBunExecutable(root, 'win32-x64')).toBe(join(root, 'bun-release', 'bun.exe'))
})
it('selects bun for a POSIX target', () => {
const root = archiveTree('bun')
expect(findBunExecutable(root, 'linux-x64-glibc')).toBe(join(root, 'bun-release', 'bun'))
})
it('derives executable names from the target rather than the builder host', () => {
expect(bunExecutableName('win32-arm64')).toBe('bun.exe')
expect(bunExecutableName('darwin-arm64')).toBe('bun')
})
})
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_TEMPLATE_MANIFEST_FILENAME,
ORCAD_TEMPLATE_TARGETS_DIR,
ORCAD_RIPGREP_ARTIFACTS,
orcadTemplateCommonFilenames
} from '../../src/shared/orcad-artifacts.ts'
import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts'
import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/orcad-bun-runtime.ts'
import { runProcessSync } from './script-child-process.mjs'
import { materializeWatcherPackage } from './orcad-watcher-package.mjs'
import { verifyPackagedOrcadTemplate } from './verify-packaged-orcad-template.cjs'
const root = resolve(import.meta.dirname, '../..')
const outputDir = join(root, 'out', 'orcad-template')
const buildDir = join(root, 'out', '.orcad-template-build')
const commonArtifacts = orcadTemplateCommonFilenames()
function copy(source, destination, executable = false) {
mkdirSync(dirname(destination), { recursive: true })
copyFileSync(source, destination)
if (executable && process.platform !== 'win32') {
chmodSync(destination, 0o755)
}
}
function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
function targetPlatform(target) {
return target.split('-')[0]
}
function targetArch(target) {
return target.split('-')[1]
}
function buildCommonArtifacts() {
rmSync(buildDir, { recursive: true, force: true })
const result = runProcessSync({
program: process.execPath,
args: [join(root, 'config/scripts/build-orcad-bun.mjs'), '--out-dir', buildDir],
cwd: root,
stdio: 'inherit',
timeoutMs: null
})
if (result.code !== 0) {
throw new Error(`Common orcad artifact build failed with exit ${result.code ?? 'unknown'}`)
}
}
async function stageTarget(target) {
const destination = join(outputDir, ORCAD_TEMPLATE_TARGETS_DIR, target)
const targetIdentity = join(destination, ORCAD_BUILD_TARGET_FILENAME)
mkdirSync(destination, { recursive: true })
writeFileSync(targetIdentity, `${target}\n`)
const watcherSource = await materializeWatcherPackage(target)
const watcherDestination = join(destination, 'watcher.node')
copy(watcherSource, watcherDestination)
const browserName = orcadAgentBrowserNativeName(
targetPlatform(target),
targetArch(target),
target.endsWith('-musl') ? 'musl' : 'glibc'
)
const browserSource = join(root, 'node_modules', 'agent-browser', 'bin', browserName)
const browserDestination = join(destination, browserName)
if (existsSync(browserSource)) {
copy(browserSource, browserDestination, true)
}
return {
targetSha256: sha256(targetIdentity),
watcherSha256: sha256(watcherDestination),
...(existsSync(browserDestination)
? { browserName, browserSha256: sha256(browserDestination) }
: {})
}
}
async function main() {
buildCommonArtifacts()
rmSync(outputDir, { recursive: true, force: true })
mkdirSync(outputDir, { recursive: true })
for (const filename of commonArtifacts) {
copy(
join(buildDir, filename),
join(outputDir, filename),
ORCAD_RIPGREP_ARTIFACTS.some((artifact) => artifact === filename && artifact.endsWith('/rg'))
)
}
const targets = Object.fromEntries(
await Promise.all(
ORCAD_TEMPLATE_TARGETS.map(async (target) => [target, await stageTarget(target)])
)
)
const commonSha256 = Object.fromEntries(
commonArtifacts.map((filename) => [filename, sha256(join(outputDir, filename))])
)
writeFileSync(
join(outputDir, ORCAD_TEMPLATE_MANIFEST_FILENAME),
`${JSON.stringify({ schemaVersion: 2, commonSha256, targets }, null, 2)}\n`
)
verifyPackagedOrcadTemplate(join(root, 'out'))
rmSync(buildDir, { recursive: true, force: true })
process.stdout.write(`[build-orcad-template] ok — ${ORCAD_TEMPLATE_TARGETS.length} targets\n`)
}
await main()
+146 -97
View File
@@ -1,36 +1,46 @@
#!/usr/bin/env node
/**
* Bundle `orcad` — the Orca runtime served from plain Node, no Electron.
*
* Variant B (see docs/design/node-only-runtime-backend.html): the browser-pane and
* speech clusters are excluded. That is not a size optimisation — those modules are
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
* the host Node floor at 18 instead of 22.5+.
*/
// Ship Bun with orcad; keep module loading compatible with legacy Node launchers.
import { fork, spawnSync } from 'node:child_process'
import { build } from 'esbuild'
import {
buildOrcadEntry,
externalNativeAddons,
ORCAD_EXTERNAL_MODULES
} from './orcad-entry-build.mjs'
import { createRequire } from 'node:module'
import {
chmodSync,
copyFileSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync
} from 'node:fs'
import { arch, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import process from 'node:process'
import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs'
import { materializeWatcherPackage } from './orcad-watcher-package.mjs'
import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs'
import {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_EMOJI_SHORTCODE_DATASET,
orcadBunRuntimeFilename,
ORCAD_PARCEL_WATCHER_ENTRY,
ORCAD_PARCEL_WATCHER_NATIVE,
ORCAD_VERSION_FILENAME,
ORCAD_RIPGREP_ARTIFACTS
} from '../../src/shared/orcad-artifacts.ts'
import { computeOrcadFullVersion } from './orcad-artifact-version.mjs'
import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts'
import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
const OUT_DIR = process.env.ORCAD_OUT_DIR
? resolve(process.env.ORCAD_OUT_DIR)
: join(ROOT, 'out', 'orcad')
// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher
// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath`
// looks for it in the app root. A deployment has no desktop out/main to fall back to.
@@ -41,46 +51,86 @@ const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
// orcad restart would SIGKILL every running terminal.
const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts')
const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js')
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
const PTY_GATE_ENTRY = join(ROOT, 'src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts')
const PTY_GATE_OUT_FILE = join(OUT_DIR, 'windows-bun-pty-gate-entry.js')
const OUT_FILE = join(OUT_DIR, 'orcad.js')
const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET
if (!BUILD_TARGET) {
throw new Error('ORCAD_BUILD_TARGET is required; run `pnpm build:orcad`')
}
const [targetPlatform, targetArch] = BUILD_TARGET.split('-')
const targetIsWindows = targetPlatform === 'win32'
const targetIsCurrent = process.env.ORCAD_BUILD_TARGET_IS_CURRENT === '1'
const AGENT_BROWSER_NAME = orcadAgentBrowserNativeName(
targetPlatform,
targetArch,
BUILD_TARGET.endsWith('-musl') ? 'musl' : 'glibc'
)
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
const WATCHER_MODULE_DIR = join(OUT_DIR, 'node_modules', '@parcel', 'watcher')
// Native addons must exist on the host; they cannot be bundled.
// `electron` is external so a residual import fails loudly at require() time rather
// than silently bundling the npm package's installer shim, which is what happened the
// first time and made the bundle look clean while it was not.
// Why only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad
// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the
// server boots without it but every watch install fails. `fsevents` is macOS-only and
// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here
// defensively and appear nowhere in the graph; listing them implied a shipping burden
// that does not exist.
const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents']
/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */
const jsoncParserEsm = {
name: 'jsonc-parser-esm',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({
path: join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
}))
}
}
/** Why: optional native deps reference prebuilt .node files that may not exist here. */
const externalNativeAddons = {
name: 'external-native-addons',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
}
async function stageParcelWatcher(target) {
const requireFromWatcher = createRequire(
join(ROOT, 'node_modules', '@parcel', 'watcher', 'index.js')
)
const nativeSource = await materializeWatcherPackage(target)
const wrapperSource = requireFromWatcher.resolve('@parcel/watcher/wrapper.js')
mkdirSync(WATCHER_MODULE_DIR, { recursive: true })
await build({
stdin: {
contents:
`const {createWrapper}=require(${JSON.stringify(wrapperSource)});` +
`module.exports=createWrapper(require('./watcher.node'));`,
resolveDir: ROOT,
sourcefile: 'orcad-parcel-watcher-entry.js'
},
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(OUT_DIR, ORCAD_PARCEL_WATCHER_ENTRY),
external: ['./watcher.node'],
minify: true,
sourcemap: false,
logLevel: 'error'
})
copyFileSync(nativeSource, join(OUT_DIR, ORCAD_PARCEL_WATCHER_NATIVE))
}
rmSync(OUT_DIR, { recursive: true, force: true })
mkdirSync(OUT_DIR, { recursive: true })
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
if (process.platform !== 'win32') {
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
const bunRuntimeSource = process.env.ORCAD_BUN_RUNTIME_PATH
if (!bunRuntimeSource) {
throw new Error('ORCAD_BUN_RUNTIME_PATH is required; run `pnpm build:orcad`')
}
if (targetIsCurrent) {
const version = spawnSync(bunRuntimeSource, ['--version'], { encoding: 'utf8' })
if (version.status !== 0 || version.stdout.trim() !== ORCAD_BUN_VERSION) {
throw new Error(
`ORCAD_BUN_RUNTIME_PATH must be Bun ${ORCAD_BUN_VERSION}; got ${version.stdout.trim() || version.stderr.trim()}`
)
}
}
const bunRuntimeOutput = join(OUT_DIR, orcadBunRuntimeFilename(BUILD_TARGET))
copyFileSync(bunRuntimeSource, bunRuntimeOutput)
writeFileSync(join(OUT_DIR, ORCAD_BUILD_TARGET_FILENAME), `${BUILD_TARGET}\n`)
if (!targetIsWindows) {
chmodSync(bunRuntimeOutput, 0o755)
}
await stageParcelWatcher(BUILD_TARGET)
stageOrcadWindowsProcessTree(ROOT, OUT_DIR, BUILD_TARGET)
const emojiDatasetOutput = join(OUT_DIR, ORCAD_EMOJI_SHORTCODE_DATASET)
mkdirSync(dirname(emojiDatasetOutput), { recursive: true })
copyFileSync(
createRequire(import.meta.url).resolve('emojibase-data/en/shortcodes/emojibase.json'),
emojiDatasetOutput
)
if (existsSync(AGENT_BROWSER_SOURCE)) {
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
if (!targetIsWindows) {
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
}
}
// Why every platform: an SSH deployment can target a different host than the build machine.
for (const artifact of ORCAD_RIPGREP_ARTIFACTS) {
@@ -100,8 +150,10 @@ cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep',
recursive: true
})
// Child and worker resolvers require flat entries beside orcad.js.
function buildIsolatedEntry(entryPoint, outfile) {
/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
* directory under `outdir`, and both children must land flat beside orcad.js — that is where
* their runtime resolvers look for them. */
function buildForkedChild(entryPoint, outfile) {
return build({
entryPoints: [entryPoint],
bundle: true,
@@ -109,42 +161,31 @@ function buildIsolatedEntry(entryPoint, outfile) {
target: 'node18',
format: 'cjs',
outfile,
external: EXTERNAL,
external: ORCAD_EXTERNAL_MODULES,
plugins: [externalNativeAddons],
metafile: true,
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
define: {
'process.env.NODE_ENV': '"production"'
},
logLevel: 'error'
})
}
const isolatedResults = await Promise.all([
buildIsolatedEntry(WATCHER_ENTRY, WATCHER_OUT_FILE),
buildIsolatedEntry(DAEMON_ENTRY, DAEMON_OUT_FILE),
const childResults = await Promise.all([
buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE),
buildForkedChild(PTY_GATE_ENTRY, PTY_GATE_OUT_FILE),
...['writer', 'backup'].map((role) =>
buildIsolatedEntry(
buildForkedChild(
join(ROOT, `src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`),
join(OUT_DIR, `profile-state-${role}-worker-entry.js`)
)
)
])
const result = await build({
entryPoints: [ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: OUT_FILE,
external: EXTERNAL,
plugins: [jsoncParserEsm, externalNativeAddons],
metafile: true,
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
logLevel: 'error'
})
const result = await buildOrcadEntry(OUT_FILE)
const output = Object.values(result.metafile.outputs).find(
(o) => o.entryPoint === 'src/main/orcad/main.ts'
@@ -152,7 +193,9 @@ const output = Object.values(result.metafile.outputs).find(
// Why check `original` and not just `path`: when electron is bundleable, esbuild
// rewrites `path` to the resolved file under node_modules and the naive check passes
// while the package is very much in the bundle.
// Every isolated entry ships under the same plain-Node compatibility contract.
// Why both metafiles: the forked children ship in the same deployment and runtime. A
// daemon-entry that reached electron would fail at fork time, on the
// path whose whole point is that terminals survive.
function collectImporters(metafiles, matches) {
const importers = new Set()
for (const metafile of metafiles) {
@@ -167,7 +210,7 @@ function collectImporters(metafiles, matches) {
return importers
}
const metafiles = [result.metafile, ...isolatedResults.map((entry) => entry.metafile)]
const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)]
const electronImporters = collectImporters(
metafiles,
(specifier) => specifier === 'electron' || specifier.startsWith('electron/')
@@ -199,7 +242,7 @@ if (graphErrors.length > 0) {
process.exitCode = 1
} else {
// Why smoke-load and not just read the metafile: the import scan proves no module
// *names* electron, but a graph can still fail to resolve under plain Node — a
// *names* electron, but the rollback graph can still fail to resolve under plain Node — a
// dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
// smoke-loads its entries for exactly this reason, and orcad cannot join that guard
// because it is an esbuild artifact rather than a rollup input.
@@ -214,7 +257,7 @@ if (graphErrors.length > 0) {
const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
if (smoke.error || smoke.signal || smoke.status !== 0) {
console.error(
`[build-orcad] the bundle did not load under plain Node.\n` +
`[build-orcad] the bundle lost Node load compatibility.\n` +
`Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` +
`signal=${smoke.signal ?? 'none'} ` +
`error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
@@ -243,26 +286,30 @@ if (graphErrors.length > 0) {
const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}`
if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) {
console.error(
`[build-orcad] the daemon child did not load under plain Node.\n` +
`[build-orcad] the daemon child lost Node load compatibility.\n` +
`Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` +
`signal=${daemonSmoke.signal ?? 'none'} ` +
`error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}`
)
process.exitCode = 1
}
const watcherFailure = await smokeLoadWatcherChild()
const watcherFailure = targetIsCurrent ? await smokeLoadWatcherChild(bunRuntimeOutput) : null
if (watcherFailure) {
console.error(
`[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}`
`[build-orcad] the watcher child failed under the bundled runtime.\n${watcherFailure}`
)
process.exitCode = 1
}
try {
await smokeProfileStateWorkers(OUT_DIR)
} catch (error) {
console.error('[build-orcad] profile state worker check failed:', error)
process.exitCode = 1
}
try {
await smokeProfileStateWorkers(OUT_DIR)
if (targetIsCurrent) {
await smokeProfileStateWorkers(OUT_DIR, { runtimePath: bunRuntimeOutput })
}
} catch (error) {
console.error('[build-orcad] profile state worker check failed:', error)
process.exitCode = 1
}
// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on
@@ -270,26 +317,26 @@ if (graphErrors.length > 0) {
// already-`.install-complete` dir is never re-uploaded. The deploy would silently run stale
// bytes while reporting the new version.
if (process.exitCode !== 1) {
const fullVersion = computeOrcadFullVersion(OUT_DIR)
const fullVersion = computeOrcadFullVersion(OUT_DIR, {
target: BUILD_TARGET,
agentBrowserFilename: AGENT_BROWSER_NAME
})
writeFileSync(join(OUT_DIR, ORCAD_VERSION_FILENAME), fullVersion)
console.log(
`[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
`[build-orcad] ok — ${fullVersion}, ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports, Bun ${ORCAD_BUN_VERSION} included.`
)
}
/**
* Fork the shipped watcher child and drive one message through it.
*
* Why a real fork and not existsSync: the file being present says nothing about whether
* its graph resolves under plain Node, and this child is only ever reached through
* `fork()` at runtime — a broken one degrades silently to in-process watching.
* `subscribe-started` is acked before the native module is touched, so this passes on a
* build machine with no compiled @parcel/watcher.
*/
async function smokeLoadWatcherChild() {
// Verify the shipped native watcher actually subscribes under the bundled runtime.
async function smokeLoadWatcherChild(runtimePath) {
const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-'))
const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
const child = fork(WATCHER_OUT_FILE, [], {
execPath: runtimePath,
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
windowsHide: true
})
let stderr = ''
let subscribed = false
child.stderr?.on('data', (chunk) => {
stderr += String(chunk)
})
@@ -297,16 +344,14 @@ async function smokeLoadWatcherChild() {
return await new Promise((resolve) => {
const timer = setTimeout(() => {
child.kill('SIGKILL')
resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`)
resolve(`Watcher did not complete its subscription within 30s.\n${stderr.slice(0, 2000)}`)
}, 30_000)
const settle = (failure) => {
clearTimeout(timer)
resolve(failure)
}
child.on('message', (message) => {
// Wait until the subscribe lifecycle has sent its final acknowledgement.
// Disconnecting on subscribe-started races the subsequent subscribed or
// subscribe-failed message and makes the child report an expected EPIPE.
subscribed ||= message?.op === 'subscribed'
if (message?.op === 'subscribed' || message?.op === 'subscribe-failed') {
child.disconnect()
}
@@ -315,7 +360,11 @@ async function smokeLoadWatcherChild() {
// Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code
// or a signal here is a load failure rather than a clean teardown.
child.on('exit', (code, signal) =>
settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`)
settle(
code === 0 && subscribed
? null
: `subscribed=${subscribed} exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`
)
)
child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} })
})
@@ -527,6 +527,8 @@ describe('packaged runtime resources', () => {
)
})
const BUN_RUNTIME_BUILTINS = new Set(['bun:ffi', 'bun:sqlite'])
// Why source-anchored: the bundler renames a createRequire()'d require, so
// verifyPackagedMainRuntimeDeps' `require("x")` scan cannot see these specifiers — packaging
// stays green while the packaged app throws MODULE_NOT_FOUND the first time the path runs.
@@ -555,7 +557,7 @@ function collectLazyRequireSpecifiers(directory, found = new Map()) {
continue
}
for (const match of source.matchAll(/\brequire[A-Za-z0-9_]*\(\s*'([^']+)'\s*\)/g)) {
if (isPackagedExternalSpecifier(match[1])) {
if (!BUN_RUNTIME_BUILTINS.has(match[1]) && isPackagedExternalSpecifier(match[1])) {
found.set(match[1], relative(projectRoot, entryPath).replaceAll('\\', '/'))
}
}
@@ -572,6 +574,26 @@ function packagedResourceDestinations(platform) {
}
describe('lazily required packages reach Resources/node_modules', () => {
it('excludes Bun runtime builtins while retaining ordinary lazy dependencies', async () => {
const sourceDir = await mkdtemp(join(tmpdir(), 'orca-lazy-bun-builtins-'))
try {
await writeFile(
join(sourceDir, 'runtime.ts'),
[
'const requireFromMain = createRequire(import.meta.url)',
"requireFromMain('node:fs')",
"requireFromMain('bun:ffi')",
"requireFromMain('bun:sqlite')",
"requireFromMain('zod')",
"requireFromMain('bun-sqlite')"
].join('\n')
)
expect([...collectLazyRequireSpecifiers(sourceDir).keys()]).toEqual(['zod', 'bun-sqlite'])
} finally {
await removeTree(sourceDir)
}
})
it('copies every createRequire specifier main uses into the packaged resource plan', () => {
const specifiers = collectLazyRequireSpecifiers(join(projectRoot, 'src', 'main'))
expect(specifiers.size).toBeGreaterThan(0)
@@ -16,6 +16,7 @@ import { createRequire } from 'node:module'
import { platform as osPlatform, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { getElectronPlatformPath } from './electron-platform-path.mjs'
import { getZipExtractorCommand } from './zip-extractor-command.mjs'
import {
shareElectronDistFromCache,
hasAdoptedSharedElectronDist,
@@ -448,33 +449,7 @@ function getExtractorCommand(zipPath, extractDir) {
}
}
if (osPlatform() === 'win32') {
return {
file: process.env.ORCA_POWERSHELL_BIN || 'powershell',
args: [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
[
"$ErrorActionPreference = 'Stop'",
`Expand-Archive -LiteralPath ${quotePowerShellLiteral(zipPath)} -DestinationPath ${quotePowerShellLiteral(extractDir)} -Force`
].join('; ')
],
label: 'powershell Expand-Archive'
}
}
return {
file: process.env.ORCA_UNZIP_BIN || 'unzip',
args: ['-q', zipPath, '-d', extractDir],
label: 'unzip'
}
}
function quotePowerShellLiteral(value) {
return `'${String(value).replaceAll("'", "''")}'`
return getZipExtractorCommand(zipPath, extractDir)
}
function formatExtractorFailure(command, result) {
@@ -631,8 +631,27 @@ export function installPageErrorSentinel() {
* say that there was something to leak before it says that nothing did.
*/
export function installSchedulerRecorder() {
globalThis.__orcaScheduler = { watching: false, scheduled: [], leaked: [] }
globalThis.__orcaScheduler = { watching: false, scheduled: [], leaked: [], heldFrames: 0 }
const state = globalThis.__orcaScheduler
const requestFrame = globalThis.requestAnimationFrame.bind(globalThis)
const cancelFrame = globalThis.cancelAnimationFrame.bind(globalThis)
const heldFrames = new Map()
let nextHeldFrame = -2
globalThis.__orcaReleaseFrames = () => {
state.holdFramesFrom = null
for (const callback of heldFrames.values()) {
requestFrame(callback)
}
heldFrames.clear()
state.heldFrames = 0
}
globalThis.cancelAnimationFrame = (id) => {
if (heldFrames.delete(id)) {
state.heldFrames--
} else {
cancelFrame(id)
}
}
const wrap = (schedule, kind) =>
function (callback, ...rest) {
if (!state.watching || typeof callback !== 'function') {
@@ -646,21 +665,22 @@ export function installSchedulerRecorder() {
// it was cancelled or is merely waiting, and cancelling never sets it.
const entry = { kind, caller, owned: container !== null, fired: false }
state.scheduled.push(entry)
return schedule(
(...args) => {
entry.fired = true
if (container !== null && !container.isConnected) {
state.leaked.push(`${kind} from ${caller}`)
}
return callback(...args)
},
...rest
)
const recorded = (...args) => {
entry.fired = true
if (container !== null && !container.isConnected) {
state.leaked.push(`${kind} from ${caller}`)
}
return callback(...args)
}
if (kind === 'frame' && state.holdFramesFrom && caller.includes(state.holdFramesFrom)) {
const id = nextHeldFrame--
heldFrames.set(id, recorded)
state.heldFrames++
return id
}
return schedule(recorded, ...rest)
}
globalThis.requestAnimationFrame = wrap(
globalThis.requestAnimationFrame.bind(globalThis),
'frame'
)
globalThis.requestAnimationFrame = wrap(requestFrame, 'frame')
globalThis.setTimeout = wrap(globalThis.setTimeout.bind(globalThis), 'timer')
globalThis.setInterval = wrap(globalThis.setInterval.bind(globalThis), 'interval')
}
@@ -583,13 +583,16 @@ describeEditor(
{ timeout: 15_000 }
)
}
// The inserted image painted: `naturalWidth` is 0 for an element the browser refused
// or never fetched, which is what a policy that did not admit it would leave.
expect(
await page.evaluate(
() => document.querySelector('#first-surface #editor img')?.naturalWidth ?? 0
// Insertion precedes image loading; a refused image must still fail this paint check.
await expect
.poll(
() =>
page.evaluate(
() => document.querySelector('#first-surface #editor img')?.naturalWidth ?? 0
),
{ timeout: 15_000 }
)
).toBeGreaterThan(0)
.toBeGreaterThan(0)
expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([])
expect(consoleErrors).toEqual([])
} finally {
@@ -453,31 +453,7 @@ describeRender(
}, 300_000)
it('takes back the frames it is owed, not only the timers', async () => {
// The timer case above is witnessed by a 550 ms timeout, which every module's own stop
// cancels by the handle the scope holds. A frame is the other shape: `applyFitScale` asks
// for one through the scope's registry and never holds its id, so `stopFitScale` can only
// bump the token it tests itself against — the frame still runs. Nothing but
// `cancelDocumentFrames` takes it back.
//
// Two things have to be pinned down for that to be readable, and the first version of this
// case had neither.
//
// The witness has to be owed whenever the dispose lands. A single refit is not: the retry
// loop commits on its first attempt whenever the grid still measures, so one resize buys
// one frame and a dispose after it owes nothing — which agrees with an empty leak list for
// exactly the reason under test, once in five runs. So the refit is re-armed from a frame
// of the test's own, which leaves the document owed a frame at the end of every frame the
// browser serves, and dispose cannot land inside one.
//
// And the leak has to be counted from the moment dispose returned, not from the moment the
// host element left the DOM. React unmounts in two steps: the mutation phase detaches the
// host, and the passive cleanup that calls `dispose` runs after it — 1 ms apart here, 20 to
// 35 ms apart with the CPU throttled 20x, which is the CI runner this failed on. A frame
// served in that gap runs with a detached container while the document is still live and
// has not been asked to stop, and no registry could take it back. It went through
// `scheduleDocumentFrame` like every other; the old oracle called it a leak because it
// judged by the container rather than by dispose. Only what runs after the last statement
// of `dispose` is the document keeping something it gave up.
// Hold a real refit frame across disposal; ResizeObserver delivery cannot race the witness.
let documentChunk = null
const { page } = await openPage(PROBE_ROUTE, {
scheduler: true,
@@ -492,72 +468,74 @@ describeRender(
})
}
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
expect(documentChunk, 'the document was served as its own chunk').not.toBe(null)
await page.evaluate((chunk) => {
const state = globalThis.__orcaScheduler
state.disposed = null
state.watching = true
// `dispose` empties the host and drops its class last, after `cancelDocumentFrames`, so
// the class going is the moment it returned. Observed on the element rather than on the
// tree because React may have detached it already.
const host = document.querySelector('.orca-terminal-document-host')
const observer = new MutationObserver(() => {
if (state.disposed !== null || host.classList.contains('orca-terminal-document-host')) {
return
}
state.disposed = {
// A cancelled frame never runs, so it is still owed here. That is the point.
owed: state.scheduled.filter(
(entry) => entry.kind === 'frame' && !entry.fired && entry.caller.includes(chunk)
).length,
leakedBefore: state.leaked.length
}
observer.disconnect()
try {
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
observer.observe(host, { attributes: true, attributeFilter: ['class'] })
// The page's refit follows the host's box, not the window, so the pulse resizes the host.
let narrow = false
const pulse = () => {
if (state.disposed !== null) {
return
}
narrow = !narrow
host.style.width = narrow ? '99%' : ''
requestAnimationFrame(pulse)
}
requestAnimationFrame(pulse)
globalThis.setTimeout(() => globalThis.__orcaTerminalProbe.setMounted(false), 200)
}, documentChunk)
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
await page.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 3000)))
await openProbeTerminal(page)
expect(documentChunk, 'the document was served as its own chunk').not.toBe(null)
const scheduler = await page.evaluate(() => globalThis.__orcaScheduler)
expect(
scheduler.disposed?.owed,
'the document owed a frame at the moment dispose returned'
).toBeGreaterThan(0)
expect(
scheduler.leaked
.slice(scheduler.disposed.leakedBefore)
.filter((entry) => entry.startsWith('frame ') && entry.includes(documentChunk))
).toEqual([])
await page.unrouteAll({ behavior: 'ignoreErrors' })
await page.close()
await page.evaluate((chunk) => {
const state = globalThis.__orcaScheduler
state.disposed = null
state.watching = true
state.holdFramesFrom = chunk
const host = document.querySelector('.orca-terminal-document-host')
// Dispose drops this class after cancelling frames; DOM detachment precedes cleanup.
const observer = new MutationObserver(() => {
if (state.disposed !== null || host.classList.contains('orca-terminal-document-host')) {
return
}
state.disposed = {
owed: state.scheduled.filter(
(entry) => entry.kind === 'frame' && !entry.fired && entry.caller.includes(chunk)
).length,
leakedBefore: state.leaked.length
}
observer.disconnect()
})
observer.observe(host, { attributes: true, attributeFilter: ['class'] })
host.style.width = '80%'
}, documentChunk)
await page.waitForFunction(() => globalThis.__orcaScheduler.heldFrames > 0, undefined, {
timeout: 30_000
})
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.waitForFunction(() => globalThis.__orcaScheduler.disposed !== null)
await page.evaluate(() => {
globalThis.__orcaScheduler.holdFramesFrom = null
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
// Uncancelled work must actually run against the replacement, so the hold cannot hide leaks.
await page.evaluate(
() =>
new Promise((resolve) => {
globalThis.__orcaReleaseFrames()
requestAnimationFrame(() => requestAnimationFrame(resolve))
})
)
const scheduler = await page.evaluate(() => globalThis.__orcaScheduler)
expect(
scheduler.disposed?.owed,
'the document owed a frame at the moment dispose returned'
).toBeGreaterThan(0)
expect(
scheduler.leaked
.slice(scheduler.disposed.leakedBefore)
.filter((entry) => entry.startsWith('frame ') && entry.includes(documentChunk))
).toEqual([])
} finally {
await page.unrouteAll({ behavior: 'ignoreErrors' }).finally(() => page.close())
}
}, 300_000)
it('styles what it owns, and only that', async () => {
+11 -4
View File
@@ -1,11 +1,15 @@
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { ORCAD_VERSION, orcadArtifactFilenames } from '../../src/shared/orcad-artifacts.ts'
import {
ORCAD_VERSION,
orcadArtifactFilenames,
orcadArtifactHashPrefix
} from '../../src/shared/orcad-artifacts.ts'
export function computeOrcadFullVersion(artifactDir) {
const hash = createHash('sha256')
for (const filename of orcadArtifactFilenames()) {
export function computeOrcadFullVersion(artifactDir, { target = '', agentBrowserFilename } = {}) {
const hash = createHash('sha256').update(orcadArtifactHashPrefix(target))
for (const filename of orcadArtifactFilenames(target)) {
const artifactPath = join(artifactDir, filename)
if (!existsSync(artifactPath)) {
throw new Error(
@@ -15,5 +19,8 @@ export function computeOrcadFullVersion(artifactDir) {
}
hash.update(readFileSync(artifactPath))
}
if (agentBrowserFilename && existsSync(join(artifactDir, agentBrowserFilename))) {
hash.update(readFileSync(join(artifactDir, agentBrowserFilename)))
}
return `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}`
}
+54 -17
View File
@@ -1,30 +1,67 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_RIPGREP_ARTIFACTS,
orcadArtifactFilenames
} from '../../src/shared/orcad-artifacts.ts'
import { ORCAD_BUN_TARGETS } from '../../src/shared/orcad-bun-runtime.ts'
import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts'
import { readOrcadArtifactIdentity } from '../../src/main/orcad/orcad-artifact-identity.ts'
import { computeOrcadFullVersion } from './orcad-artifact-version.mjs'
const directories = []
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
function createArtifactDirectory(target = '') {
const directory = mkdtempSync(join(tmpdir(), 'orcad-version-'))
directories.push(directory)
for (const filename of orcadArtifactFilenames(target)) {
const path = join(directory, filename)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, filename)
}
writeFileSync(join(directory, ORCAD_BUILD_TARGET_FILENAME), `${target}\n`)
return directory
}
describe('standalone runtime version', () => {
it('changes when a shipped search binary changes and rejects a missing binary', () => {
const dir = mkdtempSync(join(tmpdir(), 'orcad-version-'))
try {
for (const filename of orcadArtifactFilenames()) {
const path = join(dir, filename)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, filename)
}
const before = computeOrcadFullVersion(dir)
const binary = join(dir, ORCAD_RIPGREP_ARTIFACTS[0])
writeFileSync(binary, 'updated binary')
expect(computeOrcadFullVersion(dir)).not.toBe(before)
rmSync(binary)
expect(() => computeOrcadFullVersion(dir)).toThrow(ORCAD_RIPGREP_ARTIFACTS[0])
} finally {
rmSync(dir, { recursive: true, force: true })
}
const dir = createArtifactDirectory()
const before = computeOrcadFullVersion(dir)
const binary = join(dir, ORCAD_RIPGREP_ARTIFACTS[0])
writeFileSync(binary, 'updated binary')
expect(computeOrcadFullVersion(dir)).not.toBe(before)
rmSync(binary)
expect(() => computeOrcadFullVersion(dir)).toThrow(ORCAD_RIPGREP_ARTIFACTS[0])
})
it.each(ORCAD_BUN_TARGETS)(
'matches the installed %s identity with and without its optional browser',
async (target) => {
const dir = createArtifactDirectory(target)
const [platform, arch] = target.split('-')
const agentBrowserFilename = orcadAgentBrowserNativeName(
platform,
arch,
target.endsWith('-musl') ? 'musl' : 'glibc'
)
const options = { target, agentBrowserFilename }
const withoutBrowser = computeOrcadFullVersion(dir, options)
expect(withoutBrowser).toBe(await readOrcadArtifactIdentity(dir))
writeFileSync(join(dir, agentBrowserFilename), 'browser')
const withBrowser = computeOrcadFullVersion(dir, options)
expect(withBrowser).not.toBe(withoutBrowser)
expect(withBrowser).toBe(await readOrcadArtifactIdentity(dir))
writeFileSync(join(dir, agentBrowserFilename), 'updated-browser')
expect(computeOrcadFullVersion(dir, options)).not.toBe(withBrowser)
expect(computeOrcadFullVersion(dir, options)).toBe(await readOrcadArtifactIdentity(dir))
}
)
})
+49
View File
@@ -0,0 +1,49 @@
import { build } from 'esbuild'
import { join } from 'node:path'
const root = join(import.meta.dirname, '..', '..')
export const ORCAD_EXTERNAL_MODULES = [
'electron',
'node-pty',
'@parcel/watcher',
'fsevents',
'bun:ffi',
'bun:sqlite'
]
// Native binaries are staged separately from every JavaScript entry.
export const externalNativeAddons = {
name: 'external-native-addons',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
}
}
// The UMD build's relative dynamic requires cannot be bundled.
const jsoncParserEsm = {
name: 'jsonc-parser-esm',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /^jsonc-parser$/ }, () => ({
path: join(root, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
}))
}
}
export function buildOrcadEntry(outfile) {
return build({
entryPoints: [join(root, 'src/main/orcad/main.ts')],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile,
external: ORCAD_EXTERNAL_MODULES,
plugins: [jsoncParserEsm, externalNativeAddons],
metafile: true,
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
logLevel: 'error'
})
}
@@ -0,0 +1,50 @@
import { createHash } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_TEMPLATE_MANIFEST_FILENAME,
ORCAD_TEMPLATE_TARGETS_DIR,
orcadTemplateCommonFilenames
} from '../../src/shared/orcad-artifacts.ts'
import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/orcad-bun-runtime.ts'
async function write(path, contents) {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, contents)
return createHash('sha256').update(contents).digest('hex')
}
export async function writeOrcadTemplateTestFixture(resourcesDir) {
const templateDir = join(resourcesDir, 'orcad-template')
const commonFilenames = orcadTemplateCommonFilenames()
const commonSha256 = {}
for (const filename of commonFilenames) {
commonSha256[filename] = await write(
join(templateDir, ...filename.split('/')),
Buffer.from(`common:${filename}`)
)
}
const targets = {}
for (const target of ORCAD_TEMPLATE_TARGETS) {
const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target)
targets[target] = {
targetSha256: await write(join(targetDir, ORCAD_BUILD_TARGET_FILENAME), `${target}\n`),
watcherSha256: await write(join(targetDir, 'watcher.node'), `watcher:${target}`)
}
}
const browserName = 'agent-browser-linux-x64'
targets['linux-x64-glibc'] = {
...targets['linux-x64-glibc'],
browserName,
browserSha256: await write(
join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, 'linux-x64-glibc', browserName),
'browser'
)
}
await writeFile(
join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME),
JSON.stringify({ schemaVersion: 2, commonSha256, targets })
)
return templateDir
}
+86
View File
@@ -0,0 +1,86 @@
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { x as extractTar } from 'tar'
import { parseAllDocuments } from 'yaml'
import { ORCAD_BUN_TARGETS } from '../../src/shared/orcad-bun-runtime.ts'
const root = resolve(import.meta.dirname, '../..')
const require = createRequire(import.meta.url)
const archiveLimit = 16 * 1024 * 1024
export function parseWatcherLockfile(contents) {
const packages = {}
for (const document of parseAllDocuments(contents)) {
if (document.errors.length) {
throw document.errors[0]
}
Object.assign(packages, document.toJS()?.packages)
}
return { packages }
}
export function watcherPackageIdentity(target, version, lockfile) {
if (!ORCAD_BUN_TARGETS.includes(target)) {
throw new Error(`Unsupported watcher target: ${target}`)
}
const name = `@parcel/watcher-${target}`
const integrity = lockfile.packages?.[`${name}@${version}`]?.resolution?.integrity
if (typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+=*$/.test(integrity)) {
throw new Error(`The lockfile does not pin ${name}@${version}`)
}
return {
integrity,
url: `https://registry.npmjs.org/${name}/-/watcher-${target}-${version}.tgz`
}
}
export function verifyWatcherArchive(bytes, integrity) {
if (bytes.length > archiveLimit) {
throw new Error('Watcher archive exceeds the size limit')
}
if (`sha512-${createHash('sha512').update(bytes).digest('base64')}` !== integrity) {
throw new Error('Watcher archive does not match the lockfile integrity')
}
}
// Fetch only these small release assets; ordinary installs remain host-only.
export async function materializeWatcherPackage(target) {
const { version } = require('@parcel/watcher/package.json')
const lockfile = parseWatcherLockfile(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8'))
const { integrity, url } = watcherPackageIdentity(target, version, lockfile)
const cache = join(root, 'out', '.orcad-watchers', version, target)
const archivePath = join(cache, 'package.tgz')
await mkdir(cache, { recursive: true })
let bytes
try {
bytes = await readFile(archivePath)
verifyWatcherArchive(bytes, integrity)
} catch {
const response = await fetch(url, { signal: AbortSignal.timeout(60_000) })
if (!response.ok || !response.body) {
await response.body?.cancel()
throw new Error(`Watcher download failed: ${response.status} ${response.statusText}`)
}
const chunks = []
let length = 0
for await (const chunk of response.body) {
length += chunk.length
if (length > archiveLimit) {
throw new Error('Watcher archive exceeds the size limit')
}
chunks.push(chunk)
}
bytes = Buffer.concat(chunks)
verifyWatcherArchive(bytes, integrity)
await writeFile(archivePath, bytes)
}
await extractTar({
file: archivePath,
cwd: cache,
strict: true,
filter: (path, entry) => path === 'package/watcher.node' && entry.type === 'File'
})
return join(cache, 'package', 'watcher.node')
}
@@ -0,0 +1,40 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
parseWatcherLockfile,
verifyWatcherArchive,
watcherPackageIdentity
} from './orcad-watcher-package.mjs'
describe('locked watcher release assets', () => {
const archive = Buffer.from('archive')
const integrity = `sha512-${createHash('sha512').update(archive).digest('base64')}`
it('reads dependency pins after the package-manager document in pnpm 12 lockfiles', () => {
const lockfile = parseWatcherLockfile(
`---\npackages: {}\n---\npackages:\n '@parcel/watcher-linux-x64-glibc@2.5.6':\n resolution:\n integrity: ${integrity}\n`
)
expect(watcherPackageIdentity('linux-x64-glibc', '2.5.6', lockfile).integrity).toBe(integrity)
})
it('resolves a target using the exact installed wrapper version and locked integrity', () => {
const lockfile = {
packages: { '@parcel/watcher-linux-x64-musl@2.5.6': { resolution: { integrity } } }
}
expect(watcherPackageIdentity('linux-x64-musl', '2.5.6', lockfile)).toEqual({
integrity,
url: 'https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz'
})
expect(() => watcherPackageIdentity('linux-x64-glibc', '2.5.6', lockfile)).toThrow('lockfile')
expect(() => watcherPackageIdentity('linux-x64-musl', '2.5.7', lockfile)).toThrow('lockfile')
})
it('rejects missing, unknown and corrupted inputs before extraction', () => {
expect(() => watcherPackageIdentity('../x64', '2.5.6', {})).toThrow('Unsupported')
expect(() => verifyWatcherArchive(archive, integrity)).not.toThrow()
expect(() => verifyWatcherArchive(Buffer.from('tampered'), integrity)).toThrow('integrity')
expect(() => verifyWatcherArchive(Buffer.alloc(16 * 1024 * 1024 + 1), integrity)).toThrow(
'size limit'
)
})
})
@@ -0,0 +1,48 @@
import { copyFileSync, lstatSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { ORCAD_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/orcad-artifacts.ts'
import {
inspectWindowsProcessTreeAddon,
windowsProcessTreeAddonPath
} from './windows-process-tree-gyp-rebuild.mjs'
const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)(
'./windows-pe-machine.cjs'
)
export function stageOrcadWindowsProcessTree(
root,
outputDir,
target,
host = { platform: process.platform, arch: process.arch }
) {
if (!target.startsWith('win32-')) {
return
}
const arch = target.slice('win32-'.length)
let source = join(
root,
'.build',
'windows-process-tree',
arch,
ORCAD_WINDOWS_PROCESS_TREE_FILENAME
)
// Ordinary Windows installs already compile this N-API addon for the host.
if (target === `${host.platform}-${host.arch}` && !lstatSync(source, { throwIfNoEntry: false })) {
source = windowsProcessTreeAddonPath(
join(root, 'node_modules', '@vscode', 'windows-process-tree')
)
}
if (inspectWindowsProcessTreeAddon(source) !== 'clean') {
throw new Error(
`Orcad ${target} requires a patched process reader. On Windows, run: ` +
`node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=${arch}`
)
}
const machine = readPeMachine(source)
if (machine !== PE_MACHINE[arch]) {
throw new Error(`Orcad ${target} process reader has ${describePeMachine(machine)}`)
}
copyFileSync(source, join(outputDir, ORCAD_WINDOWS_PROCESS_TREE_FILENAME))
}
@@ -0,0 +1,106 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { stageOrcadWindowsProcessTree } from './orcad-windows-process-tree.mjs'
import { windowsProcessTreeAddonPath } from './windows-process-tree-gyp-rebuild.mjs'
const windowsHost = { platform: 'win32', arch: 'x64' }
const roots = []
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })))
function fixture(machine = 0x8664, suffix = '') {
const root = mkdtempSync(join(tmpdir(), 'orcad-process-reader-'))
roots.push(root)
const source = join(root, '.build/windows-process-tree/x64')
const output = join(root, 'output')
mkdirSync(source, { recursive: true })
mkdirSync(output)
const bytes = Buffer.alloc(0x90)
bytes.write('MZ')
bytes.writeUInt32LE(0x80, 0x3c)
bytes.write('PE\0\0', 0x80)
bytes.writeUInt16LE(machine, 0x84)
const file = join(source, 'windows-process-tree.node')
writeFileSync(file, Buffer.concat([bytes, Buffer.from(suffix)]))
return { root, output, file }
}
function installedFixture(machine = 0x8664, suffix = '') {
const prepared = fixture(machine, suffix)
const packageDir = join(prepared.root, 'node_modules', '@vscode', 'windows-process-tree')
mkdirSync(join(packageDir, 'build', 'Release'), { recursive: true })
const installed = windowsProcessTreeAddonPath(packageDir)
writeFileSync(installed, readFileSync(prepared.file))
rmSync(prepared.file)
return { ...prepared, installed }
}
it('stages only a clean reader for the requested machine', () => {
const { root, output, file } = fixture()
stageOrcadWindowsProcessTree(root, output, 'win32-x64')
expect(readFileSync(join(output, 'windows-process-tree.node'))).toEqual(readFileSync(file))
})
it('rejects an unpatched reader even when its architecture matches', () => {
const { root, output } = fixture(0x8664, 'ReadProcessMemory')
expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64')).toThrow(
'patched process reader'
)
})
it('rejects wrong architecture and absent artifacts', () => {
const { root, output } = fixture(0xaa64)
expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64')).toThrow('machine 0xaa64')
expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-arm64')).toThrow(
'patched process reader'
)
})
it('keeps POSIX builds independent of Windows build tools', () => {
expect(() => stageOrcadWindowsProcessTree('absent', 'absent', 'linux-x64-glibc')).not.toThrow()
})
it('reuses the checked native addon from an ordinary Windows host install', () => {
const { root, output, installed } = installedFixture()
stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost)
expect(readFileSync(join(output, 'windows-process-tree.node'))).toEqual(readFileSync(installed))
})
it.each([
{ platform: 'darwin', arch: 'x64' },
{ platform: 'win32', arch: 'arm64' }
])('does not reuse host installation for a different target: %j', (host) => {
const { root, output } = installedFixture()
expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64', host)).toThrow(
'patched process reader'
)
})
it('requires the installed fallback to have the matching architecture and patch', () => {
const { root, output, installed } = installedFixture(0xaa64)
expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost)).toThrow(
'machine 0xaa64'
)
writeFileSync(installed, 'ReadProcessMemory')
expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost)).toThrow(
'patched process reader'
)
rmSync(installed)
expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost)).toThrow(
'patched process reader'
)
})
it('prefers explicit architecture builds and refuses to mask a stale one', () => {
const { root, output, file, installed } = installedFixture()
const staged = Buffer.concat([readFileSync(installed), Buffer.from('staged')])
writeFileSync(file, staged)
stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost)
expect(readFileSync(join(output, 'windows-process-tree.node'))).toEqual(staged)
writeFileSync(file, 'ReadProcessMemory')
expect(() => stageOrcadWindowsProcessTree(root, output, 'win32-x64', windowsHost)).toThrow(
'patched process reader'
)
})
+23 -1
View File
@@ -1,10 +1,17 @@
import { deepStrictEqual } from 'node:assert'
import { randomUUID } from 'node:crypto'
import { build } from 'esbuild'
import { mkdtempSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { Worker } from 'node:worker_threads'
import { runProcessSync } from './script-child-process.mjs'
import {
ORCAD_PROFILE_PREFLIGHT_FLAG,
parseOrcadProfilePreflight
} from '../../src/shared/orcad-profile-preflight.ts'
import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts'
async function initializeFixture(directory, databasePath, profileId) {
const fixture = join(directory, 'initialize.cjs')
@@ -76,7 +83,22 @@ function runWorker(entry, workerData, steps, timeoutMs) {
}
/** Exercise the shipped entries and copied state before publishing their content version. */
export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000 } = {}) {
export async function smokeProfileStateWorkers(outDir, { timeoutMs = 30_000, runtimePath } = {}) {
if (runtimePath) {
const nonce = randomUUID()
const result = runProcessSync({
program: runtimePath,
args: [join(outDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce],
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs,
maxOutputBytes: 64 * 1024
})
if (result.code !== 0 || result.timedOut || result.outputTruncated) {
throw new Error(`Packaged profile runtime preflight failed: ${result.stderr}`)
}
parseOrcadProfilePreflight(result.stdout, nonce, ORCAD_BUN_VERSION)
return
}
const directory = mkdtempSync(join(tmpdir(), 'orca-profile-worker-smoke-'))
const databasePath = join(directory, 'profile.db')
const targetPath = join(directory, 'backup.db')
+96
View File
@@ -0,0 +1,96 @@
import { join, resolve } from 'node:path'
import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import {
ORCAD_VERSION_FILENAME,
orcadBunRuntimeFilename
} from '../../src/shared/orcad-artifacts.ts'
import { ORCAD_BUN_VERSION } from '../../src/shared/orcad-bun-runtime.ts'
import {
ORCAD_PROFILE_PREFLIGHT_FLAG,
parseOrcadProfilePreflight
} from '../../src/shared/orcad-profile-preflight.ts'
import { currentTarget } from './build-orcad-bun.mjs'
import { runProcessSync } from './script-child-process.mjs'
const root = resolve(import.meta.dirname, '../..')
const target = currentTarget()
const artifact = process.argv.includes('--artifact')
const testArgs = process.argv.slice(2).filter((arg) => arg !== '--artifact')
const runtimeDir = artifact
? join(root, 'out', 'orcad')
: join(root, 'out', '.bun-profile-test-runtime', target)
const runtimePath = join(runtimeDir, orcadBunRuntimeFilename(target))
const env = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', BUN_EXECUTABLE: runtimePath }
function run(program, args) {
const result = runProcessSync({
program,
args,
cwd: root,
env,
stdio: 'inherit',
timeoutMs: null
})
if (result.code !== 0) {
process.exit(result.code ?? 1)
}
}
if (artifact) {
const nonce = randomUUID()
const result = runProcessSync({
program: runtimePath,
args: [join(runtimeDir, 'orcad.js'), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce],
cwd: root,
env,
timeoutMs: 90_000
})
if (result.code !== 0 || result.timedOut || result.outputTruncated) {
throw new Error(`Bundled runtime readiness failed: ${result.stderr}`)
}
const response = parseOrcadProfilePreflight(
result.stdout,
nonce,
ORCAD_BUN_VERSION,
readFileSync(join(runtimeDir, ORCAD_VERSION_FILENAME), 'utf8').trim()
)
process.stdout.write(`${JSON.stringify({ target, ...response })}\n`)
} else {
run(process.execPath, [
join(root, 'config/scripts/build-orcad-bun.mjs'),
'--runtime-only',
'--out-dir',
runtimeDir
])
}
run(runtimePath, [
join(root, 'node_modules/vitest/vitest.mjs'),
'run',
'--config',
'config/vitest.config.ts',
...(testArgs.length > 0
? testArgs
: [
'src/main/persistence/profile-state',
'src/main/persistence/loading-store/profile-state',
'src/main/sqlite',
'src/main/orcad/orcad-entry.test.ts',
'src/main/orcad/orcad-push-startup.test.ts',
...(artifact
? [
'src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts',
'src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts',
'src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts',
'src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts',
'src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts',
'tests/e2e/daemon-running-work-probe.unit.test.ts',
'src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts',
'src/main/providers/local-pty-bun-artifact.integration.test.ts',
'src/main/providers/agent-foreground-process-git-bash.win32.test.ts',
'src/main/orcad/orcad-bun-launcher.integration.test.ts',
'config/scripts/zip-extractor-command.test.mjs'
]
: [])
])
])
+33
View File
@@ -0,0 +1,33 @@
import { build } from 'esbuild'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const root = resolve(import.meta.dirname, '../..')
const temporary = mkdtempSync(join(tmpdir(), 'orca-script-child-process-'))
const output = join(temporary, 'child-process.mjs')
let implementation
try {
await build({
stdin: {
contents: [
`export { runProcessSync } from ${JSON.stringify(join(root, 'src/shared/child-process/run-process.ts'))}`
].join('\n'),
resolveDir: root,
sourcefile: 'script-child-process-entry.ts'
},
bundle: true,
platform: 'node',
target: 'node20',
format: 'esm',
outfile: output,
logLevel: 'silent'
})
implementation = await import(pathToFileURL(output).href)
} finally {
rmSync(temporary, { recursive: true, force: true })
}
export const runProcessSync = implementation.runProcessSync
@@ -1,8 +1,8 @@
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { dirname, join, relative } from 'node:path'
/**
* Copy a script and every co-located module it imports into a fixture's `config/scripts`.
* Copy a script and its relative modules, preserving their paths in the fixture.
*
* Walked rather than listed: a module the script needs but the fixture never copied fails every
* test in the suite with a module-resolution error that looks nothing like the defect it hides.
@@ -10,7 +10,9 @@ import { basename, dirname, join } from 'node:path'
export function copyScriptWithLocalModules(sourceScriptPath, destinationScriptsDir) {
mkdirSync(destinationScriptsDir, { recursive: true })
for (const modulePath of collectScriptModules(sourceScriptPath)) {
copyFileSync(modulePath, join(destinationScriptsDir, basename(modulePath)))
const destination = join(destinationScriptsDir, relative(dirname(sourceScriptPath), modulePath))
mkdirSync(dirname(destination), { recursive: true })
copyFileSync(modulePath, destination)
}
}
@@ -27,7 +29,7 @@ function collectScriptModules(scriptPath, seen = new Set()) {
// not against this file, so following them would stage the wrong path.
const source = readFileSync(scriptPath, 'utf8')
const specifiers = source.matchAll(
/(?:\bfrom|\brequire\s*\(|\bimport\s*\(|\bimport)\s*'(\.\/[^']+)'/g
/(?:\bfrom|\brequire\s*\(|\bimport\s*\(|\bimport)\s*'(\.\.?\/[^']+)'/g
)
for (const [, specifier] of specifiers) {
collectScriptModules(join(dirname(scriptPath), specifier), seen)
@@ -1,4 +1,11 @@
import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs'
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -23,6 +30,23 @@ function copiedNames(files, entryName) {
}
describe('copyScriptWithLocalModules', () => {
it('preserves parent paths for modules shared with the runtime', () => {
const sourceDir = sourceTree({ 'runtime.ts': 'export const value = 42\n' })
mkdirSync(join(sourceDir, 'scripts'))
writeFileSync(
join(sourceDir, 'scripts', 'entry.mjs'),
"export { value } from '../runtime.ts'\n"
)
const destinationRoot = mkdtempSync(join(fixtureDir, 'dest-'))
copyScriptWithLocalModules(
join(sourceDir, 'scripts', 'entry.mjs'),
join(destinationRoot, 'scripts')
)
expect(readFileSync(join(destinationRoot, 'runtime.ts'), 'utf8')).toBe(
'export const value = 42\n'
)
})
it('takes the entry script itself', () => {
expect(copiedNames({ 'entry.mjs': 'export const a = 1\n' }, 'entry.mjs')).toEqual(['entry.mjs'])
})
+39 -19
View File
@@ -366,23 +366,38 @@ function parseImportedSymbols(objdumpOutput) {
/** Version needs + DT_NEEDED from a single `objdump -p` (fail-closed). */
function readDynamicInfo(filePath, objdumpPath) {
const output = runObjdump(objdumpPath, '-p', filePath)
const versionNeeds = parseVersionNeeds(output)
const neededLibraries = parseNeededLibraries(output)
return {
versionNeeds: parseVersionNeeds(output),
neededLibraries: parseNeededLibraries(output)
versionNeeds,
neededLibraries,
// LLVM prints an empty Dynamic Section even for static executables.
isStatic:
/^Program Header:/m.test(output) &&
/^\s+LOAD\s+off\s+0x[0-9a-f]+/m.test(output) &&
!/^\s+(?:DYNAMIC|INTERP)\s+off\s+/m.test(output) &&
versionNeeds.length === 0 &&
neededLibraries.size === 0
}
}
function isMuslTemplatePayload(filePath, neededLibraries, versionNeeds) {
return (
/(?:^|[/\\])orcad-template[/\\]targets[/\\]linux-(?:x64|arm64)-musl[/\\]/.test(filePath) &&
[...neededLibraries].some(
(name) => name === 'libc.so' || /^libc\.musl-[\w-]+\.so\.1$/.test(name)
) &&
![...neededLibraries, ...versionNeeds.map((need) => need.library)].some((name) =>
/^(?:libc\.so\.6|libm\.so\.6|libpthread\.so\.0|libdl\.so\.2|librt\.so\.1|ld-linux.*)$/.test(
name
)
)
)
}
/** Imported (undefined) dynamic symbols from `objdump -T` (fail-closed). */
function readImportedSymbols(filePath, objdumpPath) {
try {
return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath))
} catch (error) {
// Why: a statically linked binary (bundled ripgrep) has no dynamic symbol table to import from.
if (error instanceof Error && error.message.includes('not a dynamic object')) {
return new Set()
}
throw error
}
return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath))
}
/**
@@ -433,15 +448,20 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) {
const offenders = []
for (const filePath of binaries) {
const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath)
const floorViolations = findFloorViolations(versionNeeds, filePath)
const { versionNeeds, neededLibraries, isStatic } = readDynamicInfo(filePath, objdumpPath)
const isMuslTarget = isMuslTemplatePayload(filePath, neededLibraries, versionNeeds)
// Remote musl payloads use their host's C++ runtime, not Ubuntu's libstdc++ or libutil.
const floorViolations = findFloorViolations(versionNeeds, filePath).filter(
(need) => !isMuslTarget || !isLibstdcxxNode(need.name)
)
// Only pay for `objdump -T` when a relocated-symbol provider is not already
// in DT_NEEDED (the common, healthy case short-circuits without it).
const providerViolations = Object.values(RELOCATED_SYMBOL_PROVIDERS).some(
(library) => !neededLibraries.has(library)
)
? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries)
: []
const providerViolations =
!isStatic &&
!isMuslTarget &&
Object.values(RELOCATED_SYMBOL_PROVIDERS).some((library) => !neededLibraries.has(library))
? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries)
: []
if (floorViolations.length > 0 || providerViolations.length > 0) {
offenders.push({ filePath, floorViolations, providerViolations })
}
@@ -473,7 +493,7 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) {
}
console.log(
`[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries all load on ${FLOOR_LABEL}`
`[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries meet applicable ${FLOOR_LABEL} requirements`
)
}
@@ -0,0 +1,124 @@
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs')
const roots = []
const STATIC_HEADERS = 'Program Header:\n LOAD off 0x0000000000000000\n'
const MUSL_TARGET = 'orcad-template/targets/linux-arm64-musl/watcher.node'
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function writeObjdumpFixture(
headers,
{ filename = 'browser', symbolTableError = false } = {}
) {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-payload-'))
roots.push(root)
const app = join(root, 'app')
const binary = join(app, ...filename.split('/'))
await mkdir(dirname(binary), { recursive: true })
const elf = Buffer.alloc(64)
elf.write('\x7fELF', 0, 'latin1')
elf[4] = 2
elf[5] = 1
elf[6] = 1
elf.writeUInt16LE(0xb7, 18)
await writeFile(binary, elf)
await writeFile(join(root, 'private-headers.txt'), headers)
const objdumpPath = join(root, 'objdump-stub.sh')
await writeFile(
objdumpPath,
[
'#!/bin/sh',
'case "$1" in',
' --version) echo "GNU objdump (fixture)" ;;',
' -p) cat "$(dirname "$0")/private-headers.txt" ;;',
symbolTableError
? ' -T) echo "not a dynamic object" >&2; exit 1 ;;'
: ' -T) echo "0000 DF *UND* 0000 openpty" ;;',
'esac'
].join('\n'),
{ mode: 0o755 }
)
return () => verifyLinuxGlibcFloor(app, { objdumpPath, targetArch: 'arm64' })
}
describe.skipIf(process.platform === 'win32')('static ELF and remote musl payloads', () => {
it.each(['', '\nDynamic Section:\n'])(
'accepts static LOAD segments with no dynamic imports (section suffix %j)',
async (suffix) => {
const verify = await writeObjdumpFixture(STATIC_HEADERS + suffix, { symbolTableError: true })
expect(verify).not.toThrow()
}
)
it.each([
['missing program headers', 'Dynamic Section:\n'],
['dynamic segment', `${STATIC_HEADERS} DYNAMIC off 0x0000000000001000\n`],
['interpreter segment', `${STATIC_HEADERS} INTERP off 0x0000000000001000\n`],
['dependency without a dynamic segment', `${STATIC_HEADERS} NEEDED libc.so.6\n`]
])('preserves symbol-table failures for %s', async (_label, headers) => {
const verify = await writeObjdumpFixture(headers, { symbolTableError: true })
expect(verify).toThrow(/objdump -T failed/)
})
it.each(['libc.so', 'libc.musl-aarch64.so.1'])(
'does not apply Ubuntu C++ or libutil requirements to a remote payload linked to %s',
async (libc) => {
const verify = await writeObjdumpFixture(
`Dynamic Section:\n NEEDED ${libc}\n NEEDED libstdc++.so.6\n` +
'Version References:\n required from libstdc++.so.6:\n 0x0 0x00 02 GLIBCXX_3.4.29\n',
{ filename: MUSL_TARGET }
)
expect(verify).not.toThrow()
}
)
it.each([
['desktop addon', 'watcher.node', 'libc.so'],
['glibc target', 'orcad-template/targets/linux-arm64-glibc/watcher.node', 'libc.so'],
['mislabeled glibc target', MUSL_TARGET, 'libc.so.6'],
['mixed libc dependencies', MUSL_TARGET, 'libc.so\n NEEDED libc.so.6']
])('retains Ubuntu floor and provider checks for %s', async (_label, filename, libc) => {
const verify = await writeObjdumpFixture(
`Dynamic Section:\n NEEDED ${libc}\n NEEDED libstdc++.so.6\n` +
'Version References:\n required from libstdc++.so.6:\n 0x0 0x00 02 GLIBCXX_3.4.29\n',
{ filename }
)
expect(verify).toThrow(
/needs GLIBCXX_3.4.29.*imports openpty but libutil.so.1 is not in DT_NEEDED/
)
})
it('still rejects too-new glibc version needs in a musl payload', async () => {
const verify = await writeObjdumpFixture(
'Dynamic Section:\n NEEDED libc.so\nVersion References:\n' +
' required from libc.so:\n 0x0 0x00 02 GLIBC_2.34\n',
{ filename: MUSL_TARGET }
)
expect(verify).toThrow(/needs GLIBC_2.34/)
})
it('retains the provider check when version references disclose a glibc dependency', async () => {
const verify = await writeObjdumpFixture(
'Dynamic Section:\n NEEDED libc.so\nVersion References:\n' +
' required from libc.so.6:\n 0x0 0x00 02 GLIBC_2.17\n',
{ filename: MUSL_TARGET }
)
expect(verify).toThrow(/imports openpty but libutil.so.1 is not in DT_NEEDED/)
})
it('preserves objdump failures for a musl-labeled file without proven musl dependencies', async () => {
const verify = await writeObjdumpFixture('Dynamic Section:\n', {
filename: MUSL_TARGET,
symbolTableError: true
})
expect(verify).toThrow(/objdump -T failed/)
})
})
@@ -0,0 +1,150 @@
const { createHash } = require('node:crypto')
const { lstatSync, readFileSync, readdirSync } = require('node:fs')
const { basename, join } = require('node:path')
const {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_TEMPLATE_MANIFEST_FILENAME,
ORCAD_TEMPLATE_TARGETS_DIR,
orcadTemplateCommonFilenames
} = require('../../src/shared/orcad-artifacts.ts')
const { ORCAD_TEMPLATE_TARGETS } = require('../../src/shared/orcad-bun-runtime.ts')
const SHA256_PATTERN = /^[a-f0-9]{64}$/
const BROWSER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
function sha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
function readManifest(templateDir) {
const path = join(templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME)
try {
return JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
throw new Error(
`[verify-packaged-orcad-template] invalid manifest at ${path}: ${error instanceof Error ? error.message : String(error)}`
)
}
}
function requireRecord(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`[verify-packaged-orcad-template] ${label} must be an object`)
}
return value
}
function requireSha256(value, label) {
if (typeof value !== 'string' || !SHA256_PATTERN.test(value)) {
throw new Error(`[verify-packaged-orcad-template] ${label} must be a SHA-256 digest`)
}
return value
}
function requireRegularFile(path, label) {
let metadata
try {
metadata = lstatSync(path)
} catch {
throw new Error(`[verify-packaged-orcad-template] missing ${label} at ${path}`)
}
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error(`[verify-packaged-orcad-template] ${label} is not a regular file at ${path}`)
}
}
function verifyFile(path, expected, label) {
requireRegularFile(path, label)
const actual = sha256(path)
if (actual !== expected) {
throw new Error(
`[verify-packaged-orcad-template] ${label} checksum mismatch: expected ${expected}, got ${actual}`
)
}
}
function requireExactNames(actual, expected, label) {
const actualNames = [...actual].sort()
const expectedNames = [...expected].sort()
if (
actualNames.length !== expectedNames.length ||
actualNames.some((name, index) => name !== expectedNames[index])
) {
throw new Error(
`[verify-packaged-orcad-template] ${label} mismatch: expected=${expectedNames.join(',')} actual=${actualNames.join(',')}`
)
}
}
function verifyTarget(templateDir, target, value) {
const targetManifest = requireRecord(value, `${target} manifest`)
const targetSha256 = requireSha256(targetManifest.targetSha256, `${target} targetSha256`)
const watcherSha256 = requireSha256(targetManifest.watcherSha256, `${target} watcherSha256`)
const hasBrowserName = Object.hasOwn(targetManifest, 'browserName')
const hasBrowserSha256 = Object.hasOwn(targetManifest, 'browserSha256')
if (hasBrowserName !== hasBrowserSha256) {
throw new Error(
`[verify-packaged-orcad-template] ${target} browserName and browserSha256 must both be present`
)
}
const targetDir = join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR, target)
const targetIdentity = join(targetDir, ORCAD_BUILD_TARGET_FILENAME)
verifyFile(targetIdentity, targetSha256, `${target} build target`)
if (readFileSync(targetIdentity, 'utf8').trim() !== target) {
throw new Error(`[verify-packaged-orcad-template] ${target} build target identity disagrees`)
}
verifyFile(join(targetDir, 'watcher.node'), watcherSha256, `${target} watcher`)
const expectedFiles = [ORCAD_BUILD_TARGET_FILENAME, 'watcher.node']
if (hasBrowserName) {
const browserName = targetManifest.browserName
if (
typeof browserName !== 'string' ||
!BROWSER_NAME_PATTERN.test(browserName) ||
basename(browserName) !== browserName
) {
throw new Error(`[verify-packaged-orcad-template] ${target} browserName is invalid`)
}
verifyFile(
join(targetDir, browserName),
requireSha256(targetManifest.browserSha256, `${target} browserSha256`),
`${target} browser`
)
expectedFiles.push(browserName)
}
requireExactNames(readdirSync(targetDir), expectedFiles, `${target} file inventory`)
}
function verifyPackagedOrcadTemplate(resourcesDir) {
const templateDir = join(resourcesDir, 'orcad-template')
const manifest = requireRecord(readManifest(templateDir), 'manifest')
if (manifest.schemaVersion !== 2) {
throw new Error('[verify-packaged-orcad-template] manifest schemaVersion must be 2')
}
const commonSha256 = requireRecord(manifest.commonSha256, 'commonSha256')
const commonFilenames = orcadTemplateCommonFilenames()
requireExactNames(Object.keys(commonSha256), commonFilenames, 'common manifest inventory')
for (const filename of commonFilenames) {
verifyFile(
join(templateDir, ...filename.split('/')),
requireSha256(commonSha256[filename], `${filename} checksum`),
filename
)
}
const targets = requireRecord(manifest.targets, 'targets')
requireExactNames(Object.keys(targets), ORCAD_TEMPLATE_TARGETS, 'target manifest inventory')
requireExactNames(
readdirSync(join(templateDir, ORCAD_TEMPLATE_TARGETS_DIR)),
ORCAD_TEMPLATE_TARGETS,
'target directory inventory'
)
for (const target of ORCAD_TEMPLATE_TARGETS) {
verifyTarget(templateDir, target, targets[target])
}
console.log(
`[verify-packaged-orcad-template] OK — verified ${ORCAD_TEMPLATE_TARGETS.length} Bun targets`
)
}
module.exports = { verifyPackagedOrcadTemplate }
@@ -0,0 +1,93 @@
import { createRequire } from 'node:module'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
ORCAD_TEMPLATE_MANIFEST_FILENAME,
ORCAD_TEMPLATE_TARGETS_DIR
} from '../../src/shared/orcad-artifacts.ts'
import { writeOrcadTemplateTestFixture } from './orcad-template-test-fixture.mjs'
const require = createRequire(import.meta.url)
const { verifyPackagedOrcadTemplate } = require('./verify-packaged-orcad-template.cjs')
const builderConfig = require('../electron-builder.config.cjs')
const roots = []
async function createFixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-packaged-orcad-template-'))
roots.push(root)
const templateDir = await writeOrcadTemplateTestFixture(root)
return { root, templateDir }
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('verifyPackagedOrcadTemplate', () => {
it('accepts the exact six-target packaged template', async () => {
const fixture = await createFixture()
expect(() => verifyPackagedOrcadTemplate(fixture.root)).not.toThrow()
})
it('rejects target-native bytes changed after manifest generation', async () => {
const fixture = await createFixture()
await writeFile(
join(fixture.templateDir, ORCAD_TEMPLATE_TARGETS_DIR, 'linux-x64-glibc', 'watcher.node'),
'mutated'
)
expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow(
'linux-x64-glibc watcher checksum mismatch'
)
})
it('rejects a missing Windows PTY gate worker', async () => {
const fixture = await createFixture()
await rm(join(fixture.templateDir, 'windows-bun-pty-gate-entry.js'))
expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow('windows-bun-pty-gate-entry.js')
})
it.each(['writer', 'backup'])(
'requires the profile %s worker and its exact bytes',
async (role) => {
const fixture = await createFixture()
const filename = `profile-state-${role}-worker-entry.js`
await writeFile(join(fixture.templateDir, filename), 'stale-worker')
expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow(
`${filename} checksum mismatch`
)
await rm(join(fixture.templateDir, filename))
expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow(`missing ${filename}`)
}
)
it('rejects a missing target before the package reaches deployment', async () => {
const fixture = await createFixture()
const manifestPath = join(fixture.templateDir, ORCAD_TEMPLATE_MANIFEST_FILENAME)
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
delete manifest.targets['linux-arm64-musl']
await writeFile(manifestPath, JSON.stringify(manifest))
expect(() => verifyPackagedOrcadTemplate(fixture.root)).toThrow(
'target manifest inventory mismatch'
)
})
it('does not ship the unused deployment template in desktop packages', async () => {
for (const platform of ['win', 'mac', 'linux']) {
expect(
builderConfig[platform].extraResources.some(
(resource) => typeof resource === 'object' && resource.to.startsWith('orcad-template')
)
).toBe(false)
}
const { scripts } = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8'))
for (const name of ['build:desktop', 'build:release', 'build:release:parallel']) {
expect(scripts[name]).not.toContain('build:orcad-template')
}
})
})
+1
View File
@@ -0,0 +1 @@
export { getZipExtractorCommand } from '../../src/shared/zip-extractor-command.ts'
@@ -0,0 +1,62 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { runProcessSync } from './script-child-process.mjs'
import { getZipExtractorCommand } from './zip-extractor-command.mjs'
const directories = []
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
function extract(bytes) {
const directory = mkdtempSync(join(tmpdir(), "orca archive '$ "))
directories.push(directory)
const archive = join(directory, "source '$.zip")
const destination = join(directory, "output '$")
writeFileSync(archive, bytes)
mkdirSync(destination)
const command = getZipExtractorCommand(archive, destination)
const result = runProcessSync({ program: command.file, args: command.args, timeoutMs: 120_000 })
return { result, destination }
}
describe('native archive extraction', () => {
it('uses the system archive reader on Windows unless an override is configured', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.stubEnv('SystemRoot', 'C:\\Windows')
vi.stubEnv('ORCA_UNZIP_BIN', '')
expect(getZipExtractorCommand('source.zip', 'output')).toEqual({
file: join('C:\\Windows', 'System32', 'tar.exe'),
args: ['-xf', 'source.zip', '-C', 'output'],
label: 'tar'
})
vi.stubEnv('ORCA_UNZIP_BIN', 'C:\\tools\\unzip.exe')
expect(getZipExtractorCommand("source '$.zip", "output '$")).toEqual({
file: 'C:\\tools\\unzip.exe',
args: ['-q', "source '$.zip", '-d', "output '$"],
label: 'unzip'
})
})
it('extracts through paths containing spaces, apostrophes and shell characters', () => {
const { result, destination } = extract(
Buffer.from(
'UEsDBBQAAAAAAI1iOF16rk6zGAAAABgAAAALAAAAcGF5bG9hZC50eHR2ZXJpZmllZCBhcmNoaXZlIHBheWxvYWRQSwECFAMUAAAAAACNYjhdeq5OsxgAAAAYAAAACwAAAAAAAAAAAAAAgAEAAAAAcGF5bG9hZC50eHRQSwUGAAAAAAEAAQA5AAAAQQAAAAAA',
'base64'
)
)
expect(result.code, result.stderr).toBe(0)
expect(readFileSync(join(destination, 'payload.txt'), 'utf8')).toBe('verified archive payload')
})
it('fails on a malformed archive', () => {
const { result } = extract('invalid archive')
expect(result.code).not.toBe(0)
})
})
+6
View File
@@ -213,6 +213,12 @@
"../src/main/startup/cli-command-names.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/sqlite/sync-database.ts",
"../src/main/sqlite/bun-readonly-wal.ts",
"../src/main/sqlite/sqlite-statement.ts",
"../src/main/sqlite/sqlite-integer-reader.ts",
"../src/main/sqlite/node-sqlite-statement.ts",
"../src/main/sqlite/bun-sqlite-statement.ts",
"../src/main/sqlite/bun-sqlite-database.ts",
"../src/main/win32-utils.ts"
],
"compilerOptions": {
+2
View File
@@ -16,6 +16,8 @@ export default defineConfig({
},
test: {
environment: 'node',
// Bun's external-module cache otherwise loses Zod named exports across mocked graphs.
...(process.versions.bun ? { server: { deps: { inline: ['zod'] } } } : {}),
...(process.env.ORCA_BALANCE_UNIT_SHARDS === '1'
? {
sequence: { sequencer: TimingSequencer },
+5 -5
View File
@@ -145,11 +145,11 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
after the listener is bound and the daemon verdict is in. There is no separate readiness
socket; the line is the signal. Set the supervisor's start timeout generously — the daemon
launch has its own retries and can take tens of seconds on a cold host.
- **Shutdown.** `SIGTERM` or `SIGINT` starts a graceful stop. A **second** signal exits
immediately with code 1 rather than being swallowed — a supervisor's second signal means
its first deadline elapsed, and waiting silently is what turns a stop into a `SIGKILL`,
the one teardown that skips the daemon handoff. orcad also imposes its own 15s deadline
and exits 1, so the failure stays attributable instead of arriving as an unlogged kill.
- **Shutdown.** `SIGTERM` or `SIGINT` starts one graceful stop. Repeated signals share
that stop because a supervisor may signal both the launcher and its child. A 15s deadline
exits with code 1 if teardown stalls. The bundled runtime also stops gracefully if its
launcher's IPC channel closes. On POSIX, both the launcher and runtime ignore `SIGHUP`,
so terminal hangups do not stop a headless host. Use `SIGTERM` or `SIGINT` to stop it.
- **Exit codes.**
| Code | Meaning | Supervisor should |
+4 -1
View File
@@ -30,6 +30,7 @@
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"prepare": "husky",
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"test:bun:profile": "node config/scripts/run-bun-profile-tests.mjs",
"test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts",
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
"capture:agent-transcript": "node config/scripts/ensure-native-runtime.mjs --runtime=node && node config/scripts/capture-agent-pty-transcript.mjs",
@@ -38,7 +39,8 @@
"check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
"check:readme-local-links": "node config/scripts/check-readme-local-links.mjs",
"build:orcad": "node config/scripts/build-orcad.mjs",
"build:orcad": "node config/scripts/build-orcad-bun.mjs",
"build:orcad-template": "node config/scripts/build-orcad-template.mjs",
"build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs",
"smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad",
"smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs",
@@ -299,6 +301,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tar": "7.5.22",
"tw-animate-css": "^1.4.0",
"typescript": "^7.0.2",
"typescript-api": "npm:typescript@6.0.3",
+3
View File
@@ -505,6 +505,9 @@ importers:
tailwindcss:
specifier: ^4.2.4
version: 4.2.4
tar:
specifier: 7.5.22
version: 7.5.22
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@@ -0,0 +1,280 @@
import type { IPty } from 'node-pty'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
import type * as SnapshotReader from '../../shared/process-table-snapshot-reader'
import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle'
import { resolveSpawnFileForegroundFromRows } from './pty-subprocess/spawn-file-foreground-process'
import { inspectTerminalHostProcess } from './terminal-host-process-inspection'
import { Session } from './session'
const { readSnapshot, readFresh, readStrict, members, readWindows, resolveWindows } = vi.hoisted(
() => ({
readSnapshot: vi.fn(),
readFresh: vi.fn(),
readStrict: vi.fn(),
members: vi.fn(),
readWindows: vi.fn(),
resolveWindows: vi.fn()
})
)
vi.mock('../../shared/process-table-snapshot-reader', async (importOriginal) => ({
...(await importOriginal<typeof SnapshotReader>()),
getProcessTableSnapshot: readSnapshot,
getFreshProcessTableSnapshot: readFresh,
getStrictProcessTableSnapshotWithAge: readStrict
}))
vi.mock('../providers/windows-pty-job-membership', () => ({
readWindowsPtyJobProcessIds: members,
isWindowsPtyJobReadable: () => true
}))
vi.mock('../windows/windows-process-table', () => ({
readWindowsProcessIdentityTable: readWindows,
readWindowsProcessIdentityTableFresh: readWindows
}))
vi.mock('../providers/windows-agent-foreground-process', () => ({
shouldInspectWindowsAgentForeground: () => true,
resolveWindowsAgentForegroundProcessWithAvailability: resolveWindows
}))
function table(command: string | null, loginWrapper = false): ProcessTableRow[] {
const tpgid = command === null ? (loginWrapper ? 101 : 100) : 102
const root: ProcessTableRow = {
pid: 100,
ppid: 1,
pgid: 100,
tpgid,
tty: 'ttys004',
startTime: 'Thu Sep 3 16:02:01 2026',
stat: tpgid === 100 ? 'Ss+' : 'Ss',
command: loginWrapper ? '"/Applications/Orca shell login" -fp user' : '/bin/zsh'
}
return [
root,
...(loginWrapper
? [
{
...root,
pid: 101,
ppid: 100,
pgid: 101,
stat: command === null ? 'S+' : 'S',
command: '-zsh'
}
]
: []),
...(command === null
? []
: [{ ...root, pid: 102, ppid: loginWrapper ? 101 : 100, pgid: 102, stat: 'S+', command }])
]
}
function createHandle(loginWrapper = false) {
const proc: IPty & { processNameIsSpawnFile: true } = {
pid: 100,
cols: 80,
rows: 24,
handleFlowControl: false,
process: loginWrapper ? '/Applications/Orca shell login' : '/bin/zsh',
processNameIsSpawnFile: true,
onData: () => ({ dispose() {} }),
onExit: () => ({ dispose() {} }),
write() {},
resize() {},
clear() {},
kill() {},
pause() {},
resume() {}
}
return createDaemonPtySubprocessHandle({
process: proc,
shellPath: '/bin/zsh',
spawnCwd: '/tmp',
env: {},
startupCommandDeliveredInShellArgs: false,
reportsChildExitStatus: true,
sessionId: 'static-name',
startupAgentRecognition: null
})
}
async function inspect(handle: ReturnType<typeof createHandle>) {
const session = new Session({
sessionId: 'static-name',
subprocess: handle,
shellReadySupported: false,
cols: 80,
rows: 24,
scrollback: 10
})
try {
return await inspectTerminalHostProcess({
sessionId: session.sessionId,
session,
authorityGeneration: 'generation',
nextObservationEpoch: () => 1
})
} finally {
session.dispose()
}
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.resetAllMocks()
})
describe.each(['linux', 'darwin'] as const)('static spawn-file foreground on %s', (platform) => {
it.each(['vim', 'sleep', 'node', 'npm', 'node /usr/bin/claude'])(
'resolves %s in both the synchronous tracker and host inspection',
async (command) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
const rows = table(command, platform === 'darwin')
readSnapshot.mockResolvedValue(rows)
readFresh.mockResolvedValue(rows)
readStrict.mockResolvedValue({ rows, capturedAgeMs: 0 })
const handle = createHandle(platform === 'darwin')
const expected = command.includes('claude') ? 'claude' : command
expect(handle.processNameIsSpawnFile).toBe(true)
expect(await handle.confirmForegroundProcess?.()).toBe(expected)
expect(handle.getForegroundProcess()).toBe(expected)
expect(await inspect(createHandle(platform === 'darwin'))).toMatchObject({
foregroundProcess: expected,
hasChildProcesses: true
})
expect(readStrict).toHaveBeenCalledTimes(1)
}
)
it('observes a command ending and returns to an idle login shell', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
const handle = createHandle(true)
readFresh.mockResolvedValue(table('vim', true))
expect(await handle.confirmForegroundProcess?.()).toBe('vim')
const rows = table(null, true)
readFresh.mockResolvedValue(rows)
readSnapshot.mockResolvedValue(rows)
readStrict.mockResolvedValue({ rows, capturedAgeMs: 0 })
expect(await handle.confirmForegroundProcess?.()).toBe('zsh')
const inspection = await inspect(handle)
expect(inspection).toMatchObject({
foregroundProcess: null,
hasChildProcesses: false,
childProcessEvidence: 'no-children'
})
})
it('does not interpret a failed process read as a childless shell', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
readFresh.mockRejectedValue(new Error('unreadable'))
readSnapshot.mockRejectedValue(new Error('unreadable'))
readStrict.mockRejectedValue(new Error('unreadable'))
const handle = createHandle()
expect(await handle.confirmForegroundProcess?.()).toBeNull()
const inspection = await inspect(handle)
expect(inspection).toMatchObject({
hasChildProcesses: true,
childProcessEvidence: 'unverifiable',
foregroundProcessEvidence: { verdict: 'unverifiable' }
})
})
it('retains ordinary foreground names across a failed background refresh', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(100_000)
readSnapshot.mockResolvedValue(table('vim'))
const handle = createHandle()
expect(handle.getForegroundProcess()).toBe('zsh')
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('vim'))
readSnapshot.mockRejectedValue(new Error('unreadable'))
vi.setSystemTime(102_000)
expect(handle.getForegroundProcess()).toBe('vim')
await vi.waitFor(() => expect(readSnapshot).toHaveBeenCalledTimes(2))
expect(handle.getForegroundProcess()).toBe('vim')
handle.dispose()
})
it.each(['T', 'S'])(
'keeps the close guard live when the shell is foreground and a child has state %s',
async (stat) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
const rows = table(null, platform === 'darwin')
rows.push({ ...rows[0], pid: 102, ppid: rows.at(-1)!.pid, pgid: 102, stat, command: 'vim' })
readSnapshot.mockResolvedValue(rows)
readStrict.mockResolvedValue({ rows, capturedAgeMs: 0 })
const inspection = await inspect(createHandle(platform === 'darwin'))
expect(inspection).toMatchObject({
foregroundProcess: null,
hasChildProcesses: true,
childProcessEvidence: 'children'
})
}
)
})
it('ignores stopped/background children and another terminal beneath the same root', () => {
const rows = table(null)
rows.push({ ...rows[0], pid: 102, ppid: 100, pgid: 102, stat: 'T', command: 'vim' })
rows.push({ ...rows[0], pid: 103, ppid: 100, tty: 'ttys009', command: 'claude' })
expect(resolveSpawnFileForegroundFromRows(rows, 100)).toEqual({
available: true,
processName: 'zsh'
})
expect(resolveSpawnFileForegroundFromRows(rows, 999)).toEqual({
available: false,
processName: null
})
})
it('uses Windows job membership and the native process table for ordinary children', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
resolveWindows.mockResolvedValue({ available: true, processName: null })
members.mockReturnValue(new Set([100, 102]))
readWindows.mockResolvedValue([
{ pid: 100, ppid: 1, name: 'pwsh.exe' },
{ pid: 102, ppid: 100, name: 'vim.exe' }
])
readStrict.mockRejectedValue(new Error('POSIX evidence unavailable'))
const handle = createHandle()
expect(await handle.confirmForegroundProcess?.()).toBe('vim.exe')
expect(await inspect(handle)).toMatchObject({
foregroundProcess: 'vim.exe',
hasChildProcesses: true
})
})
it('keeps missing Windows job membership unverifiable', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
resolveWindows.mockResolvedValue({ available: true, processName: null })
members.mockReturnValue(null)
readStrict.mockRejectedValue(new Error('POSIX evidence unavailable'))
expect(await inspect(createHandle())).toMatchObject({
foregroundProcess: null,
hasChildProcesses: true,
childProcessEvidence: 'unverifiable'
})
})
it('reports an idle Windows shell only when the owned job contains the shell alone', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
resolveWindows.mockResolvedValue({ available: true, processName: null })
members.mockReturnValue(new Set([100]))
readStrict.mockRejectedValue(new Error('POSIX evidence unavailable'))
expect(await inspect(createHandle())).toMatchObject({ hasChildProcesses: false })
expect(readWindows).not.toHaveBeenCalled()
})
it('keeps the Windows close guard live when a shell descendant is selected above another job', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
resolveWindows.mockResolvedValue({ available: true, processName: null })
members.mockReturnValue(new Set([100, 102, 103, 104]))
readWindows.mockResolvedValue([
{ pid: 100, ppid: 1, name: 'pwsh.exe' },
{ pid: 102, ppid: 100, name: 'vim.exe' },
{ pid: 103, ppid: 100, name: 'cmd.exe' },
{ pid: 104, ppid: 103, name: 'pwsh.exe' }
])
readStrict.mockRejectedValue(new Error('POSIX evidence unavailable'))
const inspection = await inspect(createHandle())
expect(inspection).toMatchObject({ hasChildProcesses: true, childProcessEvidence: 'children' })
})
+1 -1
View File
@@ -84,7 +84,7 @@ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise<S
let spawned: SpawnedDaemonPty
try {
spawned = spawnNativeDaemonPty({
spawned = await spawnNativeDaemonPty({
shellPath: launch.shellPath,
shellArgs: launch.shellArgs,
spawnCwd: launch.spawnCwd,
@@ -0,0 +1,202 @@
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { runProcess } from '../../../shared/child-process/run-process'
import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts'
import { removeTreeSync } from '../../../shared/windows-transient-lock-removal'
const runtimePath =
process.env.BUN_EXECUTABLE ??
resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform))
describe.skipIf(
process.platform === 'win32' || !existsSync(runtimePath) || !existsSync('/bin/bash')
)('Bun terminal user job control', () => {
// Bash re-raises SIGHUP; Zsh exits with the signal number.
for (const [shell, expectedExitCode] of [
['/bin/bash', 129],
['/bin/zsh', 1]
] as const) {
it.skipIf(!existsSync(shell))(
`gracefully closes an interactive ${shell} before the daemon force-kill deadline`,
async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-bun-shell-hangup-'))
try {
const entry = join(directory, 'shell-hangup.cjs')
writeFileSync(
entry,
`
const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))})
const {createDaemonPtySubprocessHandle} = require(${JSON.stringify(join(__dirname, 'subprocess-handle.ts'))})
const {SessionTerminationController} = require(${JSON.stringify(join(__dirname, '../session-termination-controller.ts'))})
const {existsSync} = require('node:fs')
const {join} = require('node:path')
const cwd = ${JSON.stringify(directory)}
const ready = join(cwd, 'ready'), cleanup = join(cwd, 'hangup-cleanup')
const shell = ${JSON.stringify(shell)}
const env = {...process.env,PS1:'',ORCA_TEST_READY:ready,ORCA_TEST_CLEANUP:cleanup}
const proc = spawnBunPty({file:shell,args:shell.endsWith('/bash')?['--noprofile','--norc','-i']:['-f','-i'],cwd,env,cols:80,rows:24})
const subprocess = createDaemonPtySubprocessHandle({process:proc,shellPath:shell,spawnCwd:cwd,env,startupCommandDeliveredInShellArgs:false,reportsChildExitStatus:true,sessionId:'shell-hangup',startupAgentRecognition:null})
let exited = false, forced = false, exitCode, elapsedMs, startedAt
const forceKill = subprocess.forceKill
subprocess.forceKill = () => {forced = true;forceKill()}
const controller = new SessionTerminationController({sessionId:'shell-hangup',subprocess,launchAgent:null,isExited:()=>exited,releaseProducerPause:()=>proc.resume()})
subprocess.onExit(code => {
exited = true
exitCode = code
elapsedMs = Date.now() - startedAt
controller.markPhysicalExit()
controller.cancelForceKillFallback()
})
const waitFor = async predicate => {
const deadline = Date.now() + 8000
while (!predicate()) {
if (Date.now() >= deadline) throw new Error('Timed out waiting for shell hangup')
await Bun.sleep(10)
}
}
;(async()=>{
try {
// Observe normal hangup cleanup without replacing the shell's SIGHUP handler.
proc.write(${JSON.stringify('trap \'printf cleaned > "$ORCA_TEST_CLEANUP"\' EXIT; printf ready > "$ORCA_TEST_READY"\r')})
await waitFor(() => existsSync(ready))
startedAt = Date.now()
controller.kill()
await waitFor(() => exited)
let reaped = false
try {process.kill(proc.pid, 0)} catch (error) {if(error.code==='ESRCH')reaped=true;else throw error}
console.log(JSON.stringify({cleaned:existsSync(cleanup),forced,exitCode,elapsedMs,reaped}))
} finally {
controller.cancelForceKillFallback()
if (!exited) {
subprocess.forceKill()
await waitFor(() => exited)
}
controller.disposeSubprocessHandle()
}
})().catch(error => {console.error(error);process.exitCode=1})
`
)
const result = await runProcess({
program: runtimePath,
args: [entry],
timeoutMs: 25_000
})
expect(result.timedOut).toBe(false)
expect(result.code, result.stderr).toBe(0)
const evidence = JSON.parse(result.stdout)
expect(evidence).toEqual({
cleaned: true,
forced: false,
exitCode: expectedExitCode,
elapsedMs: expect.any(Number),
reaped: true
})
expect(evidence.elapsedMs).toBeLessThan(5_000)
} finally {
removeTreeSync(directory)
}
}
)
}
it('keeps a real Ctrl-Z job suspended while pausing and resuming a background producer', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-bun-job-control-'))
try {
writeFileSync(join(directory, 'producer.cjs'), 'setInterval(()=>console.log("flow-tick"),10)')
const entry = join(directory, 'job-control.cjs')
writeFileSync(
entry,
`
const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))})
const {readPosixPtyProcessTable,forceKillPosixPtyProcessGroups} = require(${JSON.stringify(join(__dirname, '../../pty/posix-pty-process-groups.ts'))})
const signals = []
const proc = spawnBunPty({
file:'/bin/bash', args:['--noprofile','--norc','-i'], cwd:${JSON.stringify(directory)},
env:{...process.env,PS1:'',ORCA_TEST_RUNTIME:process.execPath},cols:80,rows:24
}, {signalProcessGroup:(pgid,signal)=>{process.kill(-pgid,signal);signals.push([pgid,signal])}})
let output = '', exited = false
proc.onData(data => output += data)
proc.onExit(() => {exited = true})
const isAlive = pid => {
try {process.kill(pid, 0);return true}
catch (error) {if(error.code==='ESRCH')return false;throw error}
}
const rows = async () => {
const table = (await readPosixPtyProcessTable(proc.pid)).trim().split(/\\r?\\n/).map(row => {
const [pid,pgid,tty,state] = row.trim().split(/\\s+/)
return {pid:Number(pid),pgid:Number(pgid),tty,state}
}).filter(row => row.pid > 0 && row.state)
const root = table.find(row => row.pid === proc.pid)
// BusyBox discovery returns all processes; this probe owns only its shell's terminal.
return root ? table.filter(row => row.tty === root.tty) : []
}
const waitFor = async predicate => {
const deadline = Date.now() + 8000
while (Date.now() < deadline) {
const value = await predicate()
if (value) return value
await Bun.sleep(20)
}
throw new Error('Timed out waiting for terminal process state')
}
;(async()=>{
try {
proc.write('sleep 30\\r')
const sleeper = await waitFor(async () => (await rows()).find(row => row.pid !== proc.pid))
proc.write('\\x1a')
await waitFor(async () => (await rows()).some(row => row.pid === sleeper.pid && row.state.startsWith('T')))
proc.write(${JSON.stringify('"$ORCA_TEST_RUNTIME" producer.cjs &\r')})
await waitFor(() => output.split('flow-tick').length > 5)
proc.pause()
await waitFor(() => signals.filter(([,signal]) => signal === 'SIGSTOP').length >= 2)
await Bun.sleep(100)
const pausedLength = output.length
await Bun.sleep(100)
const producerPaused = pausedLength === output.length
proc.resume()
await waitFor(() => signals.some(([,signal]) => signal === 'SIGCONT'))
await waitFor(() => output.length > pausedLength)
const sleeperAfter = (await rows()).find(row => row.pid === sleeper.pid)
console.log(JSON.stringify({producerPaused,producerResumed:true,userJobStopped:sleeperAfter?.state.startsWith('T')===true,userJobSignalled:signals.some(([pgid])=>pgid===sleeper.pgid)}))
} finally {
let ownedPids = []
try {
proc.resume()
const ownedRows = await rows()
ownedPids = ownedRows.map(row => row.pid)
const root = ownedRows.find(row => row.pid === proc.pid)
if (!root) throw new Error('Cleanup could not find the owned shell')
// Keep Bash running until it reaps its jobs; container PID 1 may not reap orphans.
forceKillPosixPtyProcessGroups(proc.pid, () => {throw new Error('Cleanup lost terminal ownership')}, {
signalProcessGroup: pgid => {if (pgid !== root.pgid) process.kill(-pgid, 'SIGKILL')}
})
process.kill(proc.pid, 'SIGCONT')
await waitFor(() => ownedPids.every(pid => pid === proc.pid || !isAlive(pid)))
} finally {
try {
forceKillPosixPtyProcessGroups(proc.pid, () => proc.kill('SIGKILL'))
await waitFor(() => exited && ownedPids.every(pid => !isAlive(pid)))
} finally {
proc.destroy()
}
}
}
})().catch(error => {console.error(error);process.exitCode=1})
`
)
const result = await runProcess({ program: runtimePath, args: [entry], timeoutMs: 25_000 })
expect(result.timedOut).toBe(false)
expect(result.code, result.stderr).toBe(0)
expect(JSON.parse(result.stdout)).toEqual({
producerPaused: true,
producerResumed: true,
userJobStopped: true,
userJobSignalled: false
})
} finally {
removeTreeSync(directory)
}
})
})
@@ -0,0 +1,27 @@
import type { BunRuntime } from './bun-pty-process-contract'
function currentRuntime(): unknown {
return 'Bun' in globalThis ? globalThis.Bun : undefined
}
function isBunRuntime(runtime: unknown): runtime is BunRuntime {
return (
typeof runtime === 'object' &&
runtime !== null &&
'spawn' in runtime &&
typeof runtime.spawn === 'function' &&
'Terminal' in runtime &&
typeof runtime.Terminal === 'function'
)
}
export function canUseBunPty(runtime: unknown = currentRuntime()): boolean {
return isBunRuntime(runtime)
}
export function resolveBunRuntime(runtime: unknown = currentRuntime()): BunRuntime {
if (!isBunRuntime(runtime)) {
throw new Error('Bun terminal runtime is unavailable')
}
return runtime
}
@@ -0,0 +1,72 @@
import type * as pty from 'node-pty'
import type { JobTerminationOutcome } from '../../windows/windows-pty-job'
import type { WindowsBunPtyJob } from './windows-bun-pty-job'
import type { createWindowsBunPtyLaunch } from './windows-bun-pty-launch'
export type BunTerminal = {
closed: boolean
write(data: string | ArrayBufferView): number
resize(cols: number, rows: number): void
close(): void
}
export type BunSubprocess = {
pid: number
terminal: BunTerminal
exited: Promise<number>
signalCode?: string | null
kill(signal?: string | number): void
}
export type BunTerminalOptions = {
cols: number
rows: number
name: string
data(terminal: BunTerminal, data: Uint8Array<ArrayBuffer>): void
exit?(terminal: BunTerminal, exitCode: number, signal: string | null): void
drain?(terminal: BunTerminal): void
}
export type BunRuntime = {
Terminal: new (options: BunTerminalOptions) => BunTerminal
spawn(
command: string[],
options: {
cwd: string
env: Record<string, string>
terminal: BunTerminal | BunTerminalOptions
windowsVerbatimArguments?: boolean
onExit?(process: BunSubprocess, exitCode: number, signalCode: string | null): void
}
): BunSubprocess
}
export type BunPtyProcess = pty.IPty & {
destroy(): void
processNameIsSpawnFile?: true
jobRootProcessIsWrapper?: true
shellProcessId?: number
waitForSpawn?(): Promise<void>
terminateOwnedTree?(): JobTerminationOutcome
listOwnedProcessIds?(): readonly number[] | null
signalProcess?(signal: string): void
}
export type BunPtySpawnArgs = {
file: string
args: string[]
cwd: string
env: Record<string, string>
cols: number
rows: number
}
export type SpawnBunPtyDeps = {
platform?: NodeJS.Platform
runtime?: BunRuntime
assignHostJob?: () => boolean
createJob?: (pid: number) => WindowsBunPtyJob | null
createWindowsLaunch?: typeof createWindowsBunPtyLaunch
readProcessTable?: () => string
signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void
}
@@ -0,0 +1,387 @@
import { constants } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control'
const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test'
const settled = (): Promise<void> => new Promise((resolve) => setImmediate(resolve))
function createHarness() {
let exited = false
const reads: { resolve: (table: string) => void; signal: AbortSignal }[] = []
const readProcessTableAsync = vi.fn(
(signal: AbortSignal) =>
new Promise<string>((resolve) => {
reads.push({ resolve, signal })
})
)
const signalProcessGroup = vi.fn<(pgid: number, signal: NodeJS.Signals) => void>()
const kill = vi.fn()
const flow = createBunPtyProducerFlowControl({
platform: 'linux',
processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } },
windowsJob: null,
isExited: () => exited,
readProcessTable: () => TABLE,
readProcessTableAsync,
signalProcessGroup
})
return {
flow,
reads,
kill,
readProcessTableAsync,
signalProcessGroup,
exit: () => {
exited = true
}
}
}
afterEach(() => vi.useRealTimers())
describe('asynchronous POSIX producer flow control', () => {
it.each(['S', 'R', ''])(
'leaves jobs running unless the shell is observed stopped (state %s)',
async (state) => {
const harness = createHarness()
harness.flow.pause()
expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]])
expect(harness.readProcessTableAsync).not.toHaveBeenCalled()
await settled()
harness.reads[0].resolve(TABLE.replace('pts/test T', `pts/test ${state}`))
await settled()
expect(harness.signalProcessGroup).not.toHaveBeenCalled()
harness.flow.resume()
await settled()
harness.reads[1].resolve(TABLE)
await settled()
expect(harness.kill.mock.calls).toEqual([
[constants.signals.SIGSTOP],
[constants.signals.SIGCONT]
])
expect(harness.signalProcessGroup).not.toHaveBeenCalled()
}
)
it('does not attempt a group pause after the owned shell cannot be stopped', async () => {
const harness = createHarness()
harness.kill.mockImplementationOnce(() => {
throw Object.assign(new Error('denied'), { code: 'EPERM' })
})
harness.flow.pause()
await settled()
harness.flow.resume()
await settled()
expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]])
expect(harness.readProcessTableAsync).not.toHaveBeenCalled()
expect(harness.signalProcessGroup).not.toHaveBeenCalled()
})
it('retries a failed resume lookup without another caller resume or stale group signals', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
harness.flow.pause()
await settled()
harness.reads[0].resolve(TABLE)
await settled()
harness.readProcessTableAsync.mockRejectedValueOnce(new Error('temporary ps failure'))
harness.flow.resume()
await settled()
expect(harness.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP']
])
expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]])
await vi.advanceTimersByTimeAsync(1_000)
expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(3)
harness.reads[1].resolve('4321 4321 pts/test T\n4322 4322 pts/other\n4323 4323 pts/test')
await settled()
expect(harness.signalProcessGroup.mock.calls.slice(2)).toEqual([[4321, 'SIGCONT']])
await vi.advanceTimersByTimeAsync(5_000)
expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(3)
})
it.each(['pause', 'shutdown', 'exit'] as const)(
'cancels a scheduled resume retry after %s',
async (action) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
harness.flow.pause()
await settled()
harness.reads[0].resolve(TABLE)
await settled()
harness.readProcessTableAsync.mockRejectedValueOnce(new Error('temporary ps failure'))
harness.flow.resume()
await settled()
expect(vi.getTimerCount()).toBe(1)
if (action === 'pause') {
harness.flow.pause()
await settled()
harness.reads[1].resolve(TABLE)
await settled()
} else {
if (action === 'exit') {
harness.exit()
}
harness.flow.resumeForShutdown()
}
expect(vi.getTimerCount()).toBe(0)
const signals = harness.signalProcessGroup.mock.calls.length
await vi.advanceTimersByTimeAsync(5_000)
expect(harness.signalProcessGroup).toHaveBeenCalledTimes(signals)
expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(action === 'pause' ? 3 : 2)
}
)
it('bounds retries while discovery stays unavailable and resumes after it recovers', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
harness.flow.pause()
await settled()
harness.reads[0].resolve(TABLE)
await settled()
harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable'))
harness.flow.resume()
await settled()
await vi.advanceTimersByTimeAsync(2_000)
expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(6)
expect(vi.getTimerCount()).toBe(1)
expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]])
expect(harness.signalProcessGroup).toHaveBeenCalledTimes(2)
harness.readProcessTableAsync.mockResolvedValue(TABLE)
await vi.advanceTimersByTimeAsync(500)
expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT')
expect(vi.getTimerCount()).toBe(0)
})
it('resumes a root-only suspension when process group discovery is unavailable throughout', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable'))
harness.flow.pause()
await settled()
harness.flow.resume()
await settled()
expect(harness.kill.mock.calls).toEqual([
[constants.signals.SIGSTOP],
[constants.signals.SIGCONT]
])
expect(harness.signalProcessGroup).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('reapplies pause after a partial resume and keeps the shell stopped until all jobs resume', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
const table = `${TABLE}\n4323 4323 pts/test`
let denyOnce = true
harness.signalProcessGroup.mockImplementation((pgid, signal) => {
if (pgid === 4323 && signal === 'SIGCONT' && denyOnce) {
denyOnce = false
throw Object.assign(new Error('denied'), { code: 'EPERM' })
}
})
harness.flow.pause()
await settled()
harness.reads[0].resolve(table)
await settled()
harness.flow.resume()
await settled()
harness.reads[1].resolve(table)
await settled()
expect(harness.signalProcessGroup.mock.calls.slice(3)).toEqual([
[4322, 'SIGCONT'],
[4323, 'SIGCONT']
])
expect(vi.getTimerCount()).toBe(1)
harness.flow.pause()
await settled()
harness.reads[2].resolve(table)
await settled()
expect(harness.signalProcessGroup.mock.calls.slice(5)).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP'],
[4323, 'SIGSTOP']
])
expect(vi.getTimerCount()).toBe(0)
harness.flow.resume()
await settled()
harness.reads[3].resolve(table)
await settled()
expect(harness.signalProcessGroup.mock.calls.slice(8)).toEqual([
[4322, 'SIGCONT'],
[4323, 'SIGCONT'],
[4321, 'SIGCONT']
])
})
it('does not delay the shell resume for a job group that already exited', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
harness.flow.pause()
await settled()
harness.reads[0].resolve(TABLE)
await settled()
harness.signalProcessGroup.mockImplementationOnce(() => {
throw Object.assign(new Error('gone'), { code: 'ESRCH' })
})
harness.flow.resume()
await settled()
harness.reads[1].resolve(TABLE)
await settled()
expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT')
expect(vi.getTimerCount()).toBe(0)
})
it('coalesces repeated pressure changes while discovery is pending', async () => {
const harness = createHarness()
harness.flow.pause()
await settled()
for (let i = 0; i < 1_000; i += 1) {
harness.flow.resume()
harness.flow.pause()
}
harness.flow.resume()
await settled()
expect(harness.readProcessTableAsync).toHaveBeenCalledOnce()
expect(harness.signalProcessGroup).not.toHaveBeenCalled()
harness.reads[0].resolve(TABLE)
await settled()
expect(harness.signalProcessGroup).not.toHaveBeenCalled()
expect(harness.kill.mock.calls).toEqual([
[constants.signals.SIGSTOP],
[constants.signals.SIGCONT]
])
for (let i = 0; i < 20; i += 1) {
harness.flow.pause()
await settled()
harness.reads[2 * i + 1].resolve(TABLE)
await settled()
harness.flow.resume()
await settled()
harness.reads[2 * i + 2].resolve(TABLE)
await settled()
}
expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(41)
expect(harness.signalProcessGroup).toHaveBeenCalledTimes(80)
expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT')
})
it('revalidates group ownership when resuming after a process id is reused', async () => {
const harness = createHarness()
harness.flow.pause()
await settled()
harness.reads[0].resolve(TABLE)
await settled()
harness.flow.resume()
await settled()
harness.reads[1].resolve('4321 4321 pts/test T\n4322 4322 pts/other\n4323 4323 pts/test')
await settled()
expect(harness.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP'],
[4321, 'SIGCONT']
])
})
it('does not resume a still-paused session when pressure returns during a resume scan', async () => {
const harness = createHarness()
harness.flow.pause()
await settled()
harness.reads[0].resolve(TABLE)
await settled()
harness.flow.resume()
await settled()
harness.flow.pause()
harness.reads[1].resolve(TABLE)
await settled()
expect(harness.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP']
])
harness.flow.resume()
await settled()
harness.reads[2].resolve(TABLE)
await settled()
expect(harness.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT')
})
it.each(['shutdown', 'exit'] as const)('ignores a late scan after %s', async (action) => {
const harness = createHarness()
harness.flow.pause()
await settled()
if (action === 'shutdown') {
harness.flow.resumeForShutdown()
expect(harness.reads[0].signal.aborted).toBe(true)
} else {
harness.exit()
}
harness.reads[0].resolve(TABLE)
await settled()
expect(harness.signalProcessGroup).not.toHaveBeenCalled()
expect(harness.kill.mock.calls).toEqual(
action === 'shutdown'
? [[constants.signals.SIGSTOP], [constants.signals.SIGCONT]]
: [[constants.signals.SIGSTOP]]
)
})
it('releases stopped groups before shutdown while an asynchronous resume is pending', async () => {
const harness = createHarness()
harness.flow.pause()
await settled()
harness.reads[0].resolve(TABLE)
await settled()
harness.flow.resume()
await settled()
harness.flow.resumeForShutdown()
expect(harness.reads[1].signal.aborted).toBe(true)
expect(harness.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP'],
[4322, 'SIGCONT'],
[4321, 'SIGCONT']
])
harness.reads[1].resolve(TABLE)
await settled()
expect(harness.signalProcessGroup).toHaveBeenCalledTimes(4)
})
it('automatically retries a partially failed resume of a partially stopped tree', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
const table = `${TABLE}\n4323 4323 pts/test S`
harness.signalProcessGroup.mockImplementation((pgid, signal) => {
if (pgid === 4323 && signal === 'SIGSTOP') {
throw new Error('temporary stop failure')
}
})
harness.flow.pause()
await settled()
harness.reads[0].resolve(table)
await settled()
harness.signalProcessGroup.mockImplementationOnce(() => {
throw Object.assign(new Error('denied'), { code: 'EPERM' })
})
harness.flow.resume()
await settled()
harness.reads[1].resolve(table)
await settled()
await vi.advanceTimersByTimeAsync(500)
harness.reads[2].resolve(table)
await settled()
expect(harness.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP'],
[4323, 'SIGSTOP'],
[4322, 'SIGCONT'],
[4322, 'SIGCONT'],
[4321, 'SIGCONT']
])
})
})
@@ -0,0 +1,185 @@
import { constants } from 'node:os'
import type { WindowsBunPtyJob } from './windows-bun-pty-job'
import { isPosixPtyRootStopped, readPosixPtyProcessTable } from '../../pty/posix-pty-process-groups'
import { createBunPtyProcessSuspension } from './bun-pty-process-suspension'
const TRANSITION_RETRY_MS = 500
type BunPtyProcessHandle = Readonly<{
pid: number
kill(signal?: string | number): void
terminal: Readonly<{ closed: boolean; close(): void }>
}>
export type BunPtyProducerFlowControl = Readonly<{
pause(): void
resume(): void
resumeForShutdown(): void
}>
export function createBunPtyProducerFlowControl(
options: Readonly<{
platform: NodeJS.Platform
processHandle: BunPtyProcessHandle
windowsJob: WindowsBunPtyJob | null
isExited: () => boolean
readProcessTable?: () => string
readProcessTableAsync?: (signal: AbortSignal) => Promise<string>
signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void
}>
): BunPtyProducerFlowControl {
let state: 'running' | 'paused' | 'uncertain' = 'running'
let pauseRequested = false
let shuttingDown = false
let pendingRead: AbortController | undefined
let transitionRetry: ReturnType<typeof setTimeout> | undefined
let pauseDenied = false
const signalRoot = (signal: 'SIGSTOP' | 'SIGCONT'): void => {
// The runtime's named STOP/CONT signals are not portable across POSIX platforms.
options.processHandle.kill(constants.signals[signal])
}
const suspension = createBunPtyProcessSuspension({
pid: options.processHandle.pid,
platform: options.platform,
signalRoot,
readProcessTable: options.readProcessTable,
signalProcessGroup: options.signalProcessGroup
})
const pausePermanentlyDenied = (error: unknown): boolean =>
error instanceof Error && 'code' in error && (error.code === 'EPERM' || error.code === 'EACCES')
const clearTransitionRetry = (): void => {
clearTimeout(transitionRetry)
transitionRetry = undefined
}
const needsTransition = (): boolean =>
!shuttingDown && !options.isExited() && state !== (pauseRequested ? 'paused' : 'running')
const retryTransition = (): void => {
if (!needsTransition() || transitionRetry) {
return
}
// Callers send transitions once; retain the obligation until fresh ownership confirms every group.
transitionRetry = setTimeout(() => {
transitionRetry = undefined
reconcile()
}, TRANSITION_RETRY_MS)
transitionRetry.unref?.()
}
const reconcile = (): void => {
if (!needsTransition() || pendingRead) {
return
}
if (options.platform === 'win32') {
const succeeded = pauseRequested ? options.windowsJob?.pause() : options.windowsJob?.resume()
state = succeeded ? (pauseRequested ? 'paused' : 'running') : 'uncertain'
if (pauseRequested && !succeeded) {
pauseRequested = false
}
retryTransition()
return
}
if (pauseRequested && state === 'running') {
try {
signalRoot('SIGSTOP')
state = 'uncertain'
} catch (error) {
if (pausePermanentlyDenied(error)) {
pauseDenied = true
pauseRequested = false
}
retryTransition()
return
}
}
const controller = new AbortController()
pendingRead = controller
// Process groups change as the shell runs jobs; revalidate them without blocking PTY output.
void Promise.resolve()
.then(() =>
options.readProcessTableAsync
? options.readProcessTableAsync(controller.signal)
: options.readProcessTable
? options.readProcessTable()
: readPosixPtyProcessTable(options.processHandle.pid, controller.signal)
)
.catch(() => '')
.then((table) => {
pendingRead = undefined
if (!needsTransition()) {
return
}
const nextPaused = pauseRequested
// Partial signals require a fresh transition even if the requested state changes again.
state = 'uncertain'
if (nextPaused) {
// Signal delivery is asynchronous; prove the shell stopped before suspending its jobs.
if (!isPosixPtyRootStopped(table, options.processHandle.pid)) {
retryTransition()
return
}
suspension.signal('SIGSTOP', table, true)
} else if (suspension.hasStoppedGroups()) {
suspension.signal('SIGCONT', table, true)
} else {
signalRoot('SIGCONT')
}
state = nextPaused ? 'paused' : 'running'
})
.catch((error) => {
if (pauseRequested && pausePermanentlyDenied(error)) {
pauseDenied = true
pauseRequested = false
reconcile()
} else {
retryTransition()
}
})
}
return {
pause() {
if (shuttingDown || options.isExited() || pauseDenied) {
return
}
clearTransitionRetry()
pauseRequested = true
reconcile()
},
resume() {
clearTransitionRetry()
pauseDenied = false
pauseRequested = false
reconcile()
},
resumeForShutdown() {
clearTransitionRetry()
shuttingDown = true
if (options.platform === 'win32') {
if (!options.isExited()) {
options.windowsJob?.resume()
}
state = 'running'
return
}
pendingRead?.abort()
try {
if (!options.isExited() && state !== 'running') {
// Teardown must release stopped jobs before the root receives its exit signal.
if (suspension.hasStoppedGroups()) {
suspension.signal('SIGCONT')
} else {
signalRoot('SIGCONT')
}
}
} catch {
// A failed resume must not prevent the caller from terminating the PTY.
}
state = 'running'
}
}
}
@@ -0,0 +1,128 @@
import { constants } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control'
const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test S'
const settled = (): Promise<void> => new Promise((resolve) => setImmediate(resolve))
function createHarness() {
let exited = false
const kill = vi.fn()
const signalProcessGroup = vi.fn()
const readProcessTableAsync = vi.fn<(signal: AbortSignal) => Promise<string>>()
const flow = createBunPtyProducerFlowControl({
platform: 'linux',
processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } },
windowsJob: null,
isExited: () => exited,
readProcessTableAsync,
signalProcessGroup
})
return { flow, kill, readProcessTableAsync, signalProcessGroup, exit: () => (exited = true) }
}
afterEach(() => vi.useRealTimers())
describe('Bun producer pause retry', () => {
it('retries a failed root suspension before attempting any group signals', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
harness.kill.mockImplementationOnce(() => {
throw new Error('temporary signal rejection')
})
harness.readProcessTableAsync.mockResolvedValue(TABLE)
harness.flow.pause()
await settled()
expect(harness.readProcessTableAsync).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(500)
expect(harness.kill.mock.calls).toEqual([
[constants.signals.SIGSTOP],
[constants.signals.SIGSTOP]
])
expect(harness.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP']
])
expect(vi.getTimerCount()).toBe(0)
})
it('keeps ownership discovery pending when the stopped root has no controlling tty', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
harness.readProcessTableAsync.mockResolvedValueOnce('4321 4321 ? T').mockResolvedValue(TABLE)
harness.flow.pause()
await settled()
expect(harness.signalProcessGroup).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(500)
expect(harness.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP']
])
expect(vi.getTimerCount()).toBe(0)
})
it.each(['lookup failure', 'shell still running'])(
'eventually stops jobs after a transient %s without another pause request',
async (failure) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
if (failure === 'lookup failure') {
harness.readProcessTableAsync.mockRejectedValueOnce(new Error('ps timed out'))
} else {
harness.readProcessTableAsync.mockResolvedValueOnce(TABLE.replace('test T', 'test S'))
}
harness.readProcessTableAsync.mockResolvedValue(TABLE)
harness.flow.pause()
await settled()
expect(harness.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]])
expect(harness.signalProcessGroup).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(499)
expect(harness.readProcessTableAsync).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(harness.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP']
])
expect(vi.getTimerCount()).toBe(0)
harness.flow.resume()
await settled()
expect(harness.signalProcessGroup.mock.calls.slice(2)).toEqual([
[4322, 'SIGCONT'],
[4321, 'SIGCONT']
])
}
)
it.each(['resume', 'shutdown', 'exit'] as const)(
'bounds failed pause probes and cancels them after %s',
async (action) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createHarness()
harness.readProcessTableAsync.mockRejectedValue(new Error('ps unavailable'))
harness.flow.pause()
await settled()
await vi.advanceTimersByTimeAsync(2_000)
expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(5)
expect(vi.getTimerCount()).toBe(1)
expect(harness.signalProcessGroup).not.toHaveBeenCalled()
if (action === 'resume') {
harness.flow.resume()
} else {
if (action === 'exit') {
harness.exit()
}
harness.flow.resumeForShutdown()
}
await settled()
const reads = harness.readProcessTableAsync.mock.calls.length
await vi.advanceTimersByTimeAsync(5_000)
expect(harness.readProcessTableAsync).toHaveBeenCalledTimes(reads)
expect(vi.getTimerCount()).toBe(0)
expect(harness.kill.mock.calls).toEqual(
action === 'exit'
? [[constants.signals.SIGSTOP]]
: [[constants.signals.SIGSTOP], [constants.signals.SIGCONT]]
)
}
)
})
@@ -0,0 +1,311 @@
import { constants } from 'node:os'
import {
assignCurrentProcessToBunPtyHostJob,
createWindowsBunPtyJob,
type WindowsBunPtyJob
} from './windows-bun-pty-job'
import { createWindowsBunPtyLaunch, type WindowsBunPtyLaunch } from './windows-bun-pty-launch'
import type {
BunPtyProcess,
BunPtySpawnArgs,
BunSubprocess,
BunTerminal,
BunTerminalOptions,
SpawnBunPtyDeps
} from './bun-pty-process-contract'
import { resolveBunRuntime } from './bun-pty-process-capabilities'
import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control'
export function spawnBunPty(args: BunPtySpawnArgs, deps: SpawnBunPtyDeps = {}): BunPtyProcess {
const runtime = resolveBunRuntime(deps.runtime)
const platform = deps.platform ?? process.platform
let processHandle: BunSubprocess
let windowsLaunch: WindowsBunPtyLaunch | null = null
let windowsJob: WindowsBunPtyJob | null = null
let windowsTerminal: BunTerminal | null = null
let processExitCode: number | undefined
let terminalFinished = false
let clearInFlight: Promise<number> | null = null
const dataListeners = new Set<(data: string) => void>()
const exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
const decoder = new TextDecoder()
let pendingData = ''
let exited = false
let exitCode = 0
let exitSignal: number | undefined
// Keep a closed Bun native handle from escaping as a daemon RPC failure.
let terminalUnavailable = false
let appliedCols = args.cols
let appliedRows = args.rows
const emitData = (data: string): void => {
if (dataListeners.size === 0) {
pendingData = (pendingData + data).slice(-512 * 1024)
return
}
for (const listener of dataListeners) {
listener(data)
}
}
const onProcessExit = (code: number): void => {
processExitCode = code
windowsLaunch?.dispose()
if (!windowsTerminal || terminalFinished) {
emitExit(code)
return
}
// ConPTY closes off-thread; retain listeners until its final frame reaches EOF.
if (!windowsTerminal.closed) {
windowsTerminal.close()
}
}
const emitExit = (code: number): void => {
if (exited) {
return
}
exited = true
producerFlowControl.resumeForShutdown()
windowsLaunch?.readShellProcessId()
exitCode = code
exitSignal = Object.entries(constants.signals).find(
([name]) => name === processHandle.signalCode
)?.[1]
const pending = decoder.decode()
if (pending) {
emitData(pending)
}
for (const dispose of [
() => (processHandle.terminal.closed ? undefined : processHandle.terminal.close()),
() => windowsJob?.close()
]) {
try {
dispose()
} catch (error) {
console.warn('[daemon/pty] PTY cleanup failed:', error)
}
}
for (const listener of exitListeners) {
listener({ exitCode: code, ...(exitSignal === undefined ? {} : { signal: exitSignal }) })
}
dataListeners.clear()
exitListeners.clear()
}
if (platform === 'win32') {
if (!(deps.assignHostJob ?? assignCurrentProcessToBunPtyHostJob)()) {
throw new Error('Windows Bun PTY host crash ownership is unavailable')
}
windowsLaunch = (deps.createWindowsLaunch ?? createWindowsBunPtyLaunch)(args)
}
try {
const terminalOptions: BunTerminalOptions = {
cols: args.cols,
rows: args.rows,
name: args.env.TERM ?? 'xterm-256color',
data: (_terminal, data) => {
const decoded = decoder.decode(data, { stream: true })
if (decoded) {
emitData(decoded)
}
},
exit() {
terminalFinished = true
if (processExitCode !== undefined) {
emitExit(processExitCode)
}
}
}
// Inline Bun terminals cannot be reused by the Windows clear command.
if (windowsLaunch) {
windowsTerminal = new runtime.Terminal(terminalOptions)
}
processHandle = runtime.spawn(windowsLaunch?.command ?? [args.file, ...args.args], {
cwd: args.cwd,
env: windowsLaunch?.env ?? args.env,
...(windowsLaunch
? {
windowsVerbatimArguments: windowsLaunch.windowsVerbatimArguments
}
: {}),
terminal: windowsTerminal ?? terminalOptions
})
} catch (error) {
windowsTerminal?.close()
windowsLaunch?.dispose()
throw error
}
if (windowsLaunch) {
try {
windowsJob = (deps.createJob ?? createWindowsBunPtyJob)(processHandle.pid)
if (!windowsJob) {
throw new Error('Windows Bun PTY job ownership is unavailable')
}
windowsLaunch.release()
} catch (error) {
windowsJob?.terminate()
try {
processHandle.kill('SIGTERM')
} catch {
// The failed gate release still owns cleanup through the job when available.
}
if (!processHandle.terminal.closed) {
processHandle.terminal.close()
}
windowsJob?.close()
windowsLaunch.dispose()
// A running gate can temporarily lock its private working directory on Windows.
const disposeLaunch = (): void => windowsLaunch?.dispose()
void processHandle.exited.then(disposeLaunch, disposeLaunch)
throw error
}
}
void processHandle.exited.then(onProcessExit, () => onProcessExit(1))
const producerFlowControl = createBunPtyProducerFlowControl({
platform,
processHandle,
windowsJob,
isExited: () => exited,
...(deps.readProcessTable ? { readProcessTable: deps.readProcessTable } : {}),
...(deps.signalProcessGroup ? { signalProcessGroup: deps.signalProcessGroup } : {})
})
const windowsCapabilities = windowsJob
? {
waitForSpawn: () => windowsLaunch?.waitForSpawn(processHandle.exited) ?? Promise.resolve(),
terminateOwnedTree: () => windowsJob?.terminate() ?? 'unavailable',
listOwnedProcessIds: () => windowsJob?.listProcessIds() ?? null,
jobRootProcessIsWrapper: true as const,
signalProcess(signal: string) {
if (signal === 'SIGWINCH') {
return
}
if (windowsJob?.terminate() === 'terminated') {
return
}
try {
processHandle.kill(signal)
} finally {
if (!processHandle.terminal.closed) {
processHandle.terminal.close()
}
}
}
}
: {}
const clearCapability = windowsLaunch
? {
clear() {
if (exited || clearInFlight) {
return
}
try {
const clearProcess = runtime.spawn(windowsLaunch.clearCommand, {
cwd: args.cwd,
env: args.env,
terminal: processHandle.terminal,
windowsVerbatimArguments: true
})
clearInFlight = clearProcess.exited
const settled = (): void => {
clearInFlight = null
}
void clearInFlight.then(settled, settled)
} catch {
clearInFlight = null
}
}
}
: {}
const terminate = (signal: string): void => {
producerFlowControl.resumeForShutdown()
const treeTerminated = windowsJob?.terminate() === 'terminated'
try {
processHandle.kill(signal)
} catch (error) {
if (!treeTerminated) {
throw error
}
}
}
return {
pid: processHandle.pid,
get shellProcessId() {
return windowsLaunch?.readShellProcessId()
},
handleFlowControl: false,
processNameIsSpawnFile: true,
clear() {},
process: args.file,
get cols() {
return appliedCols
},
get rows() {
return appliedRows
},
onData(listener) {
if (pendingData) {
const data = pendingData
pendingData = ''
listener(data)
}
if (exited) {
return { dispose() {} }
}
dataListeners.add(listener)
return { dispose: () => dataListeners.delete(listener) }
},
onExit(listener) {
if (exited) {
listener({ exitCode, ...(exitSignal === undefined ? {} : { signal: exitSignal }) })
return { dispose() {} }
}
exitListeners.add(listener)
return { dispose: () => exitListeners.delete(listener) }
},
write(data) {
if (exited || terminalUnavailable || processHandle.terminal.closed) {
return
}
try {
processHandle.terminal.write(data)
} catch {
terminalUnavailable = true
}
},
resize(cols, rows) {
if (exited || terminalUnavailable || processHandle.terminal.closed) {
return
}
try {
processHandle.terminal.resize(cols, rows)
appliedCols = cols
appliedRows = rows
} catch {
terminalUnavailable = true
}
},
...clearCapability,
...producerFlowControl,
...windowsCapabilities,
// Interactive POSIX shells ignore SIGTERM.
kill(signal = platform === 'win32' ? 'SIGTERM' : 'SIGHUP') {
if (!exited) {
terminate(signal)
}
},
destroy() {
if (!exited) {
terminate(platform === 'win32' ? 'SIGTERM' : 'SIGHUP')
}
if (!processHandle.terminal.closed) {
processHandle.terminal.close()
}
}
}
}
@@ -0,0 +1,214 @@
import { constants } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createBunPtyProducerFlowControl } from './bun-pty-process-flow-control'
const TABLE = '4321 4321 pts/test T\n4322 4322 pts/test S\n4323 4323 pts/test T'
const settled = (): Promise<void> => new Promise((resolve) => setImmediate(resolve))
function harness(platform: NodeJS.Platform = 'linux') {
let exited = false
const kill = vi.fn()
const signalProcessGroup = vi.fn()
const readProcessTableAsync = vi.fn(async () => TABLE)
const windowsJob = {
listProcessIds: () => [],
pause: vi.fn(() => true),
resume: vi.fn(() => true),
terminate: () => 'terminated' as const,
close() {}
}
const flow = createBunPtyProducerFlowControl({
platform,
processHandle: { pid: 4321, kill, terminal: { closed: false, close() {} } },
windowsJob,
isExited: () => exited,
readProcessTable: () => TABLE,
readProcessTableAsync,
signalProcessGroup
})
return {
flow,
kill,
signalProcessGroup,
readProcessTableAsync,
windowsJob,
exit: () => (exited = true)
}
}
afterEach(() => vi.useRealTimers())
describe('flow-control suspension ownership', () => {
it.each(['resume', 'shutdown'] as const)(
'preserves a Ctrl-Z stopped job during %s',
async (action) => {
const h = harness()
h.flow.pause()
await settled()
h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4322 pts/test S', '4322 pts/test T'))
if (action === 'resume') {
h.flow.resume()
} else {
h.flow.resumeForShutdown()
}
await settled()
expect(h.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP'],
[4322, 'SIGCONT'],
[4321, 'SIGCONT']
])
}
)
it('does not resume a group it already released when another group needs a retry', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const h = harness()
h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4323 pts/test T', '4323 pts/test S'))
let failed = false
h.signalProcessGroup.mockImplementation((pgid, signal) => {
if (pgid === 4323 && signal === 'SIGCONT' && !failed) {
failed = true
throw new Error('temporary resume failure')
}
})
h.flow.pause()
await settled()
h.flow.resume()
await settled()
// The user can suspend a job again after its first successful resume.
h.readProcessTableAsync.mockResolvedValue(TABLE.replace('4322 pts/test S', '4322 pts/test T'))
await vi.advanceTimersByTimeAsync(500)
expect(
h.signalProcessGroup.mock.calls.filter(
([pid, signal]) => pid === 4322 && signal === 'SIGCONT'
)
).toHaveLength(1)
expect(h.signalProcessGroup).toHaveBeenLastCalledWith(4321, 'SIGCONT')
expect(vi.getTimerCount()).toBe(0)
})
it.each(['EPERM', 'EACCES'])('does not retry a root pause denied with %s', async (code) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const h = harness()
h.kill.mockImplementationOnce(() => {
throw Object.assign(new Error('denied'), { code })
})
h.flow.pause()
await settled()
h.flow.pause()
await vi.advanceTimersByTimeAsync(30_000)
expect(h.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]])
expect(h.readProcessTableAsync).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
h.flow.resume()
h.flow.pause()
await settled()
expect(h.kill).toHaveBeenCalledTimes(2)
h.flow.resumeForShutdown()
})
it('rolls back acquired stops after a denied job pause without repeatedly scanning or resuming the denied job', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const h = harness()
h.readProcessTableAsync.mockResolvedValue(`${TABLE}\n4324 4324 pts/test S`)
h.signalProcessGroup.mockImplementation((pgid, signal) => {
if (pgid === 4324 && signal === 'SIGSTOP') {
throw Object.assign(new Error('denied'), { code: 'EPERM' })
}
})
h.flow.pause()
await settled()
await vi.advanceTimersByTimeAsync(30_000)
expect(h.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP'],
[4324, 'SIGSTOP'],
[4322, 'SIGCONT'],
[4321, 'SIGCONT']
])
expect(h.readProcessTableAsync).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
})
it('retains the resume obligation when rollback after a denied pause also fails', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const h = harness()
let failed = false
h.signalProcessGroup.mockImplementation((pgid, signal) => {
if (pgid === 4322 && signal === 'SIGSTOP') {
throw Object.assign(new Error('denied'), { code: 'EPERM' })
}
if (pgid === 4321 && signal === 'SIGCONT' && !failed) {
failed = true
throw Object.assign(new Error('resume denied'), { code: 'EPERM' })
}
})
h.flow.pause()
await settled()
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(500)
expect(h.signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP'],
[4321, 'SIGCONT'],
[4321, 'SIGCONT']
])
expect(vi.getTimerCount()).toBe(0)
})
})
describe('Windows resume retries', () => {
it('releases a failed partial pause without repeatedly attempting the denied pause', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const h = harness('win32')
h.windowsJob.pause.mockReturnValueOnce(false)
h.windowsJob.resume.mockReturnValueOnce(false)
h.flow.pause()
await vi.advanceTimersByTimeAsync(500)
expect(h.windowsJob.resume).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(500)
expect(h.windowsJob.resume).toHaveBeenCalledTimes(2)
expect(h.windowsJob.pause).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
h.flow.pause()
expect(h.windowsJob.pause).toHaveBeenCalledTimes(2)
h.flow.resumeForShutdown()
})
it('retries a failed resume without another caller transition', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const h = harness('win32')
h.windowsJob.resume.mockReturnValueOnce(false)
h.flow.pause()
h.flow.resume()
expect(h.windowsJob.resume).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(500)
expect(h.windowsJob.resume).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
expect(h.readProcessTableAsync).not.toHaveBeenCalled()
})
it.each(['pause', 'shutdown', 'exit'] as const)(
'cancels stale resume retries after %s',
async (action) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const h = harness('win32')
h.windowsJob.resume.mockReturnValue(false)
h.flow.pause()
h.flow.resume()
if (action === 'pause') {
h.flow.pause()
} else {
if (action === 'exit') {
h.exit()
}
h.flow.resumeForShutdown()
}
const resumes = h.windowsJob.resume.mock.calls.length
await vi.advanceTimersByTimeAsync(5_000)
expect(h.windowsJob.resume).toHaveBeenCalledTimes(resumes)
expect(vi.getTimerCount()).toBe(0)
}
)
})
@@ -0,0 +1,78 @@
import {
getPosixPtyStoppedJobGroups,
signalPosixPtyProcessGroups
} from '../../pty/posix-pty-process-groups'
export function createBunPtyProcessSuspension(options: {
pid: number
platform: NodeJS.Platform
signalRoot: (signal: 'SIGSTOP' | 'SIGCONT') => void
readProcessTable?: () => string
signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void
}) {
const stoppedGroups = new Set<number>()
return {
hasStoppedGroups: () => stoppedGroups.size > 0,
signal(signal: 'SIGSTOP' | 'SIGCONT', table?: string, requireGroups = false): void {
const alreadyStopped =
signal === 'SIGSTOP' && table !== undefined
? getPosixPtyStoppedJobGroups(table, options.pid)
: new Set<number>()
let resumeFailed = false
signalPosixPtyProcessGroups(
options.pid,
signal,
() => {
if (requireGroups) {
throw new Error('Paused PTY group ownership is unavailable')
}
options.signalRoot(signal)
},
{
platform: options.platform,
...(table !== undefined
? { readProcessTable: () => table }
: options.readProcessTable
? { readProcessTable: options.readProcessTable }
: {}),
signalProcessGroup(pgid) {
if (
signal === 'SIGSTOP'
? alreadyStopped.has(pgid) && !stoppedGroups.has(pgid)
: !stoppedGroups.has(pgid)
) {
return
}
// Keep the shell stopped until every preceding job group has resumed.
if (signal === 'SIGCONT' && requireGroups && resumeFailed) {
throw new Error('An earlier PTY group could not be resumed')
}
try {
if (options.signalProcessGroup) {
options.signalProcessGroup(pgid, signal)
} else {
process.kill(-pgid, signal)
}
} catch (error) {
const gone = error instanceof Error && 'code' in error && error.code === 'ESRCH'
if (gone) {
stoppedGroups.delete(pgid)
}
resumeFailed = !gone
throw error
}
if (signal === 'SIGSTOP') {
stoppedGroups.add(pgid)
} else {
stoppedGroups.delete(pgid)
}
}
}
)
if (signal === 'SIGCONT') {
// A fresh successful scan also retires groups that no longer belong to this terminal.
stoppedGroups.clear()
}
}
}
}
@@ -0,0 +1,298 @@
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { runProcess, runProcessSync } from '../../../shared/child-process/run-process'
import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts'
import { ORCAD_BUN_VERSION } from '../../../shared/orcad-bun-runtime'
import { removeTreeSync } from '../../../shared/windows-transient-lock-removal'
const runtimePath =
process.env.BUN_EXECUTABLE ??
resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform))
async function runTerminalScript(script: string): Promise<unknown> {
expect(runProcessSync({ program: runtimePath, args: ['--version'] }).stdout.trim()).toBe(
ORCAD_BUN_VERSION
)
const directory = mkdtempSync(join(tmpdir(), 'orca-bun-terminal-'))
try {
const entry = join(directory, 'terminal.cjs')
writeFileSync(
entry,
[
`const {spawnBunPty} = require(${JSON.stringify(join(__dirname, 'bun-pty-process.ts'))})`,
`const args = {file: process.execPath, cwd: ${JSON.stringify(directory)}, env: process.env, cols: 80, rows: 24}`,
script
].join('\n')
)
const result = await runProcess({ program: runtimePath, args: [entry], timeoutMs: 30_000 })
expect(result.timedOut).toBe(false)
expect(result.code, result.stderr).toBe(0)
return JSON.parse(result.stdout)
} finally {
removeTreeSync(directory)
}
}
describe.skipIf(!existsSync(runtimePath) || process.platform === 'win32')(
'real Bun terminal',
() => {
it('drains multi-byte output before publishing process exit and applies resize', async () => {
const result = await runTerminalScript(`
const expected = '⌘状態'.repeat(200_000)
const proc = spawnBunPty({...args, args: ['-e', 'process.stdout.write("⌘状態".repeat(200000));process.exitCode=17']})
let output = ''
proc.resize(103, 37)
proc.onData(data => output += data)
proc.onExit(event => {
console.log(JSON.stringify({event, exact: output === expected, cols:proc.cols, rows:proc.rows}))
proc.destroy()
})
`)
expect(result).toEqual({ event: { exitCode: 17 }, exact: true, cols: 103, rows: 37 })
})
it('receives the real shell identity from a gated Bun subprocess', async () => {
const result = await runTerminalScript(`
const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))})
const proc = spawnBunPty({...args,args:['-e','setTimeout(()=>{process.exitCode=17},100)']}, {
platform:'win32', assignHostJob:()=>true,
createJob:()=>({listProcessIds:()=>[], pause:()=>true,resume:()=>true,terminate:()=> 'terminated',close(){}}),
createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, {
runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))}
})
})
proc.onExit(event => {
console.log(JSON.stringify({event, shellIdentified:proc.shellProcessId>0 && proc.shellProcessId!==proc.pid}))
proc.destroy()
})
`)
expect(result).toEqual({ event: { exitCode: 17 }, shellIdentified: true })
})
it('reports signal termination distinctly from an ordinary exit', async () => {
const result = await runTerminalScript(`
const proc = spawnBunPty({...args,args:['-e', 'console.log("ready");setInterval(()=>{},1000)']})
proc.onData(() => proc.kill('SIGTERM'))
proc.onExit(event => { console.log(JSON.stringify(event));proc.destroy() })
`)
expect(result).toEqual({ exitCode: 143, signal: 15 })
})
it('pauses and resumes the owned process when process discovery is unavailable', async () => {
const result = await runTerminalScript(`
const expected = 'ready' + 'x'.repeat(1024 * 1024)
const continueOutput = require('node:path').join(args.cwd, 'continue-output')
const proc = spawnBunPty({...args,env:{...args.env,ORCA_TEST_CONTINUE:continueOutput},args:['-e','process.stdout.write("ready");const timer=setInterval(()=>{if(!require("node:fs").existsSync(process.env.ORCA_TEST_CONTINUE))return;clearInterval(timer);process.stdout.write("x".repeat(1024*1024))},1)']},{readProcessTable:()=>''})
let output = '', paused = false, stable = false
proc.onData(data => {
output += data
if (paused) return
paused = true
proc.pause()
setTimeout(() => {
const settled = output.length
require('node:fs').writeFileSync(continueOutput, 'continue')
setTimeout(() => { stable = output.length === settled;proc.resume() }, 150)
}, 150)
})
proc.onExit(event => {
console.log(JSON.stringify({event,stable,exact:output===expected}))
proc.destroy()
})
`)
expect(result).toEqual({ event: { exitCode: 0 }, stable: true, exact: true })
})
it.skipIf(!existsSync('/bin/bash')).each([
[false, 0],
[false, 100],
[true, 0],
[true, 100]
] as const)(
'stops foreground and background floods without losing output (resume failure: %s, signal gap: %sms)',
async (rejectFirstResume, stopSignalGapMs) => {
const result = await runTerminalScript(`
const expected = 16 * 1024 * 1024
const {join} = require('node:path')
const {writeFileSync,existsSync} = require('node:fs')
const producer = join(args.cwd, 'producer.cjs')
const backgroundReady = join(args.cwd, 'background-ready')
const foregroundReady = join(args.cwd, 'foreground-ready')
const go = join(args.cwd, 'go')
const continueOutput = join(args.cwd, 'continue-output')
writeFileSync(producer, [
'const {writeFileSync,existsSync}=require("node:fs")',
'writeFileSync(process.argv[2],"ready")',
'const deadline=setTimeout(()=>process.exit(97),10000)',
'const ready=setInterval(()=>{if(!existsSync(process.argv[3]))return;clearInterval(ready);process.stdout.write("x".repeat(65536));const continued=setInterval(()=>{if(!existsSync(process.argv[4]))return;clearInterval(continued);clearTimeout(deadline);let count=1;const timer=setInterval(()=>{process.stdout.write("x".repeat(65536));if(++count===128)clearInterval(timer)},1)},1)},1)'
].join(';'))
const groups = new Set()
let bytes = 0, paused = false, settledBytes = 0, stable = false, verifying = false, rejectedResume = false
const proc = spawnBunPty({
...args, file:'/bin/bash',
args:['--noprofile','--norc','-i','-c','exec 2>/dev/null; "$ORCA_TEST_RUNTIME" "$ORCA_TEST_PRODUCER" "$ORCA_TEST_BACKGROUND_READY" "$ORCA_TEST_GO" "$ORCA_TEST_CONTINUE" & "$ORCA_TEST_RUNTIME" "$ORCA_TEST_PRODUCER" "$ORCA_TEST_FOREGROUND_READY" "$ORCA_TEST_GO" "$ORCA_TEST_CONTINUE"; wait'],
env:{...args.env,ORCA_TEST_RUNTIME:process.execPath,ORCA_TEST_PRODUCER:producer,ORCA_TEST_BACKGROUND_READY:backgroundReady,ORCA_TEST_FOREGROUND_READY:foregroundReady,ORCA_TEST_GO:go,ORCA_TEST_CONTINUE:continueOutput}
},{signalProcessGroup:(pgid,signal)=>{
if (signal === 'SIGCONT' && ${rejectFirstResume} && !rejectedResume) {
rejectedResume = true
throw Object.assign(new Error('transient resume failure'), {code:'EPERM'})
}
process.kill(-pgid,signal)
if (signal === 'SIGSTOP') {
groups.add(pgid)
// Give Bash time to react between signals; stopping its jobs first can end its wait.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${stopSignalGapMs})
}
if (groups.size < 3 || verifying) return
verifying = true
writeFileSync(continueOutput, 'continue')
setTimeout(() => {
settledBytes = bytes
setTimeout(() => { stable = bytes === settledBytes;proc.resume() }, 150)
},150)
}})
const ready = setInterval(() => {
if (!existsSync(backgroundReady) || !existsSync(foregroundReady)) return
clearInterval(ready)
writeFileSync(go, 'go')
}, 5)
let beats = 0
const heartbeat = setInterval(() => beats++, 5)
proc.onData(data => {
bytes += data.length
// Drain both initial writes before measuring whether stopped producers emit more.
if (!paused && bytes === 2 * 65536) {
paused = true
proc.pause()
}
})
proc.onExit(event => {
clearInterval(ready)
clearInterval(heartbeat)
console.log(JSON.stringify({event,stable,exact:bytes===expected,pausedBeforeExit:settledBytes<expected,responsive:beats>10,jobControlGroups:groups.size>=3,rejectedResume}))
proc.destroy()
})
`)
expect(result).toEqual({
event: { exitCode: 0 },
stable: true,
exact: true,
pausedBeforeExit: true,
responsive: true,
jobControlGroups: true,
rejectedResume: rejectFirstResume
})
}
)
}
)
describe.skipIf(!existsSync(runtimePath) || process.platform !== 'win32')(
'native Windows Bun terminal',
() => {
it('falls back after actual shell spawn rejection and cleans each private launch directory', async () => {
const result = await runTerminalScript(`
const {spawnNativeDaemonPty} = require(${JSON.stringify(join(__dirname, 'native-pty-spawn.ts'))})
const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))})
const {existsSync} = require('node:fs')
const {dirname,join} = require('node:path')
const directories = []
const attempts = [join(args.cwd,'missing-pwsh.exe'),join(args.cwd,'missing-powershell.exe'),process.execPath].map(shellPath=>({
shellPath,shellArgs:['-e','process.exitCode=17'],effectiveCwd:args.cwd,validationCwd:args.cwd,startupCommandDeliveredInShellArgs:true
}))
spawnNativeDaemonPty({
shellPath:attempts[0].shellPath,shellArgs:attempts[0].shellArgs,spawnCwd:args.cwd,
env:args.env,cols:80,rows:24,windowsFallbackAttempts:attempts
}, {canUseBunPty:()=>true, spawnBunPty:options=>spawnBunPty(options, {
createWindowsLaunch:launchArgs=>{
const launch = createWindowsBunPtyLaunch(launchArgs, {
runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))}
})
directories.push(dirname(launch.command.at(-1)))
return launch
}
})}).then(({process:proc,shellPath})=>{
proc.onExit(event=>{
console.log(JSON.stringify({event,fallback:shellPath===process.execPath,attempts:directories.length,cleaned:directories.every(path=>!existsSync(path))}))
proc.destroy()
})
}).catch(error=>{console.error(error);process.exitCode=1})
`)
expect(result).toEqual({
event: { exitCode: 17 },
fallback: true,
attempts: 3,
cleaned: true
})
}, 35_000)
it('enumerates and suspends a native job with more than 64 processes', async () => {
const result = await runTerminalScript(`
const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))})
const script = 'for(let i=0;i<65;i++)Bun.spawn([process.execPath,"-e","setInterval(()=>{},1000)"],{stdin:"ignore",stdout:"ignore",stderr:"ignore"});setInterval(()=>console.log("tick"),10)'
const proc = spawnBunPty({...args,args:['-e',script]}, {
createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, {
runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))}
})
})
let bytes=0,started=false,evidence
proc.onData(data=>{
bytes+=data.length
if(started || !data.includes('tick'))return
const members=proc.listOwnedProcessIds()
if(!members || members.length<67)return
started=true
proc.pause()
setTimeout(()=>{
const pausedBytes=bytes
setTimeout(()=>{
const stopped=bytes===pausedBytes
proc.resume()
setTimeout(()=>{
evidence={members:members.length,stopped,resumed:bytes>pausedBytes}
proc.kill()
},150)
},150)
},150)
})
proc.onExit(()=>{
console.log(JSON.stringify(evidence))
proc.destroy()
})
`)
expect(result).toEqual({ members: 67, stopped: true, resumed: true })
}, 35_000)
it('opens ConPTY without IPC and identifies the shell inside its job', async () => {
const result = await runTerminalScript(`
const {createWindowsBunPtyLaunch} = require(${JSON.stringify(join(__dirname, 'windows-bun-pty-launch.ts'))})
const proc = spawnBunPty({...args,args:['-e','console.log("ready");setInterval(()=>{},1000)']}, {
createWindowsLaunch:launch => createWindowsBunPtyLaunch(launch, {
runtimePath:process.execPath,workerPath:${JSON.stringify(join(__dirname, 'windows-bun-pty-gate-entry.ts'))}
})
})
let output = '', evidence
proc.onData(data => output += data)
const timer = setInterval(() => {
const shell = proc.shellProcessId
const members = proc.listOwnedProcessIds()
if (!output.includes('ready') || !shell || !members?.includes(shell)) return
clearInterval(timer)
evidence = {distinctShell:shell!==proc.pid, gateOwned:members.includes(proc.pid), shellOwned:true}
proc.kill()
}, 10)
proc.onExit(event => {
clearInterval(timer)
console.log(JSON.stringify({evidence, exited:event.exitCode!==undefined}))
proc.destroy()
})
`)
expect(result).toEqual({
evidence: { distinctShell: true, gateOwned: true, shellOwned: true },
exited: true
})
})
}
)
@@ -0,0 +1,652 @@
import { constants } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { canUseBunPty, spawnBunPty } from './bun-pty-process'
import type { BunRuntime, BunTerminalOptions } from './bun-pty-process-contract'
import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership'
import * as posixPtyGroups from '../../pty/posix-pty-process-groups'
type FakeTerminal = {
closed: boolean
write(data: string | ArrayBufferView): number
resize(cols: number, rows: number): void
close(): void
}
let testRuntime: NonNullable<Parameters<typeof spawnBunPty>[1]>['runtime']
function createBunHarness({ closeImmediately = true } = {}) {
let resolveExit: (code: number) => void = () => {}
let windowsTerminalOptions: BunTerminalOptions | undefined
const terminal: FakeTerminal = {
closed: false,
write: vi.fn(() => 1),
resize: vi.fn(),
close: vi.fn(function (this: FakeTerminal) {
this.closed = true
if (closeImmediately) {
windowsTerminalOptions?.exit?.(terminal, 0, null)
}
})
}
const processHandle = {
pid: 4321,
terminal,
kill: vi.fn(),
exited: new Promise<number>((resolve) => {
resolveExit = resolve
})
}
const spawn = vi.fn(
(_command: string[], _options: Parameters<BunRuntime['spawn']>[1]) => processHandle
)
testRuntime = {
Terminal: class {
closed = false
write = terminal.write
resize = terminal.resize
close = terminal.close
constructor(options: BunTerminalOptions) {
windowsTerminalOptions = options
return terminal
}
},
spawn
}
const emitData = (data: Uint8Array<ArrayBuffer>): void => {
const options = spawn.mock.calls[0]?.[1]
const callbacks =
windowsTerminalOptions ??
(options && 'data' in options.terminal ? options.terminal : undefined)
if (!callbacks) {
throw new Error('missing terminal callbacks')
}
callbacks.data(terminal, data)
}
return {
processHandle,
resolveExit,
spawn,
terminal,
emitData,
finishTerminal: () => windowsTerminalOptions?.exit?.(terminal, 0, null)
}
}
function spawn(deps?: Parameters<typeof spawnBunPty>[1]) {
return spawnBunPty(
{
file: '/bin/sh',
args: ['-l'],
cwd: '/tmp',
env: { TERM: 'xterm-256color' },
cols: 80,
rows: 24
},
{ platform: 'linux', runtime: testRuntime, ...deps }
)
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
testRuntime = undefined
})
describe('Bun.Terminal PTY adapter', () => {
it('cancels a pending ownership lookup on natural exit without delivering a late stop', async () => {
const harness = createBunHarness()
let finishRead: (table: string) => void = () => {}
const read = vi.spyOn(posixPtyGroups, 'readPosixPtyProcessTable').mockImplementation(
() =>
new Promise<string>((resolve) => {
finishRead = resolve
})
)
const signalProcessGroup = vi.fn()
const proc = spawn({ signalProcessGroup })
proc.pause()
await new Promise<void>((resolve) => setImmediate(resolve))
const signal = read.mock.calls[0][1]
expect(signal?.aborted).toBe(false)
harness.resolveExit(0)
await harness.processHandle.exited
expect(signal?.aborted).toBe(true)
finishRead('4321 4321 pts/test T\n4322 4322 pts/test')
await new Promise<void>((resolve) => setImmediate(resolve))
expect(signalProcessGroup).not.toHaveBeenCalled()
expect(harness.processHandle.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]])
})
it('cancels a queued resume retry immediately on natural exit', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const harness = createBunHarness()
const read = vi
.spyOn(posixPtyGroups, 'readPosixPtyProcessTable')
.mockResolvedValueOnce('4321 4321 pts/test T\n4322 4322 pts/test')
.mockRejectedValueOnce(new Error('temporary ps failure'))
const signalProcessGroup = vi.fn()
const proc = spawn({ signalProcessGroup })
proc.pause()
await new Promise<void>((resolve) => setImmediate(resolve))
proc.resume()
await new Promise<void>((resolve) => setImmediate(resolve))
expect(vi.getTimerCount()).toBe(1)
harness.resolveExit(0)
await harness.processHandle.exited
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(5_000)
expect(read).toHaveBeenCalledTimes(2)
expect(signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP']
])
expect(harness.processHandle.kill.mock.calls).toEqual([[constants.signals.SIGSTOP]])
})
it('exposes initial and successfully applied dimensions for terminal inspection', () => {
const harness = createBunHarness()
const proc = spawn()
expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 80, rows: 24 })
proc.resize(103, 37)
expect(harness.terminal.resize).toHaveBeenCalledWith(103, 37)
expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 103, rows: 37 })
})
it.each(['closed', 'exited', 'failed'] as const)(
'retains last applied dimensions when resize is %s',
async (reason) => {
const harness = createBunHarness()
const proc = spawn()
proc.resize(103, 37)
if (reason === 'closed') {
harness.terminal.closed = true
}
if (reason === 'exited') {
harness.resolveExit(0)
await harness.processHandle.exited
}
if (reason === 'failed') {
vi.mocked(harness.terminal.resize).mockImplementationOnce(() => {
throw new Error('closed')
})
}
proc.resize(120, 40)
expect({ cols: proc.cols, rows: proc.rows }).toEqual({ cols: 103, rows: 37 })
}
)
it('requires Bun.Terminal as well as Bun.spawn', () => {
const spawn = vi.fn()
expect(canUseBunPty({ spawn })).toBe(false)
expect(canUseBunPty({ Terminal: class {}, spawn })).toBe(true)
})
it('streams split UTF-8 and reports exit to current and late listeners', async () => {
const harness = createBunHarness()
const proc = spawn()
const onData = vi.fn()
const onExit = vi.fn()
proc.onData(onData)
proc.onExit(onExit)
const bytes = new TextEncoder().encode('⌘状')
harness.emitData(bytes.slice(0, 2))
expect(onData).not.toHaveBeenCalled()
harness.emitData(bytes.slice(2))
expect(onData).toHaveBeenCalledWith('⌘状')
harness.resolveExit(7)
await harness.processHandle.exited
await Promise.resolve()
expect(onExit).toHaveBeenCalledWith({ exitCode: 7 })
const lateExit = vi.fn()
proc.onExit(lateExit)
expect(lateExit).toHaveBeenCalledWith({ exitCode: 7 })
})
it('preserves output arriving before the first data listener', () => {
const harness = createBunHarness()
const proc = spawn()
harness.emitData(new TextEncoder().encode('startup output'))
const listener = vi.fn()
proc.onData(listener)
expect(listener).toHaveBeenCalledWith('startup output')
})
it('preserves signal termination and never signals the exited handle during disposal', async () => {
const harness = createBunHarness()
const proc = spawn()
Object.assign(harness.processHandle, { signalCode: 'SIGTERM' })
harness.resolveExit(143)
await harness.processHandle.exited
await Promise.resolve()
const listener = vi.fn()
proc.onExit(listener)
proc.destroy()
expect(listener).toHaveBeenCalledWith({ exitCode: 143, signal: 15 })
expect(harness.processHandle.kill).not.toHaveBeenCalled()
expect(harness.terminal.close).toHaveBeenCalledOnce()
})
it('disposes data and exit listeners without retaining them', async () => {
const harness = createBunHarness()
const proc = spawn()
const onData = vi.fn()
const onExit = vi.fn()
const dataSubscription = proc.onData(onData)
const exitSubscription = proc.onExit(onExit)
dataSubscription.dispose()
exitSubscription.dispose()
harness.emitData(new TextEncoder().encode('ignored'))
harness.resolveExit(0)
await harness.processHandle.exited
await Promise.resolve()
expect(onData).not.toHaveBeenCalled()
expect(onExit).not.toHaveBeenCalled()
})
it.each(['darwin', 'linux'] as const)(
'forwards input, resize, hangup, explicit signals, and destroy on %s',
(platform) => {
const harness = createBunHarness()
const proc = spawn({ platform })
proc.write('hello')
proc.resize(120, 40)
proc.kill()
proc.kill('SIGTERM')
proc.kill('SIGINT')
proc.kill('SIGKILL')
proc.destroy()
expect(harness.terminal.write).toHaveBeenCalledWith('hello')
expect(harness.terminal.resize).toHaveBeenCalledWith(120, 40)
expect(harness.processHandle.kill.mock.calls).toEqual([
['SIGHUP'],
['SIGTERM'],
['SIGINT'],
['SIGKILL'],
['SIGHUP']
])
expect(harness.terminal.close).toHaveBeenCalledOnce()
}
)
it('destroys a still-running process even if its terminal has already closed', () => {
const harness = createBunHarness()
const proc = spawn()
harness.terminal.closed = true
proc.destroy()
expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGHUP')
expect(harness.terminal.close).not.toHaveBeenCalled()
})
it('contains a native terminal write failure and suppresses later writes', () => {
const harness = createBunHarness()
harness.terminal.write = vi.fn(() => {
throw new Error('terminal closed')
})
const proc = spawn()
expect(() => proc.write('first')).not.toThrow()
proc.write('second')
expect(harness.terminal.write).toHaveBeenCalledOnce()
})
it('contains a native terminal resize failure and suppresses later resizes', () => {
const harness = createBunHarness()
harness.terminal.resize = vi.fn(() => {
throw new Error('terminal closed')
})
const proc = spawn()
expect(() => proc.resize(120, 40)).not.toThrow()
proc.resize(100, 30)
expect(harness.terminal.resize).toHaveBeenCalledOnce()
})
it('pauses and resumes the POSIX producer process group once per transition', async () => {
createBunHarness()
const signalProcessGroup = vi.fn()
const proc = spawn({
readProcessTable: () => ' 4321 4321 pts/test T\n 4322 4322 pts/test',
signalProcessGroup
})
proc.pause()
proc.pause()
await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(2))
proc.resume()
proc.resume()
await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(4))
expect(signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP'],
[4322, 'SIGCONT'],
[4321, 'SIGCONT']
])
})
it('resumes a paused process group before graceful shutdown', async () => {
const harness = createBunHarness()
const signalProcessGroup = vi.fn()
const proc = spawn({
readProcessTable: () => ' 4321 4321 pts/test T\n 4322 4322 pts/test',
signalProcessGroup
})
proc.pause()
await vi.waitFor(() => expect(signalProcessGroup).toHaveBeenCalledTimes(2))
proc.kill()
expect(signalProcessGroup.mock.calls).toEqual([
[4321, 'SIGSTOP'],
[4322, 'SIGSTOP'],
[4322, 'SIGCONT'],
[4321, 'SIGCONT']
])
expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGHUP')
})
it('falls back to Bun process signals when group signaling is unavailable', async () => {
const harness = createBunHarness()
vi.spyOn(process, 'kill').mockImplementation(() => {
throw Object.assign(new Error('not supported'), { code: 'EINVAL' })
})
const proc = spawn({ readProcessTable: () => '' })
proc.pause()
await vi.waitFor(() =>
expect(harness.processHandle.kill).toHaveBeenCalledWith(constants.signals.SIGSTOP)
)
proc.resume()
await vi.waitFor(() =>
expect(harness.processHandle.kill).toHaveBeenCalledWith(constants.signals.SIGCONT)
)
expect(harness.processHandle.kill.mock.calls).toEqual([
[constants.signals.SIGSTOP],
[constants.signals.SIGCONT]
])
})
it('gates a Windows shell behind exact job ownership and exposes owned capabilities', async () => {
const harness = createBunHarness({ closeImmediately: false })
const assignHostJob = vi.fn(() => true)
const release = vi.fn()
const dispose = vi.fn()
const waitForSpawn = vi.fn(async () => {})
let reportedShellPid: number | undefined
const job = {
listProcessIds: vi.fn(() => [4321, 4322]),
pause: vi.fn(() => true),
resume: vi.fn(() => true),
terminate: vi.fn(() => 'terminated' as const),
close: vi.fn()
}
const createJob = vi.fn(() => job)
const createWindowsLaunch = vi.fn(() => ({
command: ['cmd.exe', '/d /c launch.cmd'],
clearCommand: ['cmd.exe', '/d /c clear.cmd'],
env: { TERM: 'xterm-256color', ORCA_BUN_PTY_JOB_GATE: 'gate' },
windowsVerbatimArguments: true as const,
release,
dispose,
waitForSpawn,
readShellProcessId: () => reportedShellPid
}))
const proc = spawn({
platform: 'win32',
assignHostJob,
createJob,
createWindowsLaunch
})
expect(harness.spawn.mock.calls[0]?.[0]).toEqual(['cmd.exe', '/d /c launch.cmd'])
expect(harness.spawn.mock.calls[0]?.[1]).toMatchObject({
windowsVerbatimArguments: true,
env: { ORCA_BUN_PTY_JOB_GATE: 'gate' },
terminal: harness.terminal
})
expect(assignHostJob.mock.invocationCallOrder[0]).toBeLessThan(
harness.spawn.mock.invocationCallOrder[0]
)
expect(createJob).toHaveBeenCalledWith(4321)
expect(createJob.mock.invocationCallOrder[0]).toBeLessThan(release.mock.invocationCallOrder[0])
await proc.waitForSpawn?.()
expect(waitForSpawn).toHaveBeenCalledWith(harness.processHandle.exited)
proc.pause()
proc.pause()
proc.resume()
proc.resume()
expect(job.pause).toHaveBeenCalledOnce()
expect(job.resume).toHaveBeenCalledOnce()
expect(proc.jobRootProcessIsWrapper).toBe(true)
expect(readWindowsPtyJobProcessIds(proc)).toBeNull()
expect(harness.spawn.mock.calls[0]?.[1]).not.toHaveProperty('ipc')
reportedShellPid = 4322
expect(proc.shellProcessId).toBe(4322)
expect(readWindowsPtyJobProcessIds(proc)).toEqual(new Set([4322]))
job.listProcessIds.mockReturnValueOnce([4321, 4323])
expect(readWindowsPtyJobProcessIds(proc)).toBeNull()
expect(proc.shellProcessId).toBe(4322)
expect(proc.listOwnedProcessIds?.()).toEqual([4321, 4322])
expect(proc.terminateOwnedTree?.()).toBe('terminated')
job.terminate.mockClear()
proc.signalProcess?.('SIGINT')
expect(job.terminate).toHaveBeenCalledOnce()
expect(harness.processHandle.kill).not.toHaveBeenCalled()
proc.clear()
proc.clear()
expect(harness.spawn.mock.calls[1]?.[0]).toEqual(['cmd.exe', '/d /c clear.cmd'])
expect(harness.spawn.mock.calls[1]?.[1]).toMatchObject({
terminal: harness.terminal,
windowsVerbatimArguments: true
})
expect(harness.spawn).toHaveBeenCalledTimes(2)
const lastOutput = vi.fn()
const onExit = vi.fn()
proc.onData(lastOutput)
proc.onExit(onExit)
harness.resolveExit(0)
await harness.processHandle.exited
await Promise.resolve()
expect(onExit).not.toHaveBeenCalled()
expect(job.close).not.toHaveBeenCalled()
harness.emitData(new TextEncoder().encode('final ConPTY frame'))
harness.finishTerminal()
expect(lastOutput).toHaveBeenCalledWith('final ConPTY frame')
expect(onExit).toHaveBeenCalledOnce()
expect(job.close).toHaveBeenCalledOnce()
expect(dispose).toHaveBeenCalledOnce()
expect(job.resume).toHaveBeenCalledOnce()
job.resume.mockImplementation(() => {
throw new Error('job already closed')
})
expect(() => {
proc.pause()
proc.resume()
proc.kill()
proc.destroy()
}).not.toThrow()
expect(job.resume).toHaveBeenCalledOnce()
})
it('does not release a Windows gate without exact job ownership', async () => {
const harness = createBunHarness()
const release = vi.fn()
const dispose = vi.fn()
expect(() =>
spawn({
platform: 'win32',
assignHostJob: () => true,
createJob: () => null,
createWindowsLaunch: () => ({
command: ['cmd.exe', '/d /c launch.cmd'],
clearCommand: ['cmd.exe', '/d /c clear.cmd'],
env: {},
windowsVerbatimArguments: true,
waitForSpawn: async () => {},
readShellProcessId: () => undefined,
release,
dispose
})
})
).toThrow('Windows Bun PTY job ownership is unavailable')
expect(release).not.toHaveBeenCalled()
expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGTERM')
expect(harness.terminal.close).toHaveBeenCalledOnce()
expect(dispose).toHaveBeenCalledOnce()
harness.resolveExit(1)
await harness.processHandle.exited
expect(dispose).toHaveBeenCalledTimes(2)
})
it('does not spawn a Windows PTY without host crash ownership', () => {
const harness = createBunHarness()
const createWindowsLaunch = vi.fn()
expect(() =>
spawn({
platform: 'win32',
assignHostJob: () => false,
createWindowsLaunch
})
).toThrow('Windows Bun PTY host crash ownership is unavailable')
expect(createWindowsLaunch).not.toHaveBeenCalled()
expect(harness.spawn).not.toHaveBeenCalled()
})
it('preserves a Windows PTY after a failed suspension and allows a retry', () => {
const harness = createBunHarness()
const job = {
listProcessIds: vi.fn(() => [4321]),
pause: vi.fn(() => true).mockReturnValueOnce(false),
resume: vi.fn(() => true),
terminate: vi.fn(() => 'terminated' as const),
close: vi.fn()
}
const proc = spawn({
platform: 'win32',
assignHostJob: () => true,
createJob: () => job,
createWindowsLaunch: () => ({
command: ['cmd.exe', '/d /c launch.cmd'],
clearCommand: ['cmd.exe', '/d /c clear.cmd'],
env: {},
windowsVerbatimArguments: true,
waitForSpawn: async () => {},
readShellProcessId: () => undefined,
release: vi.fn(),
dispose: vi.fn()
})
})
proc.pause()
proc.write('still usable')
proc.pause()
proc.resume()
expect(job.pause).toHaveBeenCalledTimes(2)
expect(job.resume).toHaveBeenCalledOnce()
expect(job.terminate).not.toHaveBeenCalled()
expect(harness.terminal.close).not.toHaveBeenCalled()
expect(harness.terminal.write).toHaveBeenCalledWith('still usable')
proc.kill()
proc.kill('SIGKILL')
proc.destroy()
expect(harness.processHandle.kill.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGTERM']])
expect(job.terminate).toHaveBeenCalledTimes(3)
expect(harness.terminal.close).toHaveBeenCalledOnce()
})
it('delivers Windows exit after cleanup failures', async () => {
const harness = createBunHarness()
const cleanupError = new Error('job close failed')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const proc = spawn({
platform: 'win32',
assignHostJob: () => true,
createJob: () => ({
listProcessIds: vi.fn(() => []),
pause: vi.fn(() => true),
resume: vi.fn(() => true),
terminate: vi.fn(() => 'terminated' as const),
close: vi.fn(() => {
throw cleanupError
})
}),
createWindowsLaunch: () => ({
command: ['cmd.exe', '/d /c launch.cmd'],
clearCommand: ['cmd.exe', '/d /c clear.cmd'],
env: {},
windowsVerbatimArguments: true,
waitForSpawn: async () => {},
readShellProcessId: () => undefined,
release: vi.fn(),
dispose: vi.fn()
})
})
const onExit = vi.fn()
proc.onExit(onExit)
harness.resolveExit(9)
await harness.processHandle.exited
await Promise.resolve()
expect(onExit).toHaveBeenCalledWith({ exitCode: 9 })
expect(warn).toHaveBeenCalledWith('[daemon/pty] PTY cleanup failed:', cleanupError)
})
it('terminates and closes Windows job state when gate release fails', () => {
const harness = createBunHarness()
const dispose = vi.fn()
const job = {
listProcessIds: vi.fn(() => [4321]),
pause: vi.fn(() => true),
resume: vi.fn(() => true),
terminate: vi.fn(() => 'terminated' as const),
close: vi.fn()
}
expect(() =>
spawn({
platform: 'win32',
assignHostJob: () => true,
createJob: () => job,
createWindowsLaunch: () => ({
command: ['cmd.exe', '/d /c launch.cmd'],
clearCommand: ['cmd.exe', '/d /c clear.cmd'],
env: {},
windowsVerbatimArguments: true,
waitForSpawn: async () => {},
readShellProcessId: () => undefined,
release() {
throw new Error('gate release failed')
},
dispose
})
})
).toThrow('gate release failed')
expect(job.terminate).toHaveBeenCalledOnce()
expect(job.close).toHaveBeenCalledOnce()
expect(harness.processHandle.kill).toHaveBeenCalledWith('SIGTERM')
expect(harness.terminal.close).toHaveBeenCalledOnce()
expect(dispose).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,2 @@
export { canUseBunPty } from './bun-pty-process-capabilities'
export { spawnBunPty } from './bun-pty-process-runtime'
@@ -1,6 +1,6 @@
import type * as pty from 'node-pty'
import { ptyShellProcessId } from '../../windows/windows-pty-job'
import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths'
import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process'
import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation'
import {
judgeCachedAgentJobEvidence,
@@ -24,6 +24,11 @@ import {
import { isShellProcess } from '../../../shared/shell-process-detection'
import { resolveFallbackForegroundProcess } from './foreground-fallback-process'
import { parsePtySessionId } from '../pty-session-id'
import {
ptyProcessNameIsSpawnFile,
createPtyForegroundResolver,
shouldCachePtyForeground
} from './spawn-file-foreground-process'
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
@@ -52,6 +57,8 @@ export function createPtyForegroundProcessTracker(args: {
isDead: () => boolean
}): PtyForegroundProcessTracker {
const proc = args.process
const staticName = ptyProcessNameIsSpawnFile(proc)
const resolveForeground = createPtyForegroundResolver(proc)
let lastOutputAt = 0
// `pid` anchors the identity to the row that proved it (null when ambiguous).
let cachedAgentForeground: CachedAgentForeground | null = null
@@ -69,16 +76,12 @@ export function createPtyForegroundProcessTracker(args: {
let foregroundRefreshInFlight = false
let lastForegroundRefreshStartedAt = 0
const getFallbackProcess = (): string | null =>
resolveFallbackForegroundProcess(proc.process, args.shellPath)
resolveFallbackForegroundProcess(staticName ? args.shellPath : proc.process, args.shellPath)
const getActiveStartupAgent = (
now = Date.now()
): { processName: string; expiresAt: number } | null => {
if (!startupAgentForeground) {
return null
}
if (now > startupAgentForeground.expiresAt) {
if (startupAgentForeground && now > startupAgentForeground.expiresAt) {
startupAgentForeground = null
return null
}
return startupAgentForeground
}
@@ -138,7 +141,7 @@ export function createPtyForegroundProcessTracker(args: {
}
}
const anchor = cachedAgentForeground
void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, {
void resolveForeground(proc.pid, fallbackProcess, {
contextPaths,
...(anchor?.pid != null
? { anchorProcessId: anchor.pid, anchorProcessName: anchor.processName }
@@ -148,13 +151,13 @@ export function createPtyForegroundProcessTracker(args: {
if (args.isDead() || !available) {
return
}
if (!processName || !recognizeAgentProcess(processName)) {
if (!shouldCachePtyForeground(processName, staticName)) {
if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) {
// Job, not console: needs no console attachment, so no fork (#10857).
const verdict = judgeCachedAgentJobEvidence({
jobProcessIds: readWindowsPtyJobProcessIds(proc),
jobSupported: isWindowsPtyJobReadable(),
shellPid: proc.pid,
shellPid: ptyShellProcessId(proc) ?? proc.pid,
anchorProcessId: cachedAgentForeground.pid,
identityAgeMs: Date.now() - cachedAgentForeground.refreshedAt
})
@@ -248,7 +251,8 @@ export function createPtyForegroundProcessTracker(args: {
if (
cachedAgentForeground &&
fallbackProcess !== null &&
(isAgentForegroundWrapperProcess(fallbackProcess) ||
(staticName ||
isAgentForegroundWrapperProcess(fallbackProcess) ||
inspectOuterWrapper ||
(process.platform === 'win32' && isShellProcess(fallbackProcess)))
) {
@@ -279,33 +283,30 @@ export function createPtyForegroundProcessTracker(args: {
) {
return fallbackProcess
}
const resolution = await resolveAgentForegroundProcessWithAvailability(
proc.pid,
fallbackProcess,
{
contextPaths,
fresh: true,
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid)
}
: {})
}
)
const resolution = await resolveForeground(proc.pid, fallbackProcess, {
contextPaths,
fresh: true,
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid)
}
: {})
})
if (args.isDead() || !resolution.available) {
return null
}
const recognized = recognizeAgentProcess(resolution.processName)
if (recognized) {
const processName =
recognizeAgentProcess(resolution.processName)?.processName ?? resolution.processName
if (shouldCachePtyForeground(processName, staticName)) {
cachedAgentForeground = {
processName: recognized.processName,
processName,
pid: resolution.processId ?? null,
refreshedAt: Date.now()
}
startupAgentForeground = null
return recognized.processName
return cachedAgentForeground.processName
}
cachedAgentForeground = null
startupAgentForeground = null
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest'
const { nodePtyFactory, wrapShellSpawnMock } = vi.hoisted(() => ({
nodePtyFactory: vi.fn(() => ({ spawn: vi.fn() })),
wrapShellSpawnMock: vi.fn((file: string, args: string[]) => ({ file, args }))
}))
vi.mock('node-pty', nodePtyFactory)
vi.mock('../../providers/macos-tcc-login-shell', () => ({
hostReportsChildExitStatus: (file: string) => file !== '/usr/bin/login',
wrapShellSpawnForMacosTccAttribution: wrapShellSpawnMock
}))
import { spawnNativeDaemonPty } from './native-pty-spawn'
describe('native PTY runtime selection', () => {
it('spawns with Bun.Terminal without loading node-pty', async () => {
const dispose = vi.fn()
const spawnBunPty = vi.fn(() => ({
pid: 9876,
cols: 80,
rows: 24,
process: '/bin/zsh',
handleFlowControl: false,
onData: vi.fn(() => ({ dispose })),
onExit: vi.fn(() => ({ dispose })),
write: vi.fn(),
resize: vi.fn(),
clear: vi.fn(),
kill: vi.fn(),
destroy: vi.fn(),
pause: vi.fn(),
resume: vi.fn()
}))
const result = await spawnNativeDaemonPty(
{
shellPath: '/bin/zsh',
shellArgs: ['-l'],
spawnCwd: '/tmp',
env: { TERM: 'xterm-256color' },
cols: 80,
rows: 24,
windowsFallbackAttempts: []
},
{ canUseBunPty: () => true, spawnBunPty }
)
expect(result.process.pid).toBe(9876)
expect(spawnBunPty).toHaveBeenCalledOnce()
expect(nodePtyFactory).not.toHaveBeenCalled()
})
it('applies the macOS login wrapper before selecting the Bun PTY runtime', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const spawnBunPty = vi.fn(() => ({
pid: 9877,
cols: 80,
rows: 24,
process: '/usr/bin/login',
handleFlowControl: false,
onData: vi.fn(() => ({ dispose: vi.fn() })),
onExit: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn(),
resize: vi.fn(),
clear: vi.fn(),
kill: vi.fn(),
destroy: vi.fn(),
pause: vi.fn(),
resume: vi.fn()
}))
const onMacosTccSpawnStrategy = vi.fn()
wrapShellSpawnMock.mockReturnValueOnce({
file: '/usr/bin/login',
args: ['-flpq', 'tester', '/bin/zsh', '-l']
})
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
try {
const result = await spawnNativeDaemonPty(
{
shellPath: '/bin/zsh',
shellArgs: ['-l'],
spawnCwd: '/tmp',
env: { TERM: 'xterm-256color' },
cols: 80,
rows: 24,
windowsFallbackAttempts: [],
onMacosTccSpawnStrategy
},
{ canUseBunPty: () => true, spawnBunPty }
)
expect(result.process.pid).toBe(9877)
expect(spawnBunPty).toHaveBeenCalledWith(
expect.objectContaining({
file: '/usr/bin/login',
args: ['-flpq', 'tester', '/bin/zsh', '-l']
})
)
expect(result.reportsChildExitStatus).toBe(false)
expect(onMacosTccSpawnStrategy).toHaveBeenCalledWith('wrapped')
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
})
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnNativeDaemonPty } from './native-pty-spawn'
import { WindowsBunPtySpawnUnconfirmedError } from './windows-bun-pty-spawn-receipt'
const attempts = ['pwsh.exe', 'powershell.exe', 'cmd.exe'].map((shellPath) => ({
shellPath,
shellArgs: [shellPath === 'cmd.exe' ? '/K' : '-NoExit'],
effectiveCwd: 'C:\\work',
validationCwd: 'C:\\work',
startupCommandDeliveredInShellArgs: true
}))
const args = {
shellPath: attempts[0]!.shellPath,
shellArgs: attempts[0]!.shellArgs,
spawnCwd: 'C:\\work',
env: {},
cols: 80,
rows: 24,
windowsFallbackAttempts: attempts
}
function createProcess(waitForSpawn: () => Promise<void>) {
return {
pid: 9876,
cols: 80,
rows: 24,
process: 'gate',
handleFlowControl: false,
onData: vi.fn(() => ({ dispose: vi.fn() })),
onExit: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn(),
resize: vi.fn(),
clear: vi.fn(),
kill: vi.fn(),
destroy: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
waitForSpawn
}
}
describe('Windows Bun shell fallback after gated spawn', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
Object.defineProperty(process, 'platform', platform)
vi.restoreAllMocks()
})
it('walks both fallback shells when gate wrappers start but their actual shells fail', async () => {
const spawnBunPty = vi.fn(({ file }: { file: string }) =>
createProcess(async () => {
await Promise.resolve()
if (file !== 'cmd.exe') {
throw new Error(`spawn ${file} EACCES`)
}
})
)
const result = await spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty })
expect(spawnBunPty.mock.calls.map(([args]) => args.file)).toEqual([
'pwsh.exe',
'powershell.exe',
'cmd.exe'
])
expect(result.shellPath).toBe('cmd.exe')
expect(result.startupCommandDeliveredInShellArgs).toBe(true)
expect(spawnBunPty.mock.results[0]!.value.destroy).toHaveBeenCalledOnce()
expect(spawnBunPty.mock.results[1]!.value.destroy).toHaveBeenCalledOnce()
expect(spawnBunPty.mock.results[2]!.value.destroy).not.toHaveBeenCalled()
})
it('does not report a wrapper as a working shell before its actual spawn is confirmed', async () => {
let confirm!: () => void
const confirmation = new Promise<void>((resolve) => {
confirm = resolve
})
const finished = vi.fn()
const spawnBunPty = vi.fn(() => createProcess(() => confirmation))
const result = spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty }).then(
finished
)
await Promise.resolve()
expect(finished).not.toHaveBeenCalled()
confirm()
await result
expect(finished).toHaveBeenCalledOnce()
})
it('destroys an unconfirmed gate on cancellation without starting a fallback shell', async () => {
const controller = new AbortController()
const proc = createProcess(() => new Promise(() => {}))
const spawnBunPty = vi.fn(() => proc)
const result = spawnNativeDaemonPty(
{ ...args, signal: controller.signal },
{ canUseBunPty: () => true, spawnBunPty }
)
controller.abort(new Error('spawn canceled'))
await expect(result).rejects.toThrow('spawn canceled')
expect(proc.destroy).toHaveBeenCalledOnce()
expect(spawnBunPty).toHaveBeenCalledOnce()
})
it.each([0, 1])(
'stops at an ambiguous attempt %s to avoid running its startup command twice',
async (ambiguousIndex) => {
const spawnBunPty = vi.fn(({ file }: { file: string }) =>
createProcess(async () => {
if (file === attempts[ambiguousIndex]!.shellPath) {
throw new WindowsBunPtySpawnUnconfirmedError('missing receipt')
}
throw new Error('spawn ENOENT')
})
)
await expect(
spawnNativeDaemonPty(args, { canUseBunPty: () => true, spawnBunPty })
).rejects.toBeInstanceOf(WindowsBunPtySpawnUnconfirmedError)
expect(spawnBunPty).toHaveBeenCalledTimes(ambiguousIndex + 1)
expect(spawnBunPty.mock.results.at(-1)!.value.destroy).toHaveBeenCalledOnce()
}
)
})
@@ -1,4 +1,5 @@
import * as pty from 'node-pty'
import type * as pty from 'node-pty'
import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason'
import {
hostReportsChildExitStatus,
wrapShellSpawnForMacosTccAttribution
@@ -6,6 +7,12 @@ import {
import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain'
import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job'
import { canUseBunPty, spawnBunPty } from './bun-pty-process'
import { WindowsBunPtySpawnUnconfirmedError } from './windows-bun-pty-spawn-receipt'
async function loadNodePty(): Promise<typeof pty> {
return import('node-pty')
}
export type SpawnedDaemonPty = {
process: pty.IPty
shellPath: string
@@ -15,25 +22,66 @@ export type SpawnedDaemonPty = {
reportsChildExitStatus: boolean
}
type NativePtyRuntime = {
canUseBunPty: typeof canUseBunPty
spawnBunPty: typeof spawnBunPty
}
/** Walks the Windows PowerShell -> cmd.exe fallback chain when ConPTY rejects the primary shell. */
export function spawnNativeDaemonPty(args: {
shellPath: string
shellArgs: string[]
spawnCwd: string
env: Record<string, string>
cols: number
rows: number
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void
}): SpawnedDaemonPty {
export async function spawnNativeDaemonPty(
args: {
shellPath: string
shellArgs: string[]
spawnCwd: string
env: Record<string, string>
cols: number
rows: number
windowsFallbackAttempts: WindowsShellSpawnAttempt[]
signal?: AbortSignal
onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void
},
runtime: NativePtyRuntime = { canUseBunPty, spawnBunPty }
): Promise<SpawnedDaemonPty> {
let reportsChildExitStatus = true
const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => {
const spawnAt = async (
shellPath: string,
shellArgs: string[],
cwd: string
): Promise<pty.IPty> => {
args.signal?.throwIfAborted()
const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env)
reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file)
if (runtime.canUseBunPty()) {
const proc = runtime.spawnBunPty({
file: wrapped.file,
args: wrapped.args,
cwd,
env: args.env,
cols: args.cols,
rows: args.rows
})
try {
if (proc.waitForSpawn) {
await waitForPromiseWithSignal(proc.waitForSpawn(), args.signal)
}
args.signal?.throwIfAborted()
} catch (error) {
try {
proc.destroy()
} catch (cleanupError) {
console.warn('[daemon/pty] Failed shell launch cleanup failed:', cleanupError)
}
throw error
}
args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped')
return proc
}
const nodePty = await loadNodePty()
// Why: children inherit job membership, so the host job must exist before the first Windows PTY.
if (process.platform === 'win32') {
assignHostProcessToKillOnCloseJob()
}
const proc = pty.spawn(wrapped.file, wrapped.args, {
const proc = nodePty.spawn(wrapped.file, wrapped.args, {
name: args.env.TERM ?? 'xterm-256color',
cols: args.cols,
rows: args.rows,
@@ -48,7 +96,7 @@ export function spawnNativeDaemonPty(args: {
}
try {
const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd)
const process_ = await spawnAt(args.shellPath, args.shellArgs, args.spawnCwd)
return {
process: process_,
shellPath: args.shellPath,
@@ -56,12 +104,13 @@ export function spawnNativeDaemonPty(args: {
reportsChildExitStatus
}
} catch (primaryErr) {
if (process.platform !== 'win32') {
args.signal?.throwIfAborted()
if (process.platform !== 'win32' || primaryErr instanceof WindowsBunPtySpawnUnconfirmedError) {
throw primaryErr
}
for (const attempt of args.windowsFallbackAttempts.slice(1)) {
try {
const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd)
const process = await spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd)
const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr)
console.warn(
`[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"`
@@ -73,7 +122,11 @@ export function spawnNativeDaemonPty(args: {
startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs,
reportsChildExitStatus
}
} catch {
} catch (error) {
args.signal?.throwIfAborted()
if (error instanceof WindowsBunPtySpawnUnconfirmedError) {
throw error
}
// This fallback shell also failed -- try the next link in the chain.
}
}
@@ -1,4 +1,5 @@
import type * as pty from 'node-pty'
import { ptyShellProcessId } from '../../windows/windows-pty-job'
import { confirmShellForegroundProcess } from '../../providers/agent-foreground-process'
import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership'
@@ -14,7 +15,7 @@ export async function confirmPtyShellForeground(args: {
return false
}
const confirmed = await confirmShellForegroundProcess(
args.process.pid,
ptyShellProcessId(args.process),
args.shellPath,
process.platform === 'win32'
? { readWindowsPtyJobProcessIds: () => readWindowsPtyJobProcessIds(args.process) }
@@ -0,0 +1,50 @@
import type { IPty } from 'node-pty'
import {
getCommandTokenPathBasename,
getFirstCommandToken
} from '../../../shared/command-token-scanner'
import {
collectDescendantsFromIndex,
getProcessTableIndex
} from '../../../shared/process-table-index'
import type { ProcessTableRow } from '../../../shared/process-table-snapshot'
import type { PtyChildProcessVerdict } from '../../../shared/terminal-process-inspection'
import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership'
function executableName(command: string): string {
return getCommandTokenPathBasename(getFirstCommandToken(command)).replace(/^-/, '')
}
export function inspectSpawnFileChildProcessesFromRows(
rows: readonly ProcessTableRow[],
rootPid: number,
shellName: string | null
): PtyChildProcessVerdict {
const index = getProcessTableIndex(rows)
const root = index.byPid.get(rootPid)
if (!root || !shellName || !root.tty || root.tty === '?') {
return 'unverifiable'
}
const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, rootPid)]
const shell = tree
.filter((row) => executableName(row.command) === shellName && !row.stat.includes('Z'))
.sort((left, right) => left.depth - right.depth)[0]
if (!shell) {
return 'unverifiable'
}
// The macOS login wrapper and its spawned shell are launch plumbing, not user jobs.
const launchChain = new Set([rootPid])
let ancestor: ProcessTableRow | undefined = shell
while (ancestor && !launchChain.has(ancestor.pid)) {
launchChain.add(ancestor.pid)
ancestor = index.byPid.get(ancestor.ppid)
}
return tree.some((row) => !launchChain.has(row.pid) && !row.stat.includes('Z'))
? 'children'
: 'no-children'
}
export function inspectSpawnFileWindowsChildProcesses(proc: IPty): PtyChildProcessVerdict {
const members = readWindowsPtyJobProcessIds(proc)
return members === null ? 'unverifiable' : members.size > 1 ? 'children' : 'no-children'
}
@@ -0,0 +1,125 @@
import type { IPty } from 'node-pty'
import { isShellProcess } from '../../../shared/shell-process-detection'
import {
getCommandTokenPathBasename,
getFirstCommandToken
} from '../../../shared/command-token-scanner'
import {
collectDescendantsFromIndex,
getProcessTableIndex
} from '../../../shared/process-table-index'
import type { ProcessTableRow } from '../../../shared/process-table-snapshot'
import {
getFreshProcessTableSnapshot,
getProcessTableSnapshot
} from '../../../shared/process-table-snapshot-reader'
import { selectForegroundProcessCandidate } from '../../../shared/foreground-process-selection'
import { resolveOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent'
import { recognizeAgentProcess } from '../../../shared/agent-process-recognition'
import {
resolveAgentForegroundProcessWithAvailability,
type AgentForegroundProcessResolution,
type AgentForegroundResolutionOptions
} from '../../providers/agent-foreground-process'
import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership'
import { ptyShellProcessId } from '../../windows/windows-pty-job'
import {
readWindowsProcessIdentityTable,
readWindowsProcessIdentityTableFresh
} from '../../windows/windows-process-table'
export function ptyProcessNameIsSpawnFile(proc: IPty): boolean {
return 'processNameIsSpawnFile' in proc && proc.processNameIsSpawnFile === true
}
export function createPtyForegroundResolver(
proc: IPty
): typeof resolveAgentForegroundProcessWithAvailability {
return ptyProcessNameIsSpawnFile(proc)
? (_pid, fallback, options) => resolveSpawnFileForegroundProcess(proc, fallback, options)
: resolveAgentForegroundProcessWithAvailability
}
export function shouldCachePtyForeground(name: string | null, staticName: boolean): name is string {
return (
name !== null && (recognizeAgentProcess(name) !== null || (staticName && !isShellProcess(name)))
)
}
export function resolveSpawnFileForegroundFromRows(
rows: readonly ProcessTableRow[],
rootPid: number
): AgentForegroundProcessResolution {
const index = getProcessTableIndex(rows)
const root = index.byPid.get(rootPid)
if (!root || !root.tpgid || root.tpgid < 0 || !root.tty || root.tty === '?') {
return { available: false, processName: null }
}
const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, rootPid)]
const candidates = tree
.filter((row) => row.pgid === root.tpgid && row.tty === root.tty && !/[TZ]/.test(row.stat))
.sort((left, right) => right.depth - left.depth)
const foreground = candidates[0]
if (!foreground) {
return { available: false, processName: null }
}
const name = getCommandTokenPathBasename(getFirstCommandToken(foreground.command)).replace(
/^-/,
''
)
const selected = selectForegroundProcessCandidate(candidates, tree)
return {
available: name.length > 0,
processName: selected
? resolveOuterWrapperForegroundProcess(selected.recognized, selected.candidate, tree)
: recognizeAgentProcess(name)
? null
: name || null
}
}
export async function resolveSpawnFileForegroundProcess(
proc: IPty,
fallbackProcess: string | null,
options: AgentForegroundResolutionOptions = {}
): Promise<AgentForegroundProcessResolution> {
try {
if (process.platform !== 'win32') {
const rows = options.fresh
? await getFreshProcessTableSnapshot()
: await getProcessTableSnapshot()
return resolveSpawnFileForegroundFromRows(rows, proc.pid)
}
const resolution = await resolveAgentForegroundProcessWithAvailability(
proc.pid,
fallbackProcess,
options
)
if (!resolution.available || recognizeAgentProcess(resolution.processName)) {
return resolution
}
const members = readWindowsPtyJobProcessIds(proc)
const shellPid = ptyShellProcessId(proc)
if (!members || shellPid === undefined) {
return { available: false, processName: null }
}
if (members.size === 1) {
return { available: true, processName: fallbackProcess }
}
const rows = options.fresh
? await readWindowsProcessIdentityTableFresh()
: await readWindowsProcessIdentityTable()
const candidate = collectDescendantsFromIndex(getProcessTableIndex(rows), shellPid)
.filter((row) => members.has(row.pid))
.sort((left, right) => right.depth - left.depth)[0]
// Only the agent resolver can grant an identity after ambiguity and console checks.
if (candidate && recognizeAgentProcess(candidate.name)) {
return resolution
}
return candidate
? { available: true, processName: candidate.name, processId: candidate.pid }
: { available: false, processName: null }
} catch {
return { available: false, processName: null }
}
}
@@ -0,0 +1,134 @@
import type { IPty } from 'node-pty'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProcessTableRow } from '../../../shared/process-table-snapshot'
import { __setWindowsProcessTreeLoaderForTests } from '../../windows/windows-process-table'
import {
resolveSpawnFileForegroundFromRows,
resolveSpawnFileForegroundProcess
} from './spawn-file-foreground-process'
const { members } = vi.hoisted(() => ({ members: vi.fn() }))
vi.mock('../../providers/windows-pty-job-membership', () => ({
readWindowsPtyJobProcessIds: members
}))
const proc: IPty = {
pid: 100,
cols: 80,
rows: 24,
handleFlowControl: false,
process: 'powershell.exe',
onData: () => ({ dispose() {} }),
onExit: () => ({ dispose() {} }),
write() {},
resize() {},
clear() {},
kill() {},
pause() {},
resume() {}
}
const root: ProcessTableRow = {
pid: 100,
ppid: 1,
pgid: 100,
tpgid: 101,
tty: 'pts/test',
stat: 'S',
startTime: 'shell-start',
command: '/bin/zsh'
}
beforeEach(() => members.mockReturnValue(new Set([100, 101, 102])))
afterEach(() => {
__setWindowsProcessTreeLoaderForTests()
vi.restoreAllMocks()
vi.clearAllMocks()
})
describe('POSIX static-name agent selection', () => {
it('does not pick a rejected sibling agent by its executable basename', () => {
expect(
resolveSpawnFileForegroundFromRows(
[
root,
{ ...root, pid: 101, ppid: 100, pgid: 101, stat: 'S+', command: 'claude' },
{ ...root, pid: 102, ppid: 100, pgid: 101, stat: 'S+', command: 'codex' }
],
100
)
).toEqual({ available: true, processName: null })
})
it('does not promote a headless one-shot agent from its executable basename', () => {
expect(
resolveSpawnFileForegroundFromRows(
[
root,
{ ...root, pid: 101, ppid: 100, pgid: 101, stat: 'S+', command: 'claude -p "review"' }
],
100
)
).toEqual({ available: true, processName: null })
})
it.each(['vim', 'npm', 'sleep'])('retains the ordinary %s name', (command) => {
expect(
resolveSpawnFileForegroundFromRows(
[root, { ...root, pid: 101, ppid: 100, pgid: 101, stat: 'S+', command }],
100
)
).toEqual({ available: true, processName: command })
})
})
describe('Windows static-name agent selection', () => {
function installRows(names: string[]): void {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const rows = [
{ pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' },
{ pid: 100, ppid: 1, name: 'powershell.exe', commandLine: 'powershell.exe' },
...names.map((name, index) => ({ pid: 101 + index, ppid: 100, name, commandLine: name }))
]
__setWindowsProcessTreeLoaderForTests(() => ({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 },
getAllProcesses: (callback) => callback(rows)
}))
}
it('does not re-admit a detached agent through owned job membership', async () => {
installRows(['droid.exe'])
const consoleMembers = vi.fn(async () => new Set([100, 999]))
expect(
await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', {
fresh: true,
readWindowsConsoleAttachedProcessIds: consoleMembers
})
).toEqual({ available: true, processName: 'powershell.exe' })
expect(consoleMembers).toHaveBeenCalledOnce()
})
it('does not choose a rejected sibling agent from the identity table', async () => {
installRows(['claude.exe', 'codex.exe'])
expect(
await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', { fresh: true })
).toEqual({ available: true, processName: 'powershell.exe' })
})
it('retains a positively authorized agent', async () => {
installRows(['droid.exe'])
expect(
await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', {
fresh: true,
readWindowsConsoleAttachedProcessIds: async () => new Set([100, 101])
})
).toEqual({ available: true, processName: 'droid', processId: 101 })
})
it('retains an ordinary executable from the identity table', async () => {
installRows(['vim.exe'])
expect(
await resolveSpawnFileForegroundProcess(proc, 'powershell.exe', { fresh: true })
).toEqual({ available: true, processName: 'vim.exe', processId: 101 })
})
})
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const fixture = vi.hoisted((): { shellPid?: number } => ({}))
vi.mock('./bun-pty-process', () => ({
canUseBunPty: () => true,
spawnBunPty: () => ({
pid: 41,
get shellProcessId() {
return fixture.shellPid
},
onExit(callback: (event: { exitCode: number }) => void) {
queueMicrotask(() => callback({ exitCode: 0 }))
return { dispose() {} }
}
})
}))
import { runPtySpawnHealthProbe } from './spawn-preflight'
beforeEach(() =>
vi.stubGlobal(
'process',
Object.create(process, {
platform: { value: 'win32' }
})
)
)
afterEach(() => vi.unstubAllGlobals())
it.each([undefined, 41, 0])(
'refuses successful gate exit without shell identity %s',
async (pid) => {
fixture.shellPid = pid
await expect(runPtySpawnHealthProbe()).rejects.toThrow('could not identify the Windows shell')
}
)
it('accepts successful exit with the separate original shell identity', async () => {
fixture.shellPid = 42
await expect(runPtySpawnHealthProbe()).resolves.toBeUndefined()
})
@@ -1,6 +1,7 @@
import * as pty from 'node-pty'
import type * as pty from 'node-pty'
import { statSync } from 'node:fs'
import { release } from 'node:os'
import { getCmdExePath } from '../../../shared/windows-batch-spawn'
import {
ensureNodePtySpawnHelperExecutable,
getNodePtySpawnHelperCandidates,
@@ -10,9 +11,14 @@ import {
import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd'
import { TerminalAttachCanceledError } from '../daemon-errors'
import { DaemonProtocolError } from '../types'
import { canUseBunPty, spawnBunPty } from './bun-pty-process'
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000
async function loadNodePty(): Promise<typeof pty> {
return import('node-pty')
}
function daemonEnvironmentDiagSuffix(): string {
const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev'
const systemVersion =
@@ -79,7 +85,7 @@ function preflightDaemonCwd(): void {
}
function preflightMacNodePtySpawnEnvironment(): void {
if (process.platform !== 'darwin') {
if (process.platform !== 'darwin' || canUseBunPty()) {
return
}
let candidates: string[]
@@ -119,7 +125,9 @@ export async function preflightPtySpawn(args: {
sessionId: string
signal?: AbortSignal
}): Promise<void> {
ensureNodePtySpawnHelperExecutable()
if (!canUseBunPty()) {
ensureNodePtySpawnHelperExecutable()
}
preflightUnixPtySpawnEnvironment()
try {
if (process.platform === 'win32') {
@@ -154,21 +162,34 @@ export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: s
return formatted
}
export function runPtySpawnHealthProbe(): Promise<void> {
export async function runPtySpawnHealthProbe(): Promise<void> {
const requiresShellIdentity = process.platform === 'win32' && canUseBunPty()
const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH)
? process.env.ORCA_USER_DATA_PATH
: resolveSafePtyDefaultCwd()
const command =
process.platform === 'win32'
? { file: getCmdExePath(), args: ['/d', '/c', 'exit', '0'] }
: { file: '/bin/sh', args: ['-c', 'exit 0'] }
let proc: pty.IPty
try {
proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
name: 'xterm-256color',
cols: 2,
rows: 1,
cwd,
env: { ...process.env, TERM: 'xterm-256color' }
})
const env: Record<string, string> = { TERM: 'xterm-256color' }
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
env[key] = value
}
}
proc = canUseBunPty()
? spawnBunPty({ ...command, cols: 2, rows: 1, cwd, env })
: (await loadNodePty()).spawn(command.file, command.args, {
name: 'xterm-256color',
cols: 2,
rows: 1,
cwd,
env
})
} catch (err) {
throw formatPtySpawnError(err, '/bin/sh', cwd)
throw formatPtySpawnError(err, command.file, cwd)
}
return new Promise<void>((resolve, reject) => {
@@ -201,7 +222,18 @@ export function runPtySpawnHealthProbe(): Promise<void> {
}, PTY_SPAWN_HEALTH_TIMEOUT_MS)
exitDisposable = proc.onExit(({ exitCode }) => {
if (exitCode === 0) {
finish()
const shellPid = 'shellProcessId' in proc ? proc.shellProcessId : undefined
if (
requiresShellIdentity &&
(typeof shellPid !== 'number' ||
!Number.isSafeInteger(shellPid) ||
shellPid <= 0 ||
shellPid === proc.pid)
) {
finish(new Error('PTY spawn health check could not identify the Windows shell'))
} else {
finish()
}
} else {
finish(new Error(`PTY spawn health check exited with code ${exitCode}`))
}
@@ -210,10 +242,10 @@ export function runPtySpawnHealthProbe(): Promise<void> {
}
export function preflightPtySpawnHealth(): boolean {
if (process.platform === 'win32') {
if (process.platform === 'win32' && !canUseBunPty()) {
return false
}
if (process.platform === 'darwin') {
if (!canUseBunPty()) {
ensureNodePtySpawnHelperExecutable()
}
preflightUnixPtySpawnEnvironment()
@@ -9,8 +9,13 @@ import { isValidPtySize } from '../daemon-pty-size'
import type { SubprocessHandle } from '../session-subprocess-handle'
import { createPtyForegroundProcessTracker } from './foreground-process-tracker'
import { PtyPreListenerEvents } from './pre-listener-events'
import { ptyProcessNameIsSpawnFile } from './spawn-file-foreground-process'
import { inspectSpawnFileWindowsChildProcesses } from './spawn-file-child-processes'
type DisposableNativePty = pty.IPty & { destroy?: () => void }
type DisposableNativePty = pty.IPty & {
destroy?: () => void
signalProcess?: (signal: string) => void
}
export function createDaemonPtySubprocessHandle(args: {
process: pty.IPty
@@ -26,7 +31,7 @@ export function createDaemonPtySubprocessHandle(args: {
const reportsChildExitStatus = args.reportsChildExitStatus
const proc = args.process
// node-pty exposes destroy at runtime but omits it from IPty.
const nativeProc = proc as DisposableNativePty
const nativeProc: DisposableNativePty = proc
const events = new PtyPreListenerEvents()
let dead = false
// I/O failure is not exit evidence; keep termination and producer flow control available.
@@ -64,6 +69,10 @@ export function createDaemonPtySubprocessHandle(args: {
const slavePath = readPtySlavePath(proc)
return {
pid: proc.pid,
processNameIsSpawnFile: ptyProcessNameIsSpawnFile(proc),
...(process.platform === 'win32'
? { inspectChildProcesses: () => inspectSpawnFileWindowsChildProcesses(proc) }
: {}),
shellPath: args.shellPath,
shellCwd: args.spawnCwd,
shellPathEnv: args.env.PATH,
@@ -166,6 +175,14 @@ export function createDaemonPtySubprocessHandle(args: {
if (dead) {
return
}
if (nativeProc.signalProcess) {
try {
nativeProc.signalProcess(sig)
} catch {
/* The process may have exited. */
}
return
}
const signalRootPid = (): void => {
try {
process.kill(proc.pid, sig)
@@ -0,0 +1,21 @@
import { unlinkSync } from 'node:fs'
import { readWindowsBunPtyGateRequest, runWindowsBunPtyGate } from './windows-bun-pty-gate'
async function main(): Promise<void> {
const requestPath = process.argv[2]
if (!requestPath) {
throw new Error('Windows PTY gate request path is required')
}
const request = readWindowsBunPtyGateRequest(requestPath)
// Arguments can contain agent prompts; do not retain them for the shell's lifetime.
unlinkSync(requestPath)
process.exitCode = await runWindowsBunPtyGate(request)
}
void main().catch((error: unknown) => {
console.error(
'[pty] Windows job gate failed:',
error instanceof Error ? error.message : String(error)
)
process.exitCode = 1
})
@@ -0,0 +1,89 @@
import { build } from 'esbuild'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { runProcess, runProcessSync } from '../../../shared/child-process/run-process'
import { orcadBunRuntimeFilename } from '../../../shared/orcad-artifacts'
import { ORCAD_BUN_VERSION } from '../../../shared/orcad-bun-runtime'
import { createWindowsBunPtyLaunch } from './windows-bun-pty-launch'
const runtimePath =
process.env.BUN_EXECUTABLE ??
resolve(__dirname, '../../../../out/orcad', orcadBunRuntimeFilename(process.platform))
const available = existsSync(runtimePath)
describe.skipIf(!available)('bundled Windows job gate under Bun', () => {
it('executes the worker with real Bun flags and preserves long executable argv', async () => {
expect(runProcessSync({ program: runtimePath, args: ['--version'] }).stdout.trim()).toBe(
ORCAD_BUN_VERSION
)
const directory = mkdtempSync(join(tmpdir(), 'orca-gate-contract-'))
const workerPath = join(directory, 'windows-bun-pty-gate-entry.js')
try {
await build({
entryPoints: [join(__dirname, 'windows-bun-pty-gate-entry.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: workerPath,
logLevel: 'silent'
})
const argv = ['x'.repeat(16000), 'a b', 'quote"', '%value%&!', '状態', '']
const env = Object.fromEntries(
Object.entries(process.env).filter(
(entry): entry is [string, string] => entry[1] !== undefined
)
)
const launch = createWindowsBunPtyLaunch(
{
file: runtimePath,
args: ['-e', 'console.log(JSON.stringify(process.argv.slice(1)))', ...argv],
cwd: directory,
env
},
{ runtimePath, workerPath }
)
try {
launch.release()
const result = await runProcess({
program: launch.command[0]!,
args: launch.command.slice(1),
cwd: directory,
env: launch.env,
timeoutMs: 10_000
})
expect(result.timedOut).toBe(false)
expect(result.code, result.stderr).toBe(0)
expect(JSON.parse(result.stdout)).toEqual(argv)
await expect(launch.waitForSpawn(Promise.resolve(result.code!))).resolves.toBeUndefined()
expect(existsSync(launch.command.at(-1)!)).toBe(false)
} finally {
launch.dispose()
}
const failedLaunch = createWindowsBunPtyLaunch(
{ file: join(directory, 'missing-shell.exe'), args: [], cwd: directory, env },
{ runtimePath, workerPath }
)
try {
failedLaunch.release()
const result = await runProcess({
program: failedLaunch.command[0]!,
args: failedLaunch.command.slice(1),
cwd: directory,
env: failedLaunch.env,
timeoutMs: 10_000
})
expect(result.timedOut).toBe(false)
expect(result.code).toBe(1)
await expect(failedLaunch.waitForSpawn(Promise.resolve(1))).rejects.toThrow(
/missing-shell|ENOENT|not found/
)
} finally {
failedLaunch.dispose()
}
} finally {
rmSync(directory, { recursive: true, force: true })
}
}, 15_000)
})
@@ -0,0 +1,150 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import type { spawnProcess } from '../../../shared/child-process/run-process'
import { runWindowsBunPtyGate, type WindowsBunPtyGateRequest } from './windows-bun-pty-gate'
const request: WindowsBunPtyGateRequest = {
file: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
args: ['-NoLogo', '-NoExit', '-Command', 'A'.repeat(16000)],
cwd: 'C:\\work',
gatePath: 'gate',
shellPidPath: 'shell.pid',
runtimeOptions: {}
}
describe('Windows Bun PTY job gate worker', () => {
it.each(['exit', 'error'] as const)(
'ignores Windows console interrupts only while supervising a child (%s)',
async (outcome) => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const previousListeners = process.listeners('SIGINT')
const child = new EventEmitter()
try {
const result = runWindowsBunPtyGate(request, {
waitForGate: async () => {},
reportSpawnError: vi.fn(),
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes only the child events the gate consumes.
spawn: () => child as ReturnType<typeof spawnProcess>
})
expect(process.listeners('SIGINT')).toHaveLength(previousListeners.length + 1)
await Promise.resolve()
if (outcome === 'exit') {
child.emit('exit', 17)
await expect(result).resolves.toBe(17)
} else {
child.emit('error', new Error('spawn denied'))
await expect(result).rejects.toThrow('spawn denied')
}
expect(process.listeners('SIGINT')).toEqual(previousListeners)
} finally {
platform.mockRestore()
}
}
)
it('does not spawn before assignment and propagates the child exit code', async () => {
let release!: () => void
const waitForGate = vi.fn(
() =>
new Promise<void>((resolve) => {
release = resolve
})
)
const child = Object.assign(new EventEmitter(), { pid: 1234 })
const reportShellPid = vi.fn()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes only the child events and pid the gate consumes.
const spawn = vi.fn(() => child as ReturnType<typeof spawnProcess>)
const result = runWindowsBunPtyGate(request, {
waitForGate,
spawn,
reportShellPid,
env: { TERM: 'xterm-256color' }
})
await Promise.resolve()
expect(spawn).not.toHaveBeenCalled()
release()
await Promise.resolve()
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
program: request.file,
args: request.args,
cwd: request.cwd,
stdio: 'inherit'
})
)
expect(reportShellPid).not.toHaveBeenCalled()
child.emit('spawn')
expect(reportShellPid).toHaveBeenCalledWith(1234)
child.emit('exit', 17)
await expect(result).resolves.toBe(17)
})
it('never starts a child after a failed job gate', async () => {
const spawn = vi.fn()
await expect(
runWindowsBunPtyGate(request, {
waitForGate: async () => {
throw new Error('gate missing')
},
reportSpawnError: vi.fn(),
spawn
})
).rejects.toThrow('gate missing')
expect(spawn).not.toHaveBeenCalled()
})
it('keeps supervising the shell when its identity receipt cannot be published', async () => {
const child = Object.assign(new EventEmitter(), { pid: 1234 })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const reportSpawnError = vi.fn()
const result = runWindowsBunPtyGate(request, {
waitForGate: async () => {},
reportSpawnError,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture exposes only the child events and pid consumed by the gate.
spawn: () => child as ReturnType<typeof spawnProcess>,
reportShellPid() {
throw new Error('receipt denied')
}
})
await Promise.resolve()
child.emit('spawn')
expect(warn).toHaveBeenCalledOnce()
child.emit('exit', 17)
await expect(result).resolves.toBe(17)
expect(reportSpawnError).not.toHaveBeenCalled()
warn.mockRestore()
})
it('reports a child spawn error instead of a successful wrapper exit', async () => {
const child = new EventEmitter()
const reportSpawnError = vi.fn()
const result = runWindowsBunPtyGate(request, {
waitForGate: async () => {},
reportSpawnError,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture exposes the error/exit events the gate consumes.
spawn: () => child as ReturnType<typeof spawnProcess>
})
await Promise.resolve()
child.emit('error', new Error('spawn denied'))
await expect(result).rejects.toThrow('spawn denied')
expect(reportSpawnError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'spawn denied' })
)
})
it('reports synchronous native spawn rejection without requiring a child event', async () => {
const reportSpawnError = vi.fn()
await expect(
runWindowsBunPtyGate(request, {
waitForGate: async () => {},
spawn: () => {
throw new Error('invalid executable')
},
reportSpawnError
})
).rejects.toThrow('invalid executable')
expect(reportSpawnError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'invalid executable' })
)
})
})
@@ -0,0 +1,172 @@
import { readFileSync, statSync, unlinkSync } from 'node:fs'
import { setTimeout as delay } from 'node:timers/promises'
import { win32 } from 'node:path'
import { spawnProcess, type ProcessSpec } from '../../../shared/child-process/run-process'
import {
publishWindowsBunPtyShellPid,
publishWindowsBunPtySpawnError
} from './windows-bun-pty-spawn-receipt'
export const WINDOWS_BUN_PTY_GATE_ENV = 'ORCA_BUN_PTY_JOB_GATE'
export const WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS = ['NODE_OPTIONS', 'BUN_OPTIONS'] as const
export type WindowsBunPtyGateRequest = {
file: string
args: string[]
cwd: string
gatePath: string
shellPidPath: string
runtimeOptions: Partial<Record<(typeof WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS)[number], string>>
}
export function readWindowsBunPtyGateRequest(path: string): WindowsBunPtyGateRequest {
if (statSync(path).size > 1024 * 1024) {
throw new Error('Windows PTY gate request exceeds its size limit')
}
let value: unknown
try {
value = JSON.parse(readFileSync(path, 'utf8'))
} catch {
throw new Error('Invalid Windows PTY gate request')
}
const request = value
if (
typeof request !== 'object' ||
request === null ||
!('file' in request) ||
typeof request.file !== 'string' ||
!request.file ||
!('args' in request) ||
!Array.isArray(request.args) ||
!request.args.every((arg): arg is string => typeof arg === 'string') ||
!('cwd' in request) ||
typeof request.cwd !== 'string' ||
!request.cwd ||
!('gatePath' in request) ||
typeof request.gatePath !== 'string' ||
!request.gatePath ||
!('shellPidPath' in request) ||
typeof request.shellPidPath !== 'string' ||
!request.shellPidPath ||
!('runtimeOptions' in request) ||
typeof request.runtimeOptions !== 'object' ||
request.runtimeOptions === null ||
Array.isArray(request.runtimeOptions)
) {
throw new Error('Invalid Windows PTY gate request')
}
const runtimeOptions: WindowsBunPtyGateRequest['runtimeOptions'] = {}
for (const [key, value] of Object.entries(request.runtimeOptions)) {
if ((key !== 'NODE_OPTIONS' && key !== 'BUN_OPTIONS') || typeof value !== 'string') {
throw new Error('Invalid Windows PTY gate request')
}
runtimeOptions[key] = value
}
return {
file: request.file,
args: request.args,
cwd: request.cwd,
gatePath: request.gatePath,
shellPidPath: request.shellPidPath,
runtimeOptions
}
}
export async function waitForWindowsBunPtyJobGate(gatePath: string): Promise<void> {
const deadline = Date.now() + 30_000
while (true) {
try {
unlinkSync(gatePath)
return
} catch (error) {
if (
typeof error !== 'object' ||
error === null ||
!('code' in error) ||
error.code !== 'ENOENT'
) {
throw error
}
}
if (Date.now() >= deadline) {
throw new Error('Windows PTY job assignment timed out')
}
await delay(5)
}
}
export function windowsBunPtyChildSpec(
request: WindowsBunPtyGateRequest,
inheritedEnv: NodeJS.ProcessEnv
): ProcessSpec {
const env: NodeJS.ProcessEnv = { ...inheritedEnv, ...request.runtimeOptions }
delete env[WINDOWS_BUN_PTY_GATE_ENV]
delete env.ORCA_BUN_PTY_CHILD_COMMAND
return {
program: request.file,
args: request.args,
cwd: request.cwd,
env,
stdio: 'inherit',
// cmd owns the command text following /K or /C; it must not receive CRT argv escaping.
...(win32.basename(request.file).toLowerCase() === 'cmd.exe'
? { windowsVerbatimArguments: true }
: {})
}
}
export async function runWindowsBunPtyGate(
request: WindowsBunPtyGateRequest,
deps: {
waitForGate?: (gatePath: string) => Promise<void>
spawn?: typeof spawnProcess
env?: NodeJS.ProcessEnv
reportShellPid?: (pid: number) => void
reportSpawnError?: (error: unknown) => void
} = {}
): Promise<number> {
let spawned = false
// Preserve supervision when Ctrl-C reaches the entire Windows console.
const ignoreInterrupt = (): void => {}
if (process.platform === 'win32') {
process.on('SIGINT', ignoreInterrupt)
}
try {
await (deps.waitForGate ?? waitForWindowsBunPtyJobGate)(request.gatePath)
return await new Promise<number>((resolve, reject) => {
const child = (deps.spawn ?? spawnProcess)(
windowsBunPtyChildSpec(request, deps.env ?? process.env)
)
child.once('spawn', () => {
spawned = true
if (child.pid !== undefined) {
const report =
deps.reportShellPid ??
((pid) => publishWindowsBunPtyShellPid(request.shellPidPath, pid))
try {
report(child.pid)
} catch (error) {
// Keep supervising the shell; absent identity must remain unverifiable.
console.warn('[pty] Failed to publish Windows shell identity:', error)
}
}
})
child.once('error', reject)
child.once('exit', (code) => resolve(code ?? 1))
})
} catch (error) {
if (!spawned) {
try {
const report =
deps.reportSpawnError ??
((error) => publishWindowsBunPtySpawnError(request.shellPidPath, error))
report(error)
} catch (receiptError) {
console.warn('[pty] Failed to publish Windows shell spawn error:', receiptError)
}
}
throw error
} finally {
process.off('SIGINT', ignoreInterrupt)
}
}
@@ -0,0 +1,158 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
__resetWindowsBunPtyJobForTests,
assignCurrentProcessToBunPtyHostJob,
createWindowsBunPtyJob,
type WindowsBunPtyJobNative
} from './windows-bun-pty-job'
function createNative(overrides: Partial<WindowsBunPtyJobNative> = {}): WindowsBunPtyJobNative {
return {
createJob: vi.fn(() => 7),
configureJob: vi.fn(() => true),
currentProcess: vi.fn(() => 99),
openProcess: vi.fn((_access, pid) => 1_000 + pid),
assignProcess: vi.fn(() => true),
isProcessInJob: vi.fn(() => true),
queryProcessIds: vi.fn(() => [11]),
suspendProcess: vi.fn(() => true),
resumeProcess: vi.fn(() => true),
terminateJob: vi.fn(() => true),
closeHandle: vi.fn(),
...overrides
}
}
afterEach(() => {
vi.restoreAllMocks()
__resetWindowsBunPtyJobForTests()
})
describe('Windows Bun PTY job ownership', () => {
it('assigns the daemon to one kill-on-close host job', () => {
const native = createNative()
expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(true)
expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(true)
expect(native.createJob).toHaveBeenCalledOnce()
expect(native.configureJob).toHaveBeenCalledWith(7, 0x2800)
expect(native.assignProcess).toHaveBeenCalledWith(7, 99)
})
it('closes a rejected host job and caches the unavailable result', () => {
const native = createNative({ assignProcess: vi.fn(() => false) })
expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(false)
expect(assignCurrentProcessToBunPtyHostJob(native)).toBe(false)
expect(native.createJob).toHaveBeenCalledOnce()
expect(native.closeHandle).toHaveBeenCalledWith(7)
})
it('assigns the gated PTY root before exposing the job', () => {
const native = createNative()
const job = createWindowsBunPtyJob(11, native)
expect(job).not.toBeNull()
expect(native.configureJob).toHaveBeenCalledWith(7, 0)
expect(native.openProcess).toHaveBeenCalledWith(0x1901, 11)
expect(native.assignProcess).toHaveBeenCalledWith(7, 1011)
expect(native.closeHandle).toHaveBeenCalledWith(1011)
})
it('suspends children that appear during the ownership fence and resumes exact handles', () => {
const queryProcessIds = vi
.fn<() => readonly number[] | null>()
.mockReturnValueOnce([11, 12])
.mockReturnValueOnce([11, 12, 13])
.mockReturnValueOnce([11, 12, 13])
.mockReturnValue([11, 12, 13])
const native = createNative({ queryProcessIds })
const job = createWindowsBunPtyJob(11, native)!
vi.mocked(native.closeHandle).mockClear()
expect(job.pause()).toBe(true)
expect(native.suspendProcess).toHaveBeenCalledWith(1011)
expect(native.suspendProcess).toHaveBeenCalledWith(1012)
expect(native.suspendProcess).toHaveBeenCalledWith(1013)
expect(job.resume()).toBe(true)
expect(native.resumeProcess).toHaveBeenCalledWith(1011)
expect(native.resumeProcess).toHaveBeenCalledWith(1012)
expect(native.resumeProcess).toHaveBeenCalledWith(1013)
expect(native.closeHandle).toHaveBeenCalledWith(1011)
expect(native.closeHandle).toHaveBeenCalledWith(1012)
expect(native.closeHandle).toHaveBeenCalledWith(1013)
})
it('never suspends a PID whose opened handle is outside the owned job', () => {
const native = createNative({
queryProcessIds: vi.fn(() => [11, 12]),
isProcessInJob: vi.fn((process) => process !== 1012)
})
const job = createWindowsBunPtyJob(11, native)!
expect(job.pause()).toBe(false)
expect(native.suspendProcess).toHaveBeenCalledWith(1011)
expect(native.suspendProcess).not.toHaveBeenCalledWith(1012)
expect(native.resumeProcess).toHaveBeenCalledWith(1011)
expect(native.closeHandle).toHaveBeenCalledWith(1012)
})
it('terminates a paused tree without resuming it first', () => {
const native = createNative({ queryProcessIds: vi.fn(() => [11]) })
const job = createWindowsBunPtyJob(11, native)!
expect(job.pause()).toBe(true)
expect(job.terminate()).toBe('terminated')
job.close()
expect(native.terminateJob).toHaveBeenCalledWith(7)
expect(native.resumeProcess).not.toHaveBeenCalled()
expect(native.closeHandle).toHaveBeenCalledWith(1011)
expect(native.closeHandle).toHaveBeenCalledWith(7)
})
it('retains an exact handle when resume fails so a later retry can recover it', () => {
const resumeProcess = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true)
const native = createNative({ resumeProcess })
const job = createWindowsBunPtyJob(11, native)!
vi.mocked(native.closeHandle).mockClear()
expect(job.pause()).toBe(true)
expect(job.resume()).toBe(false)
expect(native.closeHandle).not.toHaveBeenCalledWith(1011)
expect(job.resume()).toBe(true)
expect(native.closeHandle).toHaveBeenCalledWith(1011)
})
it('keeps breakaway denied when forced termination needs kill-on-close', () => {
const native = createNative({
resumeProcess: vi.fn(() => false),
terminateJob: vi.fn(() => false)
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
const job = createWindowsBunPtyJob(11, native)!
expect(job.pause()).toBe(true)
job.close()
expect(native.configureJob).toHaveBeenLastCalledWith(7, 0x2000)
})
it('terminates a still-suspended tree instead of abandoning it during close', () => {
const native = createNative({ resumeProcess: vi.fn(() => false) })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const job = createWindowsBunPtyJob(11, native)!
vi.mocked(native.closeHandle).mockClear()
expect(job.pause()).toBe(true)
job.close()
expect(native.terminateJob).toHaveBeenCalledWith(7)
expect(native.closeHandle).toHaveBeenCalledWith(1011)
expect(native.closeHandle).toHaveBeenCalledWith(7)
expect(warn).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,218 @@
import type { JobTerminationOutcome } from '../../windows/windows-pty-job'
import {
__resetWindowsBunPtyNativeForTests,
loadWindowsBunPtyJobNative,
type WindowsBunPtyJobNative,
type WindowsNativeHandle
} from './windows-bun-pty-native'
export type { WindowsBunPtyJobNative } from './windows-bun-pty-native'
export type WindowsBunPtyJob = {
listProcessIds(): readonly number[] | null
pause(): boolean
resume(): boolean
terminate(): JobTerminationOutcome
close(): void
}
const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0000_0800
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x0000_2000
const PROCESS_TERMINATE = 0x0001
const PROCESS_SET_QUOTA = 0x0100
const PROCESS_SUSPEND_RESUME = 0x0800
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
const MAX_SUSPEND_PASSES = 8
let hostJobAssigned: boolean | null = null
export function assignCurrentProcessToBunPtyHostJob(
native: WindowsBunPtyJobNative | null = loadWindowsBunPtyJobNative()
): boolean {
if (hostJobAssigned !== null) {
return hostJobAssigned
}
if (!native) {
hostJobAssigned = false
return false
}
const job = native.createJob()
if (
job === null ||
!native.configureJob(job, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK) ||
!native.assignProcess(job, native.currentProcess())
) {
if (job !== null) {
native.closeHandle(job)
}
hostJobAssigned = false
return false
}
// The host job deliberately lives until Windows closes it during process teardown.
hostJobAssigned = true
return true
}
class BunPtyJob implements WindowsBunPtyJob {
private readonly suspended = new Map<number, WindowsNativeHandle>()
private closed = false
private fullySuspended = false
private terminated = false
constructor(
private readonly rootPid: number,
private readonly handle: WindowsNativeHandle,
private readonly native: WindowsBunPtyJobNative
) {}
listProcessIds(): readonly number[] | null {
return this.closed ? null : this.native.queryProcessIds(this.handle)
}
pause(): boolean {
if (this.closed || this.terminated) {
return false
}
if (this.fullySuspended) {
return true
}
if (this.suspended.size > 0 && !this.resume()) {
return false
}
for (let pass = 0; pass < MAX_SUSPEND_PASSES; pass += 1) {
const pids = this.listProcessIds()
if (!pids) {
this.resume()
return false
}
const ordered = [...pids].sort((left, right) => {
if (left === this.rootPid) {
return -1
}
if (right === this.rootPid) {
return 1
}
return left - right
})
let progressed = false
for (const pid of ordered) {
if (this.suspended.has(pid)) {
continue
}
const process = this.native.openProcess(
PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED_INFORMATION,
pid
)
if (process === null) {
continue
}
if (!this.native.isProcessInJob(process, this.handle)) {
this.native.closeHandle(process)
continue
}
if (!this.native.suspendProcess(process)) {
this.native.closeHandle(process)
continue
}
this.suspended.set(pid, process)
progressed = true
}
const remaining = this.listProcessIds()
if (remaining && remaining.every((pid) => this.suspended.has(pid))) {
this.fullySuspended = true
return true
}
if (!remaining || !progressed) {
this.resume()
return false
}
}
this.resume()
return false
}
resume(): boolean {
this.fullySuspended = false
const ownedPids = this.terminated ? [] : this.listProcessIds()
for (const [pid, process] of this.suspended) {
const processExited = ownedPids !== null && !ownedPids.includes(pid)
if (!this.terminated && !processExited && !this.native.resumeProcess(process)) {
continue
}
this.native.closeHandle(process)
this.suspended.delete(pid)
}
return this.suspended.size === 0
}
terminate(): JobTerminationOutcome {
if (this.closed) {
return this.terminated ? 'terminated' : 'unavailable'
}
if (!this.terminated) {
this.terminated = this.native.terminateJob(this.handle)
}
if (this.terminated) {
this.resume()
return 'terminated'
}
return 'unavailable'
}
close(): void {
if (this.closed) {
return
}
if (!this.resume()) {
console.warn(
'[daemon/pty] Could not resume a Windows PTY tree during cleanup; terminating it'
)
this.terminated = this.native.terminateJob(this.handle)
if (!this.terminated) {
this.terminated = this.native.configureJob(this.handle, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)
}
this.resume()
}
this.native.closeHandle(this.handle)
this.closed = true
}
}
export function createWindowsBunPtyJob(
rootPid: number,
native: WindowsBunPtyJobNative | null = loadWindowsBunPtyJobNative()
): WindowsBunPtyJob | null {
if (!native || !Number.isInteger(rootPid) || rootPid <= 0) {
return null
}
const job = native.createJob()
if (job === null || !native.configureJob(job, 0)) {
if (job !== null) {
native.closeHandle(job)
}
return null
}
const process = native.openProcess(
PROCESS_SET_QUOTA |
PROCESS_TERMINATE |
PROCESS_SUSPEND_RESUME |
PROCESS_QUERY_LIMITED_INFORMATION,
rootPid
)
if (process === null) {
native.closeHandle(job)
return null
}
const assigned = native.assignProcess(job, process)
native.closeHandle(process)
if (!assigned) {
native.closeHandle(job)
return null
}
return new BunPtyJob(rootPid, job, native)
}
export function __resetWindowsBunPtyJobForTests(): void {
__resetWindowsBunPtyNativeForTests()
hostJobAssigned = null
}
@@ -0,0 +1,157 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createWindowsBunPtyLaunch, resolveWindowsBunPtyGateEntry } from './windows-bun-pty-launch'
import { readWindowsBunPtyGateRequest, windowsBunPtyChildSpec } from './windows-bun-pty-gate'
import { publishWindowsBunPtyShellPid } from './windows-bun-pty-spawn-receipt'
const workerPath = join(__dirname, 'windows-bun-pty-launch.test.ts')
describe('Windows Bun PTY gated launch', () => {
it('reads only the atomic shell receipt and retains its first identity through cleanup', () => {
const launch = createWindowsBunPtyLaunch(
{ file: 'shell.exe', args: [], env: {} },
{ workerPath }
)
const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!)
try {
expect(launch.readShellProcessId()).toBeUndefined()
writeFileSync(`${shellPidPath}.pending`, '12')
expect(launch.readShellProcessId()).toBeUndefined()
writeFileSync(shellPidPath, 'not a PID')
expect(launch.readShellProcessId()).toBeUndefined()
for (const receipt of ['0', '-123', '4294967296', '123\n456', '1e3', '12.3']) {
writeFileSync(shellPidPath, receipt)
expect(launch.readShellProcessId()).toBeUndefined()
}
writeFileSync(shellPidPath, '1234')
expect(launch.readShellProcessId()).toBe(1234)
writeFileSync(shellPidPath, '5678')
expect(launch.readShellProcessId()).toBe(1234)
} finally {
launch.dispose()
}
expect(launch.readShellProcessId()).toBe(1234)
})
it('publishes a complete PID and leaves no intermediate receipt', () => {
const launch = createWindowsBunPtyLaunch(
{ file: 'shell.exe', args: [], env: {} },
{ workerPath }
)
const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!)
try {
publishWindowsBunPtyShellPid(shellPidPath, 1234)
expect(launch.readShellProcessId()).toBe(1234)
expect(existsSync(`${shellPidPath}.pending`)).toBe(false)
} finally {
launch.dispose()
}
})
it('preserves long executable argv without cmd interpretation and releases only once', () => {
const file = 'C:\\状 態\\%tool%&shell.exe'
const args = ['a b', 'c"d', 'e%F%g', 'h&i', 'j^k', 'bang!', 'line\nbreak', 'x'.repeat(16000)]
const launch = createWindowsBunPtyLaunch(
{ file, args, cwd: 'C:\\work tree', env: { TERM: 'xterm-256color' } },
{ workerPath }
)
const gate = launch.env.ORCA_BUN_PTY_JOB_GATE
const directory = dirname(gate)
try {
const request = readWindowsBunPtyGateRequest(launch.command.at(-1)!)
expect(request).toMatchObject({ file, args, cwd: 'C:\\work tree', gatePath: gate })
const child = windowsBunPtyChildSpec(request, launch.env)
expect(child.program).toBe(file)
expect(child.args).toEqual(args)
expect(child.windowsVerbatimArguments).toBeUndefined()
expect(child.stdio).toBe('inherit')
expect(child.env).not.toHaveProperty('ORCA_BUN_PTY_JOB_GATE')
expect(launch.windowsVerbatimArguments).toBe(false)
expect(launch.command).toContain('--no-env-file')
expect(launch.command).toContain(`--config=${join(directory, 'bunfig.toml')}`)
expect(launch.command).toContain(`--cwd=${directory}`)
expect(launch.command.join(' ').length).toBeLessThan(8191)
const clear = readFileSync(join(directory, 'clear.cmd'))
expect(clear.includes(Buffer.from('\x1b[3J\x1b[2J\x1b[H'))).toBe(true)
expect(existsSync(gate)).toBe(false)
launch.release()
launch.release()
expect(existsSync(gate)).toBe(true)
} finally {
launch.dispose()
launch.dispose()
}
expect(existsSync(directory)).toBe(false)
})
it.each(['/K', '/k', '/C', '/c'])(
'preserves direct cmd %s command text without CRT escaping',
(commandSwitch) => {
const file = 'C:\\Windows\\System32\\CMD.EXE'
const args = [commandSwitch, 'chcp 65001 > nul & echo 状態%VALUE%!']
const launch = createWindowsBunPtyLaunch({ file, args, env: {} }, { workerPath })
try {
const child = windowsBunPtyChildSpec(
readWindowsBunPtyGateRequest(launch.command.at(-1)!),
launch.env
)
expect(child.program).toBe(file)
expect(child.args).toEqual(args)
expect(child.windowsVerbatimArguments).toBe(true)
} finally {
launch.dispose()
}
}
)
it('withholds runtime preload options from the gate while preserving the shell environment', () => {
const env = {
NODE_OPTIONS: '--require C:\\workspace\\hook.js',
BUN_OPTIONS: '--preload hook.js',
TERM: 'xterm-256color'
}
const launch = createWindowsBunPtyLaunch({ file: 'shell.exe', args: [], env }, { workerPath })
try {
expect(launch.env).not.toHaveProperty('NODE_OPTIONS')
expect(launch.env).not.toHaveProperty('BUN_OPTIONS')
expect(
windowsBunPtyChildSpec(readWindowsBunPtyGateRequest(launch.command.at(-1)!), launch.env).env
).toEqual(env)
} finally {
launch.dispose()
}
})
it('fails before launch when the gate entry is missing', () => {
expect(() =>
createWindowsBunPtyLaunch(
{ file: 'shell.exe', args: [], env: {} },
{ workerPath: join(workerPath, 'missing') }
)
).toThrow('Windows PTY gate entry not found')
})
it('rejects a cmd-unsafe line break before creating launch state', () => {
expect(() =>
createWindowsBunPtyLaunch(
{ file: 'C:\\Windows\\System32\\cmd.exe', args: ['/c', 'first\nsecond'], env: {} },
{ workerPath }
)
).toThrow('cmd.exe cannot receive an argument containing a line break')
})
it('resolves adjacent, factored-chunk, and unpacked desktop layouts', () => {
const name = 'windows-bun-pty-gate-entry.js'
expect(resolveWindowsBunPtyGateEntry('/orcad', () => true)).toBe(join('/orcad', name))
expect(
resolveWindowsBunPtyGateEntry(
'/app/out/main/chunks',
(path) => path === join('/app/out/main', name)
)
).toBe(join('/app/out/main', name))
expect(resolveWindowsBunPtyGateEntry('/resources/app.asar/out/main', () => true)).toBe(
join('/resources/app.asar.unpacked/out/main', name)
)
})
})
@@ -0,0 +1,159 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, win32 } from 'node:path'
import {
buildWindowsCmdShimCommandLine,
validateWindowsCmdArguments
} from '../../../shared/child-process/windows-command-line'
import { getCmdExePath } from '../../../shared/windows-batch-spawn'
import {
WINDOWS_BUN_PTY_GATE_ENV,
WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS,
type WindowsBunPtyGateRequest
} from './windows-bun-pty-gate'
import {
readWindowsBunPtySpawnReceipt,
waitForWindowsBunPtySpawn,
type WindowsBunPtySpawnReceipt
} from './windows-bun-pty-spawn-receipt'
const CLEAR_SEQUENCE = '\x1b[3J\x1b[2J\x1b[H'
const CLEANUP_MAX_RETRIES = 5
const CLEANUP_RETRY_DELAY_MS = 50
export function resolveWindowsBunPtyGateEntry(
runtimeDir = __dirname,
pathExists: (path: string) => boolean = existsSync
): string {
const directory = runtimeDir.replace(/app\.asar(?=[\\/]|$)/, 'app.asar.unpacked')
const candidates = [
join(directory, 'windows-bun-pty-gate-entry.js'),
join(directory, '..', 'windows-bun-pty-gate-entry.js')
]
return candidates.find(pathExists) ?? candidates[0]!
}
function removeLaunchDirectory(directory: string): boolean {
try {
rmSync(directory, {
recursive: true,
force: true,
maxRetries: CLEANUP_MAX_RETRIES,
retryDelay: CLEANUP_RETRY_DELAY_MS
})
return true
} catch (error) {
console.warn(`[pty] failed to remove Windows Bun launch directory ${directory}:`, error)
return false
}
}
export type WindowsBunPtyLaunch = {
command: string[]
clearCommand: string[]
env: Record<string, string>
windowsVerbatimArguments: boolean
readShellProcessId(): number | undefined
waitForSpawn(wrapperExited: Promise<number>): Promise<void>
release(): void
dispose(): void
}
export function createWindowsBunPtyLaunch(
args: {
file: string
args: string[]
env: Record<string, string>
cwd?: string
},
deps: { workerPath?: string; runtimePath?: string } = {}
): WindowsBunPtyLaunch {
if (win32.basename(args.file).toLowerCase() === 'cmd.exe') {
validateWindowsCmdArguments([args.file, ...args.args])
}
const workerPath = deps.workerPath ?? resolveWindowsBunPtyGateEntry()
if (!existsSync(workerPath)) {
throw new Error(`Windows PTY gate entry not found: ${workerPath}`)
}
const directory = mkdtempSync(join(tmpdir(), 'orca-bun-pty-'))
const gatePath = join(directory, 'job-assigned')
const requestPath = join(directory, 'request.json')
const shellPidPath = join(directory, 'shell.pid')
const configPath = join(directory, 'bunfig.toml')
const clearPath = join(directory, 'clear.cmd')
const cmdExe = getCmdExePath()
let released = false
let disposed = false
let spawnReceipt: WindowsBunPtySpawnReceipt | undefined
const readSpawnReceipt = (): WindowsBunPtySpawnReceipt | undefined => {
if (!disposed) {
spawnReceipt ??= readWindowsBunPtySpawnReceipt(shellPidPath)
}
return spawnReceipt
}
const env: Record<string, string> = { ...args.env, [WINDOWS_BUN_PTY_GATE_ENV]: gatePath }
const runtimeOptions: WindowsBunPtyGateRequest['runtimeOptions'] = {}
for (const key of WINDOWS_BUN_PTY_RUNTIME_OPTION_KEYS) {
if (env[key] !== undefined) {
runtimeOptions[key] = env[key]
}
delete env[key]
}
try {
writeFileSync(
requestPath,
JSON.stringify({
file: args.file,
args: args.args,
cwd: args.cwd ?? process.cwd(),
gatePath,
shellPidPath,
runtimeOptions
} satisfies WindowsBunPtyGateRequest),
{ encoding: 'utf8', flag: 'wx', mode: 0o600 }
)
writeFileSync(configPath, '', { flag: 'wx', mode: 0o600 })
writeFileSync(clearPath, `@echo off\r\n<nul set /p "=${CLEAR_SEQUENCE}"\r\n`, {
encoding: 'ascii',
flag: 'wx'
})
} catch (error) {
removeLaunchDirectory(directory)
throw error
}
return {
// Run outside the workspace so its bunfig/.env/preloads cannot execute before job assignment.
command: [
deps.runtimePath ?? process.execPath,
'--no-env-file',
`--config=${configPath}`,
`--cwd=${directory}`,
workerPath,
requestPath
],
clearCommand: [cmdExe, buildWindowsCmdShimCommandLine(clearPath, [])],
env,
windowsVerbatimArguments: false,
readShellProcessId() {
const receipt = readSpawnReceipt()
return receipt && 'pid' in receipt ? receipt.pid : undefined
},
waitForSpawn: (wrapperExited) => waitForWindowsBunPtySpawn(readSpawnReceipt, wrapperExited),
release() {
if (released) {
return
}
writeFileSync(gatePath, '', { flag: 'wx' })
released = true
},
dispose() {
if (disposed) {
return
}
readSpawnReceipt()
disposed = removeLaunchDirectory(directory)
}
}
}
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest'
import { queryWindowsBunPtyProcessIds } from './windows-bun-pty-native'
function writeProcessList(bytes: Uint8Array, pids: number[]): boolean {
const capacity = (bytes.byteLength - 8) / 8
const view = new DataView(bytes.buffer)
view.setUint32(0, pids.length, true)
view.setUint32(4, Math.min(pids.length, capacity), true)
pids
.slice(0, capacity)
.forEach((pid, index) => view.setBigUint64(8 + index * 8, BigInt(pid), true))
return pids.length <= capacity
}
describe('Windows Bun job process enumeration', () => {
it.each([false, true])(
'grows an incomplete process list when the native call returns %s',
(result) => {
const pids = Array.from({ length: 257 }, (_, index) => index + 1)
const query = vi.fn((bytes: Uint8Array) => writeProcessList(bytes, pids) || result)
expect(queryWindowsBunPtyProcessIds(query)).toEqual(pids)
expect(query.mock.calls.map(([bytes]) => (bytes.byteLength - 8) / 8)).toEqual([64, 256, 1024])
}
)
it('does not mistake a failed native query for an empty job', () => {
const query = vi.fn(() => false)
expect(queryWindowsBunPtyProcessIds(query)).toBeNull()
expect(query).toHaveBeenCalledOnce()
})
it('returns an empty list only when the native query succeeds', () => {
expect(queryWindowsBunPtyProcessIds(() => true)).toEqual([])
})
it('bounds growth when a process tree exceeds the inventory limit', () => {
const query = vi.fn((bytes: Uint8Array) => {
new DataView(bytes.buffer).setUint32(0, 20_000, true)
return false
})
expect(queryWindowsBunPtyProcessIds(query)).toBeNull()
expect(query).toHaveBeenCalledTimes(5)
})
it.each([0, 0x1_0000_0000])(
'refuses invalid PID %s without reporting partial ownership',
(pid) => {
expect(
queryWindowsBunPtyProcessIds((bytes) => writeProcessList(bytes, [1234, pid]))
).toBeNull()
}
)
})
@@ -0,0 +1,176 @@
import { createRequire } from 'node:module'
export type WindowsNativeHandle = number | bigint
type NativePointer = number | bigint
export type WindowsBunPtyJobNative = {
createJob(): WindowsNativeHandle | null
configureJob(job: WindowsNativeHandle, flags: number): boolean
currentProcess(): WindowsNativeHandle
openProcess(access: number, pid: number): WindowsNativeHandle | null
assignProcess(job: WindowsNativeHandle, process: WindowsNativeHandle): boolean
isProcessInJob(process: WindowsNativeHandle, job: WindowsNativeHandle): boolean
queryProcessIds(job: WindowsNativeHandle): readonly number[] | null
suspendProcess(process: WindowsNativeHandle): boolean
resumeProcess(process: WindowsNativeHandle): boolean
terminateJob(job: WindowsNativeHandle): boolean
closeHandle(handle: WindowsNativeHandle): void
}
type FfiFunction = { args: readonly string[]; returns: string }
type FfiLibrary<T> = { symbols: T }
type BunFfi = {
dlopen<T>(name: string, symbols: Record<string, FfiFunction>): FfiLibrary<T>
ptr(view: ArrayBufferView): NativePointer
}
type Kernel32 = {
CreateJobObjectW(attributes: null, name: null): WindowsNativeHandle | null
SetInformationJobObject(
job: WindowsNativeHandle,
infoClass: number,
info: NativePointer,
infoLength: number
): number
GetCurrentProcess(): WindowsNativeHandle
OpenProcess(access: number, inherit: number, pid: number): WindowsNativeHandle | null
AssignProcessToJobObject(job: WindowsNativeHandle, process: WindowsNativeHandle): number
IsProcessInJob(
process: WindowsNativeHandle,
job: WindowsNativeHandle,
result: NativePointer
): number
QueryInformationJobObject(
job: WindowsNativeHandle,
infoClass: number,
info: NativePointer,
infoLength: number,
returnLength: null
): number
TerminateJobObject(job: WindowsNativeHandle, exitCode: number): number
CloseHandle(handle: WindowsNativeHandle): number
}
type Ntdll = {
NtSuspendProcess(process: WindowsNativeHandle): number
NtResumeProcess(process: WindowsNativeHandle): number
}
const requireFromMain = createRequire(__filename)
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
const JOB_OBJECT_BASIC_PROCESS_ID_LIST = 3
const JOB_LIMIT_FLAGS_OFFSET = 16
const JOB_EXTENDED_LIMITS_BYTES = 144
const MAX_JOB_PROCESS_IDS = 16_384
export function queryWindowsBunPtyProcessIds(
query: (buffer: Uint8Array) => boolean
): readonly number[] | null {
for (let capacity = 64; capacity <= MAX_JOB_PROCESS_IDS; capacity *= 4) {
const bytes = new Uint8Array(8 + capacity * 8)
const queried = query(bytes)
const view = new DataView(bytes.buffer)
const assigned = view.getUint32(0, true)
const count = view.getUint32(4, true)
// These output counts survive the FFI boundary; thread-local GetLastError may not.
if (assigned > count) {
continue
}
if (!queried || count > capacity) {
return null
}
const pids: number[] = []
for (let index = 0; index < count; index += 1) {
const pid = Number(view.getBigUint64(8 + index * 8, true))
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) {
return null
}
pids.push(pid)
}
return pids
}
return null
}
let cachedNative: WindowsBunPtyJobNative | null | undefined
export function loadWindowsBunPtyJobNative(): WindowsBunPtyJobNative | null {
if (cachedNative !== undefined) {
return cachedNative
}
if (process.platform !== 'win32') {
cachedNative = null
return cachedNative
}
try {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned Bun runtime supplies these FFI exports; loading failures refuse job ownership.
const ffi = requireFromMain('bun:ffi') as BunFfi
const kernel = ffi.dlopen<Kernel32>('kernel32.dll', {
CreateJobObjectW: { args: ['ptr', 'ptr'], returns: 'ptr' },
SetInformationJobObject: { args: ['ptr', 'u32', 'ptr', 'u32'], returns: 'i32' },
GetCurrentProcess: { args: [], returns: 'ptr' },
OpenProcess: { args: ['u32', 'i32', 'u32'], returns: 'ptr' },
AssignProcessToJobObject: { args: ['ptr', 'ptr'], returns: 'i32' },
IsProcessInJob: { args: ['ptr', 'ptr', 'ptr'], returns: 'i32' },
QueryInformationJobObject: {
args: ['ptr', 'u32', 'ptr', 'u32', 'ptr'],
returns: 'i32'
},
TerminateJobObject: { args: ['ptr', 'u32'], returns: 'i32' },
CloseHandle: { args: ['ptr'], returns: 'i32' }
})
const ntdll = ffi.dlopen<Ntdll>('ntdll.dll', {
NtSuspendProcess: { args: ['ptr'], returns: 'i32' },
NtResumeProcess: { args: ['ptr'], returns: 'i32' }
})
const { symbols } = kernel
cachedNative = {
createJob: () => symbols.CreateJobObjectW(null, null),
configureJob(job, flags) {
const limits = new Uint8Array(JOB_EXTENDED_LIMITS_BYTES)
new DataView(limits.buffer).setUint32(JOB_LIMIT_FLAGS_OFFSET, flags, true)
return (
symbols.SetInformationJobObject(
job,
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
ffi.ptr(limits),
limits.byteLength
) !== 0
)
},
currentProcess: () => symbols.GetCurrentProcess(),
openProcess: (access, pid) => symbols.OpenProcess(access, 0, pid),
assignProcess: (job, process) => symbols.AssignProcessToJobObject(job, process) !== 0,
isProcessInJob(process, job) {
const result = new Uint32Array(1)
return symbols.IsProcessInJob(process, job, ffi.ptr(result)) !== 0 && result[0] !== 0
},
queryProcessIds(job) {
return queryWindowsBunPtyProcessIds(
(bytes) =>
symbols.QueryInformationJobObject(
job,
JOB_OBJECT_BASIC_PROCESS_ID_LIST,
ffi.ptr(bytes),
bytes.byteLength,
null
) !== 0
)
},
suspendProcess: (process) => ntdll.symbols.NtSuspendProcess(process) >= 0,
resumeProcess: (process) => ntdll.symbols.NtResumeProcess(process) >= 0,
terminateJob: (job) => symbols.TerminateJobObject(job, 1) !== 0,
closeHandle: (handle) => {
symbols.CloseHandle(handle)
}
}
return cachedNative
} catch {
cachedNative = null
return cachedNative
}
}
export function __resetWindowsBunPtyNativeForTests(): void {
cachedNative = undefined
}
@@ -0,0 +1,90 @@
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createWindowsBunPtyLaunch } from './windows-bun-pty-launch'
import { readWindowsBunPtyGateRequest } from './windows-bun-pty-gate'
import {
publishWindowsBunPtyShellPid,
publishWindowsBunPtySpawnError,
WindowsBunPtySpawnUnconfirmedError
} from './windows-bun-pty-spawn-receipt'
const workerPath = join(__dirname, 'windows-bun-pty-spawn-receipt.test.ts')
const neverExits = new Promise<number>(() => {})
describe('Windows Bun shell spawn confirmation', () => {
afterEach(() => vi.useRealTimers())
it('waits for the actual shell and preserves successful immediate exit through cleanup', async () => {
const launch = createWindowsBunPtyLaunch(
{ file: 'shell.exe', args: [], env: {} },
{ workerPath }
)
const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!)
const ready = vi.fn()
let exit!: (code: number) => void
const exited = new Promise<number>((resolve) => {
exit = resolve
})
const waiting = launch.waitForSpawn(exited).then(ready)
try {
await Promise.resolve()
expect(ready).not.toHaveBeenCalled()
publishWindowsBunPtyShellPid(shellPidPath, 1234)
exit(17)
launch.dispose()
await waiting
expect(ready).toHaveBeenCalledOnce()
expect(launch.readShellProcessId()).toBe(1234)
expect(existsSync(dirname(shellPidPath))).toBe(false)
} finally {
launch.dispose()
}
})
it('preserves a definite spawn error through cleanup so the caller can retry another shell', async () => {
const launch = createWindowsBunPtyLaunch(
{ file: 'shell.exe', args: [], env: {} },
{ workerPath }
)
const { shellPidPath } = readWindowsBunPtyGateRequest(launch.command.at(-1)!)
try {
publishWindowsBunPtySpawnError(shellPidPath, new Error('spawn ENOENT'))
expect(existsSync(`${shellPidPath}.error.pending`)).toBe(false)
launch.dispose()
await expect(launch.waitForSpawn(Promise.resolve(1))).rejects.toThrow('spawn ENOENT')
} finally {
launch.dispose()
}
})
it('refuses to retry an exited gate without a receipt because its shell may have run', async () => {
const launch = createWindowsBunPtyLaunch(
{ file: 'shell.exe', args: [], env: {} },
{ workerPath }
)
try {
await expect(launch.waitForSpawn(Promise.resolve(0))).rejects.toBeInstanceOf(
WindowsBunPtySpawnUnconfirmedError
)
} finally {
launch.dispose()
}
})
it('bounds the wait for a live gate that never publishes its shell identity', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
const launch = createWindowsBunPtyLaunch(
{ file: 'shell.exe', args: [], env: {} },
{ workerPath }
)
try {
const waiting = launch.waitForSpawn(neverExits)
const assertion = expect(waiting).rejects.toBeInstanceOf(WindowsBunPtySpawnUnconfirmedError)
vi.setSystemTime(Date.now() + 30_001)
await assertion
} finally {
launch.dispose()
}
})
})
@@ -0,0 +1,66 @@
import { readFileSync, renameSync, writeFileSync } from 'node:fs'
import { setTimeout as delay } from 'node:timers/promises'
export type WindowsBunPtySpawnReceipt = { pid: number } | { error: string }
export class WindowsBunPtySpawnUnconfirmedError extends Error {}
function publishReceipt(path: string, value: string): void {
const pending = `${path}.pending`
writeFileSync(pending, value, { flag: 'wx', mode: 0o600 })
// ConPTY cannot inherit Bun IPC; publish the receipt atomically.
renameSync(pending, path)
}
export function publishWindowsBunPtyShellPid(path: string, pid: number): void {
publishReceipt(path, String(pid))
}
export function publishWindowsBunPtySpawnError(path: string, error: unknown): void {
publishReceipt(`${path}.error`, error instanceof Error ? error.message : String(error))
}
export function readWindowsBunPtySpawnReceipt(path: string): WindowsBunPtySpawnReceipt | undefined {
try {
const receipt = readFileSync(path, 'utf8')
const pid = Number(receipt)
if (/^[1-9][0-9]{0,9}$/.test(receipt) && Number.isSafeInteger(pid) && pid <= 0xffff_ffff) {
return { pid }
}
} catch {
// Missing or unreadable identity never proves the shell failed to spawn.
}
try {
return { error: readFileSync(`${path}.error`, 'utf8') }
} catch {
return undefined
}
}
export async function waitForWindowsBunPtySpawn(
readReceipt: () => WindowsBunPtySpawnReceipt | undefined,
wrapperExited: Promise<number>
): Promise<void> {
let ended = false
const markEnded = (): void => {
ended = true
}
void wrapperExited.then(markEnded, markEnded)
const deadline = Date.now() + 30_000
while (true) {
const receipt = readReceipt()
if (receipt) {
if ('pid' in receipt) {
return
}
if (ended) {
throw new Error(receipt.error)
}
}
if (ended || Date.now() >= deadline) {
// An unreported shell may already have run a startup command; never retry it.
throw new WindowsBunPtySpawnUnconfirmedError('Windows shell spawn could not be confirmed')
}
await delay(5)
}
}
@@ -1,9 +1,12 @@
import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
import type { JobTerminationOutcome } from '../windows/windows-pty-job'
export type SubprocessHandle = {
pid: number
processNameIsSpawnFile?: boolean
inspectChildProcesses?(): PtyChildProcessVerdict
/** Live foreground process name of the PTY (node-pty's `.process`), e.g.
* 'claude' / 'codex' / 'zsh'. Null once the child has exited. */
getForegroundProcess(options?: { rawFallback?: boolean }): string | null
+9 -7
View File
@@ -4,10 +4,7 @@ import { createSessionOutputPipeline } from './session-output-pipeline'
import { SessionProducerPause } from './session-producer-pause'
import { SessionShellReadyBarrier } from './session-shell-ready-barrier'
import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier'
import {
SessionTerminationController,
IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS
} from './session-termination-controller'
import { SessionTerminationController } from './session-termination-controller'
import type { SubprocessHandle } from './session-subprocess-handle'
import type { JobTerminationOutcome } from '../windows/windows-pty-job'
import type { SessionOptions } from './session-options'
@@ -21,6 +18,7 @@ import type {
TakePendingOutputResult,
TerminalSnapshot
} from './types'
import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
export class Session {
@@ -29,6 +27,7 @@ export class Session {
readonly terminalHandle: string | null
readonly launchAgent: TuiAgent | null
readonly wslDistro: string | null
readonly processNameIsSpawnFile: boolean
private _state: SessionState = 'running'
private _exitCode: number | null = null
private _disposed = false
@@ -47,6 +46,7 @@ export class Session {
this.launchAgent = opts.launchAgent ?? null
this.wslDistro = opts.wslDistro ?? null
this.subprocess = opts.subprocess
this.processNameIsSpawnFile = opts.subprocess.processNameIsSpawnFile === true
this.onSessionExit = opts.onExit
const pipeline = createSessionOutputPipeline({
cols: opts.cols,
@@ -195,9 +195,7 @@ export class Session {
this.termination.scheduleForceDisposeFallback()
}
async forceKillAndWaitForExit(
timeoutMs = IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS
): Promise<void> {
async forceKillAndWaitForExit(timeoutMs?: number): Promise<void> {
await this.termination.forceKillAndWaitForExit(timeoutMs)
}
@@ -253,6 +251,10 @@ export class Session {
return this.output.getCwd()
}
inspectChildProcesses(): PtyChildProcessVerdict {
return this.subprocess.inspectChildProcesses?.() ?? 'unverifiable'
}
getForegroundProcess(options?: { rawFallback?: boolean }): string | null {
return this.subprocess.getForegroundProcess(options)
}
@@ -1,11 +1,14 @@
import { isShellProcess } from '../../shared/agent-detection'
import { recognizeAgentProcess } from '../../shared/agent-process-recognition'
import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection'
import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence'
import { getCheapProcessTableSnapshot } from '../../shared/cheap-process-table-snapshot-reader'
import { getStrictProcessTableSnapshotWithAge } from '../../shared/process-table-snapshot-reader'
import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process'
import { buildPaneProcessFingerprint } from '../providers/posix-pane-foreground-fingerprint'
import type { Session } from './session'
import { resolveSpawnFileForegroundFromRows } from './pty-subprocess/spawn-file-foreground-process'
import { inspectSpawnFileChildProcessesFromRows } from './pty-subprocess/spawn-file-child-processes'
import {
clearSteadyStateAnchor,
getSteadyStateAnchor,
@@ -16,6 +19,7 @@ import { SessionNotFoundError } from './types'
export type TerminalHostProcessInspection = {
foregroundProcess: string | null
hasChildProcesses: boolean
childProcessEvidence?: PtyChildProcessVerdict
foregroundProcessEvidence?: RemoteForegroundEvidence
}
@@ -76,13 +80,32 @@ export async function inspectTerminalHostProcess(args: {
}
args.onTier?.('full')
const foregroundProcess = session.getForegroundProcess()
let foregroundProcess = session.getForegroundProcess()
let childProcessEvidence: PtyChildProcessVerdict | undefined = session.processNameIsSpawnFile
? 'unverifiable'
: undefined
if (session.processNameIsSpawnFile && process.platform === 'win32' && incarnationMatches) {
foregroundProcess = await session.confirmForegroundProcess()
childProcessEvidence = session.inspectChildProcesses()
}
let evidence: RemoteForegroundEvidence
if (!incarnationMatches) {
evidence = unverifiableEvidence(args, session, 'incarnation_mismatch')
} else {
try {
const snapshot = await getStrictProcessTableSnapshotWithAge()
if (session.processNameIsSpawnFile && process.platform !== 'win32') {
const observed = resolveSpawnFileForegroundFromRows(snapshot.rows, session.pid)
foregroundProcess = observed.available ? observed.processName : foregroundProcess
childProcessEvidence = inspectSpawnFileChildProcessesFromRows(
snapshot.rows,
session.pid,
session.getForegroundProcess({ rawFallback: true })
)
if (observed.available && observed.processName && !isShellProcess(observed.processName)) {
childProcessEvidence = 'children'
}
}
evidence = resolveRemoteForegroundEvidence(
{ rootPid: session.pid, fallbackProcess: foregroundProcess },
{
@@ -110,7 +133,11 @@ export async function inspectTerminalHostProcess(args: {
evidence.verdict === 'live'
? (evidence.processName ?? ordinaryForeground)
: foregroundProcess,
hasChildProcesses: nonShellForeground,
hasChildProcesses:
childProcessEvidence === undefined
? nonShellForeground
: childProcessEvidence !== 'no-children',
...(childProcessEvidence === undefined ? {} : { childProcessEvidence }),
foregroundProcessEvidence: evidence
}
}
+33 -3
View File
@@ -1,8 +1,36 @@
import os from 'node:os'
import * as pty from 'node-pty'
import type * as pty from 'node-pty'
import { createRequire } from 'node:module'
import { canUseBunPty, spawnBunPty } from './pty-subprocess/bun-pty-process'
import { assignHostProcessToKillOnCloseJob } from '../windows/windows-pty-job'
const WARMUP_KILL_TIMEOUT_MS = 10_000
const requireFromMain = createRequire(__filename)
const spawnWarmupPty: typeof pty.spawn = (file, args, options) => {
if (canUseBunPty()) {
if (!Array.isArray(args)) {
throw new Error('Bun PTY requires argument arrays')
}
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(options.env ?? process.env)) {
if (value !== undefined) {
env[key] = value
}
}
return spawnBunPty({
file,
args,
cwd: options.cwd ?? os.homedir(),
env,
cols: options.cols ?? 2,
rows: options.rows ?? 1
})
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node-pty's installed package implements the declared spawn contract.
const nodePty = requireFromMain('node-pty') as typeof pty
return nodePty.spawn(file, args, options)
}
/**
* Pays the one-time cost of the first ConPTY spawn (conpty native module
@@ -10,7 +38,7 @@ const WARMUP_KILL_TIMEOUT_MS = 10_000
* those binaries) at daemon boot instead of on the user's first terminal.
* Measured ~2.7s on a Windows dev profile for the first spawn vs ~70ms after.
*/
export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): void {
export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = spawnWarmupPty): void {
if (process.platform !== 'win32') {
return
}
@@ -19,7 +47,9 @@ export function warmWindowsConptyOnce(spawnPty: typeof pty.spawn = pty.spawn): v
setImmediate(() => {
try {
// Warm-up children must die with the daemon, even before its first real terminal.
assignHostProcessToKillOnCloseJob()
if (!canUseBunPty()) {
assignHostProcessToKillOnCloseJob()
}
const proc = spawnPty(process.env.COMSPEC || 'cmd.exe', ['/c', 'exit'], {
name: 'xterm-256color',
cols: 2,
@@ -1,4 +1,5 @@
import type * as ParcelWatcher from '@parcel/watcher'
import { loadParcelWatcher } from './parcel-watcher-module-loader'
import { createWatcherProcessEventDeliveryQueue } from './parcel-watcher-event-delivery'
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
import type {
@@ -64,7 +65,7 @@ export async function subscribeWithInProcessWatcher(
try {
// Why: setup ownership starts before module loading; an abort or timeout
// during the import must settle the caller just like one during the crawl.
watcher = await Promise.race([import('@parcel/watcher'), cancellation])
watcher = await Promise.race([loadParcelWatcher(), cancellation])
} catch (error) {
clearPendingControls()
throw error
@@ -0,0 +1,42 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { loadParcelWatcher } from './parcel-watcher-module-loader'
const state = vi.hoisted((): { named: unknown; fallback: unknown } => ({
named: undefined,
fallback: undefined
}))
vi.mock('@parcel/watcher', () => ({
get subscribe() {
return state.named
},
get default() {
return state.fallback
}
}))
beforeEach(() => {
state.named = undefined
state.fallback = undefined
})
it('uses named exports when the runtime exposes them', async () => {
const subscribe = vi.fn()
state.named = subscribe
state.fallback = { subscribe: vi.fn() }
expect((await loadParcelWatcher()).subscribe).toBe(subscribe)
})
it('loads the full CommonJS default when a packaged wrapper has no named exports', async () => {
const subscribe = vi.fn()
const getEventsSince = vi.fn()
state.fallback = { subscribe, getEventsSince }
expect(await loadParcelWatcher()).toBe(state.fallback)
expect((await loadParcelWatcher()).getEventsSince).toBe(getEventsSince)
})
it.each([undefined, null, {}, { subscribe: false }])(
'rejects invalid watcher exports (%j)',
async (fallback) => {
state.fallback = fallback
await expect(loadParcelWatcher()).rejects.toThrow('parcel_watcher_module_invalid')
}
)
@@ -0,0 +1,11 @@
import type * as ParcelWatcher from '@parcel/watcher'
/** Native loading stays in the watcher child, across named and CommonJS exports. */
export async function loadParcelWatcher(): Promise<typeof ParcelWatcher> {
const loaded = await import('@parcel/watcher')
const watcher = typeof loaded.subscribe === 'function' ? loaded : loaded.default
if (!watcher || typeof watcher.subscribe !== 'function') {
throw new Error('parcel_watcher_module_invalid')
}
return watcher
}
+3 -2
View File
@@ -8,6 +8,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as ParcelWatcher from '@parcel/watcher'
import { loadParcelWatcher } from './parcel-watcher-module-loader'
import { startShallowWatcher } from './parcel-watcher-shallow-subscription'
import { detectShallowWatchDelivery } from './shallow-watch-delivery-probe'
import {
@@ -46,7 +47,7 @@ async function startCanary(getStableActivityRevision: () => number | null): Prom
let lastEventAt = 0
try {
canaryDir = configuredCanaryDir ?? mkdtempSync(join(tmpdir(), 'orca-watcher-canary-'))
const watcher = await import('@parcel/watcher')
const watcher = await loadParcelWatcher()
// Why: pin the Windows backend like the main subscriptions do, so the
// canary never probes for Watchman.
const opts = (
@@ -211,7 +212,7 @@ function main(): void {
send({ op: 'watch-error', id, message: errorMessage(error) })
)
}
const watcher = await import('@parcel/watcher')
const watcher = await loadParcelWatcher()
return await watcher.subscribe(
dir,
(err, events) => {
+40 -3
View File
@@ -1,11 +1,35 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ORCAD_PROFILE_PREFLIGHT_FLAG,
ORCAD_STARTUP_PREFLIGHT_FLAG
} from '../../shared/orcad-profile-preflight'
/**
* The precondition is only worth anything if it runs first. A loader failure is not
* catchable, so a preflight that lands after `main()` has already reached
* `await import('../ipc/pty')` prevents nothing.
*/
const order: string[] = []
const { order, profileProbe } = vi.hoisted(() => {
const order: string[] = []
return { order, profileProbe: vi.fn(async () => {}) }
})
vi.mock('./orcad-bundled-runtime', () => ({ handoffToBundledOrcad: () => false }))
vi.mock('./orcad-profile-preflight', () => ({
preflightBundledOrcadStartup: async () => {
order.push('profile-admission')
},
runOrcadProfilePreflight: profileProbe
}))
beforeEach(() => {
vi.resetModules()
order.length = 0
})
afterEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
})
vi.mock('./orcad-native-preflight', () => ({
runOrcadNativePreflight: () => {
@@ -21,10 +45,23 @@ vi.mock('./orcad-entry', () => ({
}))
describe('orcad entry', () => {
it.each([
{ flag: ORCAD_PROFILE_PREFLIGHT_FLAG, nativeFeatures: true },
{ flag: ORCAD_STARTUP_PREFLIGHT_FLAG, nativeFeatures: false }
])(
'runs the selected disposable probe without starting a server: $flag',
async ({ flag, nativeFeatures }) => {
vi.spyOn(process, 'argv', 'get').mockReturnValue(['runtime', 'orcad.js', flag, 'nonce'])
await import('./main')
expect(profileProbe).toHaveBeenCalledExactlyOnceWith('nonce', { nativeFeatures })
expect(order).toEqual([])
}
)
it('runs the native preflight before starting the runtime', async () => {
await import('./main')
await vi.waitFor(() => expect(order).toContain('main'))
expect(order).toEqual(['preflight', 'main'])
expect(order).toEqual(['profile-admission', 'preflight', 'main'])
})
})
+31 -4
View File
@@ -2,6 +2,12 @@
import process from 'node:process'
import { main, resolveOrcadExitCode } from './orcad-entry'
import { runOrcadNativePreflight } from './orcad-native-preflight'
import {
ORCAD_PROFILE_PREFLIGHT_FLAG,
ORCAD_STARTUP_PREFLIGHT_FLAG
} from '../../shared/orcad-profile-preflight'
import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight'
import { handoffToBundledOrcad } from './orcad-bundled-runtime'
// Why exit before the preflight: reaching this line means the whole module graph resolved
// under plain Node, which is all the build guard needs to prove. Probing natives or
@@ -16,12 +22,33 @@ if (process.argv.includes('--orcad-smoke-load-check')) {
// evaluated before this statement, so the guarantee is that no module in the graph
// requires node-pty at import time — which the bundle's lazy `require("node-pty")` in
// local-pty-provider satisfies. See ./node-pty-precondition.ts for why a child process.
runOrcadNativePreflight()
main().catch((error: unknown) => {
function failStartup(error: unknown): void {
console.error('orcad: failed to start:', error)
// Why a resolved code and not a bare 1: a data-root or bind-address refusal is a
// configuration fault that restarting cannot fix, and a supervisor needs to tell the two
// apart to avoid restart-spinning on it.
process.exit(resolveOrcadExitCode(error))
})
}
try {
if (!handoffToBundledOrcad()) {
const flag = process.argv[2]
if (
(flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) &&
process.argv.length === 4
) {
void runOrcadProfilePreflight(process.argv[3], {
nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG
}).catch(failStartup)
} else {
void preflightBundledOrcadStartup()
.then(() => {
runOrcadNativePreflight()
return main()
})
.catch(failStartup)
}
}
} catch (error) {
failStartup(error)
}
+41
View File
@@ -0,0 +1,41 @@
import { createHash } from 'node:crypto'
import { createReadStream, existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { z } from 'zod'
import {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_VERSION,
orcadArtifactFilenames,
orcadArtifactHashPrefix
} from '../../shared/orcad-artifacts'
import { ORCAD_BUN_TARGETS } from '../../shared/orcad-bun-runtime'
import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name'
/** Hash installed bytes in the build's order; a version marker is not proof of delivery. */
export async function readOrcadArtifactIdentity(directory: string): Promise<string> {
const target = z
.enum(ORCAD_BUN_TARGETS)
.parse((await readFile(join(directory, ORCAD_BUILD_TARGET_FILENAME), 'utf8')).trim())
const platform = target.startsWith('win32-')
? 'win32'
: target.startsWith('darwin-')
? 'darwin'
: 'linux'
const browser = orcadAgentBrowserNativeName(
platform,
target.split('-')[1] ?? '',
target.endsWith('-musl') ? 'musl' : 'glibc'
)
const filenames = orcadArtifactFilenames(target)
if (existsSync(join(directory, browser))) {
filenames.push(browser)
}
const hash = createHash('sha256').update(orcadArtifactHashPrefix(target))
for (const filename of filenames) {
for await (const chunk of createReadStream(join(directory, filename))) {
hash.update(chunk)
}
}
return `${ORCAD_VERSION}+${hash.digest('hex').slice(0, 12)}`
}
@@ -0,0 +1,66 @@
import { randomUUID } from 'node:crypto'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { orcadArtifactFilenames } from '../../shared/orcad-artifacts'
import { runOrcadProfilePreflight } from './orcad-profile-preflight'
import { readOrcadArtifactIdentity } from './orcad-artifact-identity'
import { resolveOrcadExitCode } from './orcad-exit-code'
const fixture = vi.hoisted(() => ({
directory: '',
sqlite: vi.fn(async () => ({ sqliteVersion: '3.53.2', revision: 1 }))
}))
vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => fixture.directory }))
vi.mock('../persistence/profile-state/profile-state-runtime-preflight', () => ({
preflightProfileStateRuntime: fixture.sqlite
}))
vi.mock('./orcad-bun-native-preflight', () => ({
preflightOrcadBunNativeRuntime: vi.fn(async () => {})
}))
beforeEach(async () => {
fixture.directory = await mkdtemp(join(tmpdir(), 'orcad-artifact-preflight-'))
for (const filename of orcadArtifactFilenames('linux-x64-glibc')) {
const path = join(fixture.directory, filename)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, filename === '.build-target' ? 'linux-x64-glibc\n' : filename)
}
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(async () => {
vi.restoreAllMocks()
vi.clearAllMocks()
await rm(fixture.directory, { recursive: true, force: true })
})
describe('installed artifact admission', () => {
it('qualifies build output before its version marker is published', async () => {
await runOrcadProfilePreflight(randomUUID())
expect(fixture.sqlite).toHaveBeenCalledOnce()
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining(await readOrcadArtifactIdentity(fixture.directory))
)
})
it.each(['.build-target', 'node_modules/@parcel/watcher/watcher.node', 'bun-runtime'])(
'refuses a missing %s as configuration before any profile probe',
async (filename) => {
await rm(join(fixture.directory, filename))
const error = await runOrcadProfilePreflight(randomUUID()).catch(
(failure: unknown) => failure
)
expect(resolveOrcadExitCode(error)).toBe(78)
expect(fixture.sqlite).not.toHaveBeenCalled()
expect(console.log).not.toHaveBeenCalled()
}
)
it('refuses a malformed target even though all named files exist', async () => {
await writeFile(join(fixture.directory, '.build-target'), 'not-a-runtime-target')
const error = await runOrcadProfilePreflight(randomUUID()).catch((failure: unknown) => failure)
expect(resolveOrcadExitCode(error)).toBe(78)
expect(fixture.sqlite).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,221 @@
import { build } from 'esbuild'
import { existsSync } from 'node:fs'
import { copyFile, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawnProcess } from '../../shared/child-process/run-process'
import { orcadBunRuntimeFilename } from '../../shared/orcad-artifacts'
import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime'
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
const runtimePath =
process.env.BUN_EXECUTABLE ?? resolve('out/orcad', orcadBunRuntimeFilename(process.platform))
const nodePath =
process.env.ORCA_TEST_NODE_EXECUTABLE ?? (process.versions.bun ? 'node' : process.execPath)
let directory = ''
const children = new Set<ReturnType<typeof spawnProcess>>()
const runtimes = new Set<number>()
describe.skipIf(!existsSync(runtimePath))('real Bun launcher lifecycle', () => {
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-bun-launcher-'))
await copyFile(runtimePath, join(directory, orcadBunRuntimeFilename(process.platform)))
await writeFile(join(directory, '.build-target'), `${process.platform}-${process.arch}\n`)
await build({
stdin: {
contents: `
import {handoffToBundledOrcad, OrcadBundledRuntimeError} from './src/main/orcad/orcad-bundled-runtime'
import {installOrcadShutdownSignals} from './src/main/orcad/orcad-lifecycle'
import {resolveOrcadExitCode} from './src/main/orcad/orcad-exit-code'
import {writeFile} from 'node:fs/promises'
if (!process.versions.bun) {
if (!handoffToBundledOrcad()) throw new Error('Missing bundled runtime')
process.on('message', signal => process.emit(signal))
} else {
console.log('booting:' + process.pid)
console.log('runtime:' + process.versions.bun)
console.log('channel-env:' + (process.env.ORCA_BUNDLED_LAUNCHER_CHANNEL ?? 'absent'))
process.on('exit', code => console.log('runtime-exit:' + code))
const keepalive = setInterval(() => {}, 1_000)
const install = async () => {
if (process.env.ORCA_TEST_FAIL_STARTUP === '1') {
const startup = new Promise((_, reject) => setTimeout(() =>
reject(new OrcadBundledRuntimeError('startup configuration failed')), 100))
installOrcadShutdownSignals(async () => (await startup).stop())
await startup
return
}
installOrcadShutdownSignals(async () => {
console.log('flushing')
clearInterval(keepalive)
if (process.env.ORCA_TEST_STALL === '1') await new Promise(() => {})
await new Promise(resolve => setTimeout(resolve, 150))
await writeFile(process.env.ORCA_TEST_DONE, 'flushed')
}, process.env.ORCA_TEST_STALL === '1' ? 100 : undefined)
console.log('ready')
}
// Exercise the shutdown observer before the outer startup-failure reporter.
const start = () => Promise.resolve().then(install)
.catch(error => setImmediate(() => process.exit(resolveOrcadExitCode(error))))
if (process.env.ORCA_TEST_DELAY_INSTALL === '1') setTimeout(start, 300)
else start()
}
`,
resolveDir: process.cwd(),
loader: 'ts'
},
outfile: join(directory, 'orcad.js'),
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs'
})
})
afterEach(() => {
for (const child of children) {
child.kill('SIGKILL')
}
children.clear()
for (const pid of runtimes) {
try {
process.kill(pid, 'SIGKILL')
} catch {}
}
runtimes.clear()
removeTreeSync(directory)
})
function launch(
options: {
direct?: boolean
nohup?: boolean
delay?: boolean
stall?: boolean
failStartup?: boolean
} = {}
) {
const runtime = options.direct
? join(directory, orcadBunRuntimeFilename(process.platform))
: nodePath
const child = spawnProcess({
program: options.nohup ? 'nohup' : runtime,
args: [...(options.nohup ? [runtime] : []), join(directory, 'orcad.js')],
env: {
...process.env,
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_TEST_DONE: join(directory, 'done'),
ORCA_TEST_DELAY_INSTALL: options.delay ? '1' : '0',
ORCA_TEST_STALL: options.stall ? '1' : '0',
ORCA_TEST_FAIL_STARTUP: options.failStartup ? '1' : '0'
},
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
})
children.add(child)
let closed = false
child.once('close', () => {
closed = true
})
let output = ''
const capture = (chunk: Buffer): void => {
output += chunk.toString()
const pid = /booting:(\d+)/.exec(output)?.[1]
if (pid && !output.includes('runtime-exit:')) {
runtimes.add(Number(pid))
} else if (pid) {
runtimes.delete(Number(pid))
}
}
child.stdout.on('data', capture)
child.stderr.on('data', capture)
const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => {
children.delete(child)
resolve({ code, signal })
})
}
)
return { child, output: () => output, exit, isClosed: () => closed }
}
it.each([false, true])(
'drains Bun after its launcher is killed (startup pending: %s)',
async (delay) => {
const h = launch({ delay })
await vi.waitFor(() => expect(h.output()).toContain(delay ? 'booting:' : 'ready'), {
timeout: 5_000
})
expect(h.output()).toContain(`runtime:${ORCAD_BUN_VERSION}`)
expect(h.output()).toContain('channel-env:absent')
h.child.kill('SIGKILL')
await h.exit
await vi.waitFor(
async () => expect(await readFile(join(directory, 'done'), 'utf8')).toBe('flushed'),
{ timeout: 5_000 }
)
expect(h.output().match(/flushing/g)).toHaveLength(1)
await vi.waitFor(() => expect(h.output()).toContain('runtime-exit:0'))
await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 })
}
)
it.skipIf(process.platform === 'win32').each([false, true])(
'survives nohup hangups and drains on TERM (direct Bun: %s)',
async (direct) => {
const h = launch({ direct, nohup: true })
await vi.waitFor(() => expect(h.output()).toContain('ready'), { timeout: 5_000 })
if (!h.child.pid) {
throw new Error('Missing launcher pid')
}
process.kill(-h.child.pid, 'SIGHUP')
await new Promise((resolve) => setTimeout(resolve, 100))
expect(h.child.exitCode).toBeNull()
expect(h.child.signalCode).toBeNull()
expect(h.output()).not.toContain('flushing')
h.child.kill('SIGTERM')
expect(await h.exit).toEqual({ code: 0, signal: null })
expect(await readFile(join(directory, 'done'), 'utf8')).toBe('flushed')
await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 })
}
)
it('keeps an unfinished shutdown alive until its failure deadline', async () => {
const h = launch({ stall: true })
await vi.waitFor(() => expect(h.output()).toContain('ready'), { timeout: 5_000 })
h.child.kill('SIGKILL')
await h.exit
await vi.waitFor(() => expect(h.output()).toContain('runtime-exit:1'))
expect(h.output()).toContain('exceeded 100ms')
await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 })
})
it.each(process.platform === 'win32' ? [false, true] : [false])(
'forwards launcher stop requests and drains once (startup pending: %s)',
async (delay) => {
const h = launch({ delay })
await vi.waitFor(() => expect(h.output()).toContain(delay ? 'booting:' : 'ready'), {
timeout: 5_000
})
h.child.send('SIGINT')
h.child.send('SIGTERM')
expect(await h.exit).toEqual({ code: 0, signal: null })
expect(await readFile(join(directory, 'done'), 'utf8')).toBe('flushed')
expect(h.output().match(/flushing/g)).toHaveLength(1)
await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 })
}
)
it('preserves a startup configuration verdict after early launcher loss', async () => {
const h = launch({ delay: true, failStartup: true })
await vi.waitFor(() => expect(h.output()).toContain('booting:'), { timeout: 5_000 })
h.child.kill('SIGKILL')
await h.exit
await vi.waitFor(() => expect(h.output()).toContain('runtime-exit:78'), { timeout: 5_000 })
expect(h.output()).toContain('shutdown after launcher disconnect failed')
await vi.waitFor(() => expect(h.isClosed()).toBe(true), { timeout: 5_000 })
})
})
@@ -0,0 +1,165 @@
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
WatcherProcessCallback,
WatcherProcessHooks
} from '../ipc/parcel-watcher-process-subscription'
import { preflightOrcadBunNativeRuntime } from './orcad-bun-native-preflight'
const fixture = vi.hoisted(() => ({
temp: vi.fn(),
pty: vi.fn(),
available: vi.fn(),
startTime: vi.fn(),
rows: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
remove: vi.fn()
}))
vi.mock('../daemon/pty-subprocess/spawn-preflight', () => ({ runPtySpawnHealthProbe: fixture.pty }))
vi.mock('../windows/windows-process-table', () => ({
isWindowsProcessTableAvailable: fixture.available,
isWindowsProcessStartTimeAvailable: fixture.startTime,
readWindowsProcessIdentityTableFresh: fixture.rows
}))
vi.mock('node:fs/promises', () => ({
mkdtemp: fixture.temp,
writeFile: fixture.write,
rm: fixture.remove
}))
vi.mock('../ipc/parcel-watcher-process-supervisor', () => ({
WatcherProcessSupervisor: class {
subscribe = fixture.subscribe
dispose = fixture.dispose
}
}))
beforeEach(() => {
vi.useFakeTimers()
fixture.temp.mockResolvedValue('/temp/probe')
fixture.pty.mockResolvedValue(undefined)
fixture.available.mockReturnValue(true)
fixture.startTime.mockReturnValue(true)
fixture.rows.mockResolvedValue([{ pid: process.pid, creationTimeMs: Date.now() - 1_000 }])
fixture.unsubscribe.mockResolvedValue(undefined)
fixture.remove.mockResolvedValue(undefined)
fixture.subscribe.mockImplementation(
async (directory: string, callback: WatcherProcessCallback) => {
fixture.write.mockImplementation(async () =>
callback(null, [{ path: join(directory, 'ready'), type: 'create' }])
)
return { unsubscribe: fixture.unsubscribe }
}
)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.resetAllMocks()
})
describe('bundled native readiness', () => {
it('keeps runtime startup independent of PTY or watcher probe availability', async () => {
fixture.pty.mockRejectedValue(new Error('PTY spawn health check timed out'))
fixture.subscribe.mockRejectedValue(new Error('ENOSPC: watch limit reached'))
await preflightOrcadBunNativeRuntime({ nativeFeatures: false })
expect(fixture.pty).not.toHaveBeenCalled()
expect(fixture.subscribe).not.toHaveBeenCalled()
})
it('still requires Windows ownership support on normal startup', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
fixture.startTime.mockReturnValue(false)
await expect(preflightOrcadBunNativeRuntime({ nativeFeatures: false })).rejects.toThrow(
'Windows process table'
)
})
it('does not admit a failed PTY in explicit qualification', async () => {
fixture.pty.mockRejectedValue(new Error('PTY spawn health check timed out'))
await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow(
'PTY spawn health check timed out'
)
})
it('awaits actual watcher delivery and unsubscribe before disposing temporary state', async () => {
await preflightOrcadBunNativeRuntime()
expect(fixture.pty).toHaveBeenCalledOnce()
expect(fixture.unsubscribe).toHaveBeenCalledOnce()
expect(fixture.dispose).toHaveBeenCalledOnce()
expect(fixture.remove).toHaveBeenCalledWith('/temp/probe', { recursive: true, force: true })
expect(vi.getTimerCount()).toBe(0)
})
it('cancels a subscribe blocked on capacity by the same readiness deadline', async () => {
fixture.subscribe.mockImplementation(
(
_directory: string,
_callback: WatcherProcessCallback,
_options: unknown,
hooks: WatcherProcessHooks
) =>
new Promise((_resolve, reject) => {
hooks.signal?.addEventListener('abort', () => reject(hooks.signal?.reason), {
once: true
})
})
)
const readiness = preflightOrcadBunNativeRuntime()
const rejected = expect(readiness).rejects.toThrow('readiness timed out')
await vi.advanceTimersByTimeAsync(5_000)
await rejected
expect(fixture.dispose).toHaveBeenCalledOnce()
expect(fixture.remove).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it('cleans up when native delivery fails before subscribe resolves', async () => {
fixture.subscribe.mockImplementation(
async (_directory: string, callback: WatcherProcessCallback) => {
callback(new Error('native watcher failed'), [])
return { unsubscribe: fixture.unsubscribe }
}
)
await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('native watcher failed')
expect(fixture.unsubscribe).toHaveBeenCalledOnce()
expect(fixture.dispose).toHaveBeenCalledOnce()
})
it('still disposes temporary state when unsubscribe fails', async () => {
fixture.unsubscribe.mockRejectedValue(new Error('watcher did not exit'))
await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('watcher did not exit')
expect(fixture.dispose).toHaveBeenCalledOnce()
expect(fixture.remove).toHaveBeenCalledOnce()
})
it.each(['missing-addon', 'missing-creation-time', 'invalid-self-row'])(
'refuses %s on Windows before spawning a PTY or allowing CIM fallback',
async (reason) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
if (reason === 'missing-addon') {
fixture.available.mockReturnValue(false)
}
if (reason === 'missing-creation-time') {
fixture.startTime.mockReturnValue(false)
}
if (reason === 'invalid-self-row') {
fixture.rows.mockResolvedValue([{ pid: process.pid }])
}
await expect(preflightOrcadBunNativeRuntime()).rejects.toThrow('Windows process table')
expect(fixture.pty).not.toHaveBeenCalled()
if (reason !== 'invalid-self-row') {
expect(fixture.rows).not.toHaveBeenCalled()
}
}
)
it('reads a fresh self identity on Windows before qualifying the PTY', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
await preflightOrcadBunNativeRuntime()
expect(fixture.rows).toHaveBeenCalledOnce()
expect(fixture.pty).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,83 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runPtySpawnHealthProbe } from '../daemon/pty-subprocess/spawn-preflight'
import { WatcherProcessSupervisor } from '../ipc/parcel-watcher-process-supervisor'
import { resolveWatcherProcessEntryPath } from '../ipc/parcel-watcher-entry-path'
import { resolveOrcadInstallRoot } from './orcad-app-paths'
import {
isWindowsProcessTableAvailable,
isWindowsProcessStartTimeAvailable,
readWindowsProcessIdentityTableFresh
} from '../windows/windows-process-table'
/** The candidate process owns disposable PTY and watcher probes before it touches user state. */
export async function preflightOrcadBunNativeRuntime(
options: { nativeFeatures?: boolean } = {}
): Promise<void> {
if (process.platform === 'win32') {
await preflightWindowsProcessIdentity()
}
// Runtime health checks can degrade independently; artifact qualification remains strict.
if (options.nativeFeatures === false) {
return
}
await runPtySpawnHealthProbe()
const directory = await mkdtemp(join(tmpdir(), 'orca-native-ready-'))
const supervisor = new WatcherProcessSupervisor({
entryPath: resolveWatcherProcessEntryPath(resolveOrcadInstallRoot(), false),
useInProcessVitestFallback: false
})
const cancellation = new AbortController()
let subscription: { unsubscribe(): Promise<void> } | undefined
let timer: ReturnType<typeof setTimeout> | undefined
try {
let resolveDelivery: () => void = () => {}
let rejectDelivery: (error: unknown) => void = () => {}
const delivered = new Promise<void>((resolve, reject) => {
resolveDelivery = resolve
rejectDelivery = reject
})
// A native callback can fail while subscribe is pending.
void delivered.catch(() => {})
timer = setTimeout(() => {
const error = new Error('Bun file watcher readiness timed out')
cancellation.abort(error)
rejectDelivery(error)
}, 5_000)
subscription = await supervisor.subscribe(
directory,
(error, events) => {
if (error) {
rejectDelivery(error)
} else if (events.some((event) => event.path === join(directory, 'ready'))) {
resolveDelivery()
}
},
process.platform === 'win32' ? { backend: 'windows' } : {},
{ signal: cancellation.signal, subscribeTimeoutMs: 5_000, onTerminalError: rejectDelivery }
)
await writeFile(join(directory, 'ready'), '')
await delivered
} finally {
clearTimeout(timer)
try {
await subscription?.unsubscribe()
} finally {
supervisor.dispose()
await rm(directory, { recursive: true, force: true })
}
}
}
async function preflightWindowsProcessIdentity(): Promise<void> {
if (!isWindowsProcessTableAvailable() || !isWindowsProcessStartTimeAvailable()) {
throw new Error('The bundled Windows process table must support process creation times')
}
const rows = await readWindowsProcessIdentityTableFresh()
const self = rows.find((row) => row.pid === process.pid)
const created = self?.creationTimeMs
if (created === undefined || !Number.isFinite(created) || created <= 0 || created > Date.now()) {
throw new Error('The bundled Windows process table could not identify this process')
}
}
@@ -1,91 +1,70 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { pathToFileURL } from 'node:url'
import { afterEach, expect, it } from 'vitest'
import { runProcessSync } from '../../shared/child-process/run-process'
/**
* The preflight only prevents a loader crash if nothing in the bundle's import graph has
* already required node-pty by the time it runs. esbuild wraps `local-pty-provider` in a
* lazy initializer because `orcad-entry` reaches it through `await import('../ipc/pty')`,
* and that laziness is load-bearing rather than incidental — a single top-level static
* import anywhere in the graph would hoist `require("node-pty")` above every statement in
* `main.ts`, including the preflight.
*
* Why this builds the bundle instead of skipping without one: no CI job builds orcad and
* runs vitest. `smoke:orcad-terminal` builds it in the static-analysis job, which never
* runs vitest; the `orcad_browser` job runs vitest but deliberately does not build orcad.
* A `runIf(existsSync(...))` guard therefore skips in every shard, forever — the same way
* an unset ORCA_BROWSER_EXECUTABLE kept the browser provider uncovered.
*
* Why not fail-when-CI instead: the wiring that would satisfy it lives in `.github/`, so
* that turns a silent gap into a red build someone else has to fix. Building costs well
* under a second (esbuild), works in every shard and on every machine, and needs no job
* to cooperate. What it must never do is skip.
*/
const REPO_ROOT = join(__dirname, '..', '..', '..')
const BUNDLE = join(REPO_ROOT, 'out', 'orcad', 'orcad.js')
const BUILD_SCRIPT = join(REPO_ROOT, 'config', 'scripts', 'build-orcad.mjs')
const directories: string[] = []
/**
* Why it throws rather than skipping when the build fails: a bundle that cannot be built
* is a louder problem than the one this test checks, and swallowing it here is exactly
* how the assertion would go missing.
*/
function ensureOrcadBundle(): void {
if (existsSync(BUNDLE)) {
return
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
const build = runProcessSync({
program: process.execPath,
args: [BUILD_SCRIPT],
cwd: REPO_ROOT,
timeoutMs: 300_000
})
if (!existsSync(BUNDLE)) {
const output = `${build.stdout}${build.stderr}`.slice(0, 4000)
throw new Error(
`could not build ${BUNDLE} (exit ${build.code}); the load-order assertion cannot run:\n${output}`
)
}
}
describe('orcad bundle native load order', () => {
const dirs: string[] = []
afterEach(() => {
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
it('does not require node-pty in-process before the entry rejects its argv', () => {
ensureOrcadBundle()
const dir = mkdtempSync(join(tmpdir(), 'orcad-load-order-'))
dirs.push(dir)
const harness = join(dir, 'harness.cjs')
writeFileSync(
harness,
[
"const Module = require('module')",
'const original = Module._load',
'Module._load = function (request, ...rest) {',
" if (request === 'node-pty') { console.log('IN_PROCESS_NODE_PTY_REQUIRE') }",
' return original.call(this, request, ...rest)',
'}',
"process.argv.push('--orcad-load-order-check')",
`require(${JSON.stringify(BUNDLE)})`
].join('\n')
)
const result = runProcessSync({
program: process.execPath,
args: [harness],
timeoutMs: 120_000
})
const output = `${result.stdout}${result.stderr}`
// Proof the graph fully loaded and reached argv parsing rather than dying early.
expect(output).toContain('Unknown argument: --orcad-load-order-check')
expect(output).not.toContain('IN_PROCESS_NODE_PTY_REQUIRE')
}, 360_000)
})
it('loads a fresh production import graph before requiring native PTY code', () => {
const directory = mkdtempSync(join(tmpdir(), 'orcad-load-order-'))
directories.push(directory)
const bundle = join(directory, 'orcad.js')
const builder = pathToFileURL(join(REPO_ROOT, 'config/scripts/orcad-entry-build.mjs')).href
const built = runProcessSync({
program: process.execPath,
args: [
'--input-type=module',
'-e',
`import { buildOrcadEntry } from ${JSON.stringify(builder)}; await buildOrcadEntry(${JSON.stringify(bundle)})`
],
cwd: REPO_ROOT,
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs: 60_000
})
expect(built.code, built.stderr.slice(0, 2_000)).toBe(0)
expect(existsSync(bundle)).toBe(true)
const marker = join(directory, 'premature-native-load')
const preload = join(directory, 'preload.cjs')
writeFileSync(
preload,
[
"const Module = require('node:module')",
'const original = Module._load',
'Module._load = function (request, ...rest) {',
" if (request === 'node-pty') {",
` require('node:fs').writeFileSync(${JSON.stringify(marker)}, request)`,
" throw new Error('native PTY required before preflight')",
' }',
' return original.call(this, request, ...rest)',
'}'
].join('\n')
)
const run = (extraArgs: string[] = []) =>
runProcessSync({
program: process.execPath,
// The production load-check exits after module evaluation, before runtime handoff or probes.
args: ['--require', preload, ...extraArgs, bundle, '--orcad-smoke-load-check'],
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs: 30_000
})
const loaded = run()
expect(loaded.code, loaded.stderr.slice(0, 2_000)).toBe(0)
expect(existsSync(marker)).toBe(false)
// Prove the interception works without relying on minified source echoed in an error.
const eagerNative = join(directory, 'eager-native.cjs')
writeFileSync(eagerNative, "require('node-pty')")
expect(run(['--require', eagerNative]).code).not.toBe(0)
expect(existsSync(marker)).toBe(true)
}, 90_000)
@@ -0,0 +1,221 @@
import { build } from 'esbuild'
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { runProcess, spawnProcess } from '../../shared/child-process/run-process'
import { shellEscape } from '../ssh/ssh-connection-utils'
let directory = ''
const children = new Set<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')
})
})
@@ -0,0 +1,176 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { handoffToBundledOrcad } from './orcad-bundled-runtime'
import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime'
import { ORCAD_VERSION_FILENAME } from '../../shared/orcad-artifacts'
const fixture = vi.hoisted(() => ({
exists: vi.fn<(path: string) => boolean>(),
realpath: vi.fn<(path: string) => string>(),
spawn: vi.fn()
}))
vi.mock('node:fs', () => ({ existsSync: fixture.exists, realpathSync: fixture.realpath }))
vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: fixture.spawn }))
class RuntimeChild extends EventEmitter {
kill = vi.fn()
disconnect = vi.fn()
connected = true
}
let child: RuntimeChild
const signalNames = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
let oldListeners: Map<NodeJS.Signals, ReturnType<typeof process.rawListeners>>
beforeEach(() => {
oldListeners = new Map(signalNames.map((signal) => [signal, process.rawListeners(signal)]))
child = new RuntimeChild()
fixture.exists.mockReturnValue(true)
fixture.realpath.mockImplementation((path) => path)
fixture.spawn.mockReturnValue(child)
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('test process exit')
})
vi.spyOn(process, 'kill').mockReturnValue(true)
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(process, 'argv', 'get').mockReturnValue(['/node', '/slot/orcad.js', '--port', '0'])
})
afterEach(() => {
for (const signal of signalNames) {
for (const listener of process.rawListeners(signal)) {
if (!oldListeners.get(signal)?.includes(listener)) {
process.off(signal, listener)
}
}
}
vi.restoreAllMocks()
vi.clearAllMocks()
})
describe('bundled Orca runtime handoff', () => {
it('leaves nonpackaged entries on their existing runtime', () => {
fixture.exists.mockReturnValue(false)
expect(handoffToBundledOrcad()).toBe(false)
expect(fixture.spawn).not.toHaveBeenCalled()
})
it('refuses an incomplete slot before starting a process', () => {
fixture.exists.mockImplementation((path) => path.endsWith('.build-target'))
expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime is missing')
expect(fixture.spawn).not.toHaveBeenCalled()
})
it('refuses a versioned slot missing both its runtime and target marker', () => {
fixture.exists.mockImplementation((path) => path.endsWith(ORCAD_VERSION_FILENAME))
expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime target is missing')
expect(fixture.spawn).not.toHaveBeenCalled()
})
it('refuses a remaining bundled runtime without its target marker', () => {
fixture.exists.mockImplementation((path) => !path.endsWith('.build-target'))
expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime target is missing')
expect(fixture.realpath).toHaveBeenCalledExactlyOnceWith('/slot/orcad.js')
expect(fixture.spawn).not.toHaveBeenCalled()
})
it('accepts only the pinned version when already executing the bundled runtime', () => {
fixture.realpath.mockReturnValue('/real/runtime')
vi.spyOn(process, 'versions', 'get').mockReturnValue({
...process.versions,
bun: ORCAD_BUN_VERSION
})
expect(handoffToBundledOrcad()).toBe(false)
expect(fixture.spawn).not.toHaveBeenCalled()
})
it('refuses an adjacent runtime that reports the wrong Bun version', () => {
fixture.realpath.mockReturnValue('/real/runtime')
vi.spyOn(process, 'versions', 'get').mockReturnValue({ ...process.versions, bun: '0.0.0' })
expect(() => handoffToBundledOrcad()).toThrow(`must be Bun ${ORCAD_BUN_VERSION}`)
})
it.each(['linux', 'darwin', 'win32'] as const)(
'hands off arguments and respects %s signal delivery',
(platform) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
expect(handoffToBundledOrcad()).toBe(true)
expect(fixture.spawn).toHaveBeenCalledWith({
program: expect.stringMatching(/bun-runtime(?:\.exe)?$/),
args: ['/slot/orcad.js', '--port', '0'],
env: expect.objectContaining({ ORCA_BUNDLED_LAUNCHER_CHANNEL: '1' }),
detached: true,
stdio: ['inherit', 'inherit', 'inherit', 'ipc']
})
for (const signal of signalNames) {
const listener = process
.rawListeners(signal)
.find((candidate) => !oldListeners.get(signal)?.includes(candidate))
if (signal === 'SIGHUP' && platform === 'win32') {
expect(listener).toBeUndefined()
continue
}
expect(listener).toBeDefined()
if (listener) {
listener.call(process, signal)
}
if (signal === 'SIGHUP') {
expect(child.kill).not.toHaveBeenCalledWith('SIGHUP')
} else if (platform === 'win32') {
expect(child.kill).not.toHaveBeenCalled()
expect(child.disconnect).toHaveBeenCalled()
} else {
expect(child.kill).toHaveBeenLastCalledWith(signal)
}
}
}
)
it('propagates a child exit code and removes every signal listener', () => {
handoffToBundledOrcad()
expect(() => child.emit('exit', 23, null)).toThrow('test process exit')
expect(process.exit).toHaveBeenCalledWith(23)
expect(process.kill).not.toHaveBeenCalled()
for (const signal of signalNames) {
expect(process.rawListeners(signal)).toEqual(oldListeners.get(signal))
}
})
it('locates the runtime beside the resolved entry rather than its symlink', () => {
fixture.realpath.mockImplementation((path) =>
path === '/slot/orcad.js' ? '/real/slot/orcad.js' : path
)
handoffToBundledOrcad()
expect(fixture.spawn).toHaveBeenCalledWith(
expect.objectContaining({
program: expect.stringMatching(/real\/slot\/bun-runtime(?:\.exe)?$/),
args: ['/real/slot/orcad.js', '--port', '0']
})
)
})
it('reports failed spawn as a configuration failure and removes listeners', () => {
handoffToBundledOrcad()
expect(() => child.emit('error', new Error('ENOENT'))).toThrow('test process exit')
expect(process.exit).toHaveBeenCalledWith(78)
for (const signal of signalNames) {
expect(process.rawListeners(signal)).toEqual(oldListeners.get(signal))
}
})
it('mirrors a POSIX signal exit without exiting before the signal is delivered', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
handoffToBundledOrcad()
child.emit('exit', null, 'SIGTERM')
expect(process.kill).toHaveBeenCalledWith(process.pid, 'SIGTERM')
expect(process.exit).not.toHaveBeenCalled()
})
it('preserves a signal exit without sending unsupported signals on Windows', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
handoffToBundledOrcad()
expect(() => child.emit('exit', null, 'SIGTERM')).toThrow('test process exit')
expect(process.exit).toHaveBeenCalledWith(143)
expect(process.kill).not.toHaveBeenCalled()
})
})
+90
View File
@@ -0,0 +1,90 @@
import { existsSync, realpathSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { constants } from 'node:os'
import { spawnProcess } from '../../shared/child-process/run-process'
import {
ORCAD_BUILD_TARGET_FILENAME,
ORCAD_VERSION_FILENAME,
orcadBunRuntimeFilename
} from '../../shared/orcad-artifacts'
import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime'
export class OrcadBundledRuntimeError extends Error {}
export const ORCAD_BUNDLED_LAUNCHER_ENV = 'ORCA_BUNDLED_LAUNCHER_CHANNEL'
/** Keep old Node service commands usable without letting Node open the profile. */
export function handoffToBundledOrcad(): boolean {
const script = process.argv[1]
if (!script) {
return false
}
const entry = realpathSync(script)
const directory = dirname(entry)
const runtime = join(directory, orcadBunRuntimeFilename(process.platform))
const hasTarget = existsSync(join(directory, ORCAD_BUILD_TARGET_FILENAME))
const hasRuntime = existsSync(runtime)
if (!hasTarget && !hasRuntime && !existsSync(join(directory, ORCAD_VERSION_FILENAME))) {
return false
}
if (!hasTarget) {
throw new OrcadBundledRuntimeError('The bundled Orca runtime target is missing')
}
if (!hasRuntime) {
throw new OrcadBundledRuntimeError('The bundled Orca runtime is missing')
}
if (realpathSync(process.execPath) === realpathSync(runtime)) {
if (process.versions.bun !== ORCAD_BUN_VERSION) {
throw new OrcadBundledRuntimeError(
`The bundled Orca runtime must be Bun ${ORCAD_BUN_VERSION}`
)
}
return false
}
const child = spawnProcess({
program: runtime,
args: [entry, ...process.argv.slice(2)],
env: { ...process.env, [ORCAD_BUNDLED_LAUNCHER_ENV]: '1' },
// Windows' default child job kills Bun before it can drain on launcher disconnect.
detached: true,
stdio: ['inherit', 'inherit', 'inherit', 'ipc']
})
// Node resets nohup's disposition; headless runtimes stop through INT/TERM or owner loss.
const ignoreHangup = (): void => {}
if (process.platform !== 'win32') {
process.on('SIGHUP', ignoreHangup)
}
const forwards = (['SIGINT', 'SIGTERM'] as const).map((signal) => {
const forward = (): void => {
if (process.platform === 'win32') {
// Detached Windows children have a separate console; kill() skips durable shutdown.
if (child.connected) {
child.disconnect()
}
} else {
child.kill(signal)
}
}
process.on(signal, forward)
return { signal, forward }
})
const cleanup = (): void => {
process.off('SIGHUP', ignoreHangup)
for (const { signal, forward } of forwards) {
process.off(signal, forward)
}
}
child.once('error', (error) => {
cleanup()
console.error('orcad: could not start the bundled runtime:', error.message)
process.exit(78)
})
child.once('exit', (code, signal) => {
cleanup()
if (signal && process.platform !== 'win32') {
process.kill(process.pid, signal)
return
}
process.exit(code ?? (signal ? 128 + constants.signals[signal] : 1))
})
return true
}
+33 -1
View File
@@ -1,7 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { flushOrcadProfileStoreForShutdown } from './orcad-lifecycle'
import {
flushOrcadProfileStoreForShutdown,
installOrcadShutdownSignals,
ORCAD_SHUTDOWN_DEADLINE_MS
} from './orcad-lifecycle'
describe('orcad profile-state shutdown', () => {
it('keeps one bounded shutdown even when stop signals repeat', () => {
vi.useFakeTimers()
let signal: (() => void) | undefined
vi.spyOn(process, 'on').mockImplementation((event, listener) => {
if (event === 'SIGTERM') {
signal = listener
}
return process
})
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('shutdown deadline')
})
vi.spyOn(console, 'error').mockImplementation(() => {})
const stop = vi.fn(() => new Promise<void>(() => {}))
try {
installOrcadShutdownSignals(stop)
signal?.()
signal?.()
expect(stop).toHaveBeenCalledOnce()
expect(exit).not.toHaveBeenCalled()
expect(() => vi.advanceTimersByTime(ORCAD_SHUTDOWN_DEADLINE_MS)).toThrow('shutdown deadline')
expect(exit).toHaveBeenCalledWith(1)
} finally {
vi.restoreAllMocks()
vi.useRealTimers()
}
})
it('flushes durably before closing the profile store', async () => {
const events: string[] = []
const store = {
+15 -51
View File
@@ -16,15 +16,13 @@ import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environ
import { setSecretStore, type SecretStore } from '../../shared/secret-store'
import type { ServeReadiness } from '../server/serve-readiness'
import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths'
import { describeOrcadBindExposure, resolveOrcadBindHost } from './orcad-bind-address'
import {
describeOrcadBindExposure,
OrcadBindAddressError,
resolveOrcadBindHost
} from './orcad-bind-address'
import { OrcadInstanceLockError } from './orcad-instance-lock'
import { flushOrcadProfileStoreForShutdown, startOrcadWithHost } from './orcad-lifecycle'
flushOrcadProfileStoreForShutdown,
installOrcadShutdownSignals,
startOrcadWithHost
} from './orcad-lifecycle'
import { parseArgs } from './orcad-command-arguments'
import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access'
import {
changedAiVaultSearchSettings,
type AiVaultSearchSettings
@@ -369,52 +367,18 @@ async function startOrcadRuntime(
* supervision contract has to prevent, so systemd's `RestartPreventExitStatus` needs a code
* that means "do not retry" and nothing else does.
*/
export const ORCAD_EXIT_OK = 0
export const ORCAD_EXIT_FAILED = 1
export const ORCAD_EXIT_CONFIGURATION = 78
export {
ORCAD_EXIT_OK,
ORCAD_EXIT_FAILED,
ORCAD_EXIT_CONFIGURATION,
resolveOrcadExitCode
} from './orcad-exit-code'
/** Bounded so a wedged transport cannot hold a supervisor's stop past its own deadline. */
export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000
export function resolveOrcadExitCode(error: unknown): number {
return error instanceof OrcadInstanceLockError ||
error instanceof OrcadBindAddressError ||
error instanceof ProfileStateAccessError
? ORCAD_EXIT_CONFIGURATION
: ORCAD_EXIT_FAILED
}
export { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-lifecycle'
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
const handle = await startOrcad(parseArgs(argv))
let stopping = false
const shutdown = (signal: NodeJS.Signals): void => {
if (stopping) {
// Why escalate rather than ignore: a supervisor's second signal means the first
// deadline elapsed. Continuing to wait silently is what makes a stop hang until
// SIGKILL, which is the one teardown that skips the daemon handoff entirely.
console.error(`orcad: second ${signal} during shutdown — exiting immediately`)
process.exit(ORCAD_EXIT_FAILED)
}
stopping = true
// Why a self-imposed deadline as well: the supervisor's SIGKILL leaves no exit code and
// no log line. Exiting ourselves keeps the failure attributable.
const deadline = setTimeout(() => {
console.error(
`orcad: shutdown after ${signal} exceeded ${ORCAD_SHUTDOWN_DEADLINE_MS}ms — exiting`
)
process.exit(ORCAD_EXIT_FAILED)
}, ORCAD_SHUTDOWN_DEADLINE_MS)
deadline.unref()
handle
.stop()
.then(() => process.exit(ORCAD_EXIT_OK))
// Why not rethrow: we are already tearing down on a signal, and an exit code is
// the only thing a supervisor can act on.
.catch((error) => {
console.error(`orcad: shutdown after ${signal} failed:`, error)
process.exit(ORCAD_EXIT_FAILED)
})
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
const startup = startOrcad(parseArgs(argv))
installOrcadShutdownSignals(async () => (await startup).stop())
await startup
}
+18
View File
@@ -0,0 +1,18 @@
import { OrcadBindAddressError } from './orcad-bind-address'
import { OrcadBundledRuntimeError } from './orcad-bundled-runtime'
import { OrcadInstanceLockError } from './orcad-instance-lock'
import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access'
export const ORCAD_EXIT_OK = 0
export const ORCAD_EXIT_FAILED = 1
export const ORCAD_EXIT_CONFIGURATION = 78
/** Configuration faults cannot be repaired by a supervisor restart. */
export function resolveOrcadExitCode(error: unknown): number {
return error instanceof OrcadInstanceLockError ||
error instanceof OrcadBindAddressError ||
error instanceof OrcadBundledRuntimeError ||
error instanceof ProfileStateAccessError
? ORCAD_EXIT_CONFIGURATION
: ORCAD_EXIT_FAILED
}
@@ -13,6 +13,7 @@ import { startOrcadWithLifecycle } from './orcad-lifecycle'
import { OrcadBindAddressError } from './orcad-bind-address'
import { OrcadInstanceLockError } from './orcad-instance-lock'
import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access'
import { OrcadBundledRuntimeError } from './orcad-bundled-runtime'
describe('parseArgs', () => {
it('accepts --bind and leaves it unset when absent', () => {
@@ -43,6 +44,9 @@ describe('resolveOrcadExitCode', () => {
ORCAD_EXIT_CONFIGURATION
)
expect(resolveOrcadExitCode(new Error('port in use'))).toBe(ORCAD_EXIT_FAILED)
expect(resolveOrcadExitCode(new OrcadBundledRuntimeError('partial installation'))).toBe(
ORCAD_EXIT_CONFIGURATION
)
expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED)
})
})
+43
View File
@@ -1,11 +1,16 @@
import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
import { resolveOrcadBrowserProvider } from './orcad-browser-provider'
import { acquireOrcadInstanceLock } from './orcad-instance-lock'
import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime'
import { resolveOrcadExitCode } from './orcad-exit-code'
import {
acquireProfileStateRuntimeAdmission,
type ProfileStateRuntimeAdmission
} from '../persistence/profile-state/profile-state-access'
const bundledLauncherChannel = process.env[ORCAD_BUNDLED_LAUNCHER_ENV] === '1'
delete process.env[ORCAD_BUNDLED_LAUNCHER_ENV]
function createIdempotentOrcadCleanup(cleanup: () => Promise<void>): () => Promise<void> {
let completion: Promise<void> | null = null
return () => {
@@ -14,6 +19,44 @@ 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. */
export function installOrcadShutdownSignals(
stop: () => Promise<void>,
deadlineMs = ORCAD_SHUTDOWN_DEADLINE_MS
): void {
let stopping = false
const shutdown = (signal: string): void => {
if (stopping) {
return
}
stopping = true
setTimeout(() => {
console.error(`orcad: shutdown after ${signal} exceeded ${deadlineMs}ms — exiting`)
process.exit(1)
}, deadlineMs)
stop()
.then(() => process.exit(0))
.catch((error) => {
console.error(`orcad: shutdown after ${signal} failed:`, error)
process.exit(resolveOrcadExitCode(error))
})
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
// Headless runtimes survive terminal hangups; INT/TERM are the graceful stop contract.
if (process.platform !== 'win32') {
process.on('SIGHUP', () => {})
}
if (bundledLauncherChannel && typeof process.send === 'function') {
process.once('disconnect', () => shutdown('launcher disconnect'))
if (!process.connected) {
shutdown('launcher disconnect')
}
}
}
export async function startOrcadWithLifecycle<T extends object>(
start: (registerRuntimeCleanup: (cleanup: () => Promise<void>) => void) => Promise<T>,
cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise<void>
+5
View File
@@ -5,6 +5,7 @@
* testable apart from the detection (what the verdict is).
*/
import process from 'node:process'
import { canUseBunPty } from '../daemon/pty-subprocess/bun-pty-process-capabilities'
import { setRuntimeTerminalUnavailableCause } from '../runtime/native-terminal-availability'
import { terminalUnavailableMessage } from '../../shared/runtime-types'
import {
@@ -37,6 +38,10 @@ export type NativePreflightHooks = {
* sentence printed here.
*/
export function runOrcadNativePreflight(hooks: NativePreflightHooks = {}): boolean {
if (!hooks.check && canUseBunPty()) {
setRuntimeTerminalUnavailableCause(null)
return true
}
const check = hooks.check ?? checkNodePtyPrecondition
const warn = hooks.warn ?? ((message: string) => console.warn(message))
const fail = hooks.fail ?? ((message: string) => console.error(message))
@@ -0,0 +1,197 @@
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process'
import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime'
import { ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight'
import { OrcadBundledRuntimeError } from './orcad-bundled-runtime'
import { resolveOrcadExitCode } from './orcad-exit-code'
import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight'
const fixture = vi.hoisted(() => ({
identity: vi.fn(),
readVersion: vi.fn(),
sql: vi.fn(),
native: vi.fn(),
run: vi.fn<(spec: ProcessSpec) => Promise<ProcessResult>>()
}))
vi.mock('./orcad-artifact-identity', () => ({ readOrcadArtifactIdentity: fixture.identity }))
vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => '/slot' }))
vi.mock('node:fs/promises', () => ({ readFile: fixture.readVersion }))
vi.mock('../persistence/profile-state/profile-state-runtime-preflight', () => ({
preflightProfileStateRuntime: fixture.sql
}))
vi.mock('./orcad-bun-native-preflight', () => ({
preflightOrcadBunNativeRuntime: fixture.native
}))
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: fixture.run }))
const identity = '0.1.0+aaaaaaaaaaaa'
const nonce = '743bf9c8-2e58-4c79-a0ac-52c8d3e8e103'
function readyResult(challenge: string | undefined): ProcessResult {
return {
code: 0,
signal: null,
timedOut: false,
stderr: '',
stdout: JSON.stringify({
type: 'orca_profile_state_ready',
nonce: challenge,
runtime: 'bun',
runtimeVersion: ORCAD_BUN_VERSION,
artifactVersion: identity,
sqliteVersion: '3.53.2',
revision: 1
})
}
}
beforeEach(() => {
vi.spyOn(process, 'versions', 'get').mockReturnValue({
...process.versions,
bun: ORCAD_BUN_VERSION
})
fixture.identity.mockResolvedValue(identity)
fixture.readVersion.mockResolvedValue(`${identity}\n`)
fixture.sql.mockResolvedValue({ sqliteVersion: '3.53.2', revision: 1 })
fixture.native.mockResolvedValue(undefined)
fixture.run.mockImplementation(async (spec) => readyResult(spec.args?.[2]))
})
afterEach(() => {
vi.restoreAllMocks()
vi.resetAllMocks()
})
describe('bundled Orca startup readiness', () => {
it.each(['win32', 'darwin', 'linux'] as const)(
'isolates native process state in the exact bundled %s executable',
async (platform) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
await preflightBundledOrcadStartup()
expect(fixture.run).toHaveBeenCalledOnce()
expect(fixture.run).toHaveBeenCalledWith({
program: join('/slot', platform === 'win32' ? 'bun-runtime.exe' : 'bun-runtime'),
args: [join('/slot', 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, expect.any(String)],
env: expect.objectContaining({ ORCA_BACKGROUND_LAUNCH: '1' }),
timeoutMs: 90_000,
maxOutputBytes: 64 * 1024,
terminationBarrier: true
})
expect(fixture.sql).not.toHaveBeenCalled()
expect(fixture.native).not.toHaveBeenCalled()
}
)
it('leaves legacy Node startup on its existing readiness path', async () => {
const { bun: _bun, ...versions } = process.versions
vi.spyOn(process, 'versions', 'get').mockReturnValue(versions)
await preflightBundledOrcadStartup()
expect(fixture.identity).not.toHaveBeenCalled()
expect(fixture.run).not.toHaveBeenCalled()
})
it('hashes installed bytes only in the isolated child', async () => {
await preflightBundledOrcadStartup()
expect(fixture.identity).not.toHaveBeenCalled()
vi.spyOn(console, 'log').mockImplementation(() => {})
await runOrcadProfilePreflight(nonce, { nativeFeatures: false })
expect(fixture.identity).toHaveBeenCalledOnce()
})
it.each(['missing artifact', 'corrupt build target'])(
'classifies %s as a configuration fault before testing SQLite',
async (message) => {
fixture.identity.mockRejectedValue(new Error(message))
const failure = await runOrcadProfilePreflight(nonce).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(OrcadBundledRuntimeError)
expect(resolveOrcadExitCode(failure)).toBe(78)
expect(fixture.sql).not.toHaveBeenCalled()
}
)
it('classifies changed artifact bytes as configuration faults on normal startup', async () => {
fixture.readVersion.mockResolvedValue('0.1.0+bbbbbbbbbbbb')
await expect(preflightBundledOrcadStartup()).rejects.toThrow(OrcadBundledRuntimeError)
})
it.each([undefined, 'broken-version'])(
'classifies an unreadable or malformed version marker as configuration: %s',
async (version) => {
if (version === undefined) {
fixture.readVersion.mockRejectedValue(new Error('ENOENT'))
} else {
fixture.readVersion.mockResolvedValue(version)
}
await expect(preflightBundledOrcadStartup()).rejects.toThrow(OrcadBundledRuntimeError)
expect(fixture.run).not.toHaveBeenCalled()
}
)
it('awaits probe termination before permitting server startup', async () => {
const exit = Promise.withResolvers<ProcessResult>()
fixture.run.mockReturnValue(exit.promise)
let admitted = false
const startup = preflightBundledOrcadStartup().then(() => {
admitted = true
})
await vi.waitFor(() => expect(fixture.run).toHaveBeenCalledOnce())
expect(admitted).toBe(false)
exit.resolve(readyResult(fixture.run.mock.calls[0]?.[0].args?.[2]))
await startup
expect(admitted).toBe(true)
})
it.each([{ code: 78 }, { timedOut: true }, { outputTruncated: true }])(
'refuses a failed child even if it emitted a valid readiness reply: %j',
async (failure) => {
fixture.run.mockImplementation(async (spec) => ({
...readyResult(spec.args?.[2]),
...failure,
stderr: 'native probe failed'
}))
await expect(preflightBundledOrcadStartup()).rejects.toThrow('native probe failed')
}
)
it('preserves configuration exit status from the isolated child', async () => {
fixture.run.mockImplementation(async (spec) => ({ ...readyResult(spec.args?.[2]), code: 78 }))
const failure = await preflightBundledOrcadStartup().catch((error: unknown) => error)
expect(resolveOrcadExitCode(failure)).toBe(78)
})
it('keeps transient SQLite readiness failures retryable', async () => {
fixture.sql.mockRejectedValue(new Error('SQLITE_BUSY'))
const failure = await runOrcadProfilePreflight(nonce).catch((error: unknown) => error)
expect(resolveOrcadExitCode(failure)).toBe(1)
})
it('leaves optional native probes to runtime health on normal startup', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {})
await runOrcadProfilePreflight(nonce, { nativeFeatures: false })
expect(fixture.sql).toHaveBeenCalledOnce()
expect(fixture.native).toHaveBeenCalledWith({ nativeFeatures: false })
})
it('rejects stale output from a different challenge', async () => {
fixture.run.mockResolvedValue(readyResult(nonce))
await expect(preflightBundledOrcadStartup()).rejects.toThrow('invalid readiness identity')
})
it('rechecks the child artifact identity against the verified installed version', async () => {
fixture.run.mockImplementation(async (spec) => {
const result = readyResult(spec.args?.[2])
return { ...result, stdout: result.stdout.replace(identity, '0.1.0+bbbbbbbbbbbb') }
})
await expect(preflightBundledOrcadStartup()).rejects.toThrow('invalid readiness identity')
})
it('runs disposable probes directly in the command child without recursive spawning', async () => {
const output = vi.spyOn(console, 'log').mockImplementation(() => {})
await runOrcadProfilePreflight(nonce)
expect(fixture.sql).toHaveBeenCalledOnce()
expect(fixture.native).toHaveBeenCalledOnce()
expect(fixture.run).not.toHaveBeenCalled()
expect(output).toHaveBeenCalledWith(readyResult(nonce).stdout)
})
})
+93
View File
@@ -0,0 +1,93 @@
import { z } from 'zod'
import { randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { preflightProfileStateRuntime } from '../persistence/profile-state/profile-state-runtime-preflight'
import {
ORCAD_STARTUP_PREFLIGHT_FLAG,
ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS,
parseOrcadProfilePreflight,
orcadProfilePreflightResponseSchema,
type OrcadProfilePreflightResponse
} from '../../shared/orcad-profile-preflight'
import { readOrcadArtifactIdentity } from './orcad-artifact-identity'
import { resolveOrcadInstallRoot } from './orcad-app-paths'
import { ORCAD_VERSION_FILENAME, orcadBunRuntimeFilename } from '../../shared/orcad-artifacts'
import { ORCAD_BUN_VERSION } from '../../shared/orcad-bun-runtime'
import { runProcess } from '../../shared/child-process/run-process'
import { preflightOrcadBunNativeRuntime } from './orcad-bun-native-preflight'
import { OrcadBundledRuntimeError } from './orcad-bundled-runtime'
/** Check every packaged start before a profile index, data-root lock or import is touched. */
export async function preflightBundledOrcadStartup(): Promise<void> {
if (!process.versions.bun) {
return
}
const directory = resolveOrcadInstallRoot()
const identity = await readInstalledVersion(directory)
const nonce = randomUUID()
// Keep disposable SQLite ownership and native state out of the serving process.
const result = await runProcess({
program: join(directory, orcadBunRuntimeFilename(process.platform)),
args: [join(directory, 'orcad.js'), ORCAD_STARTUP_PREFLIGHT_FLAG, nonce],
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS,
maxOutputBytes: 64 * 1024,
terminationBarrier: true
})
if (result.code !== 0 || result.timedOut || result.outputTruncated) {
const Failure = result.code === 78 ? OrcadBundledRuntimeError : Error
throw new Failure(`The bundled Orca runtime failed readiness: ${result.stderr}`)
}
try {
parseOrcadProfilePreflight(result.stdout, nonce, ORCAD_BUN_VERSION, identity)
} catch (cause) {
throw new OrcadBundledRuntimeError('The bundled runtime returned invalid readiness identity', {
cause
})
}
}
/** Only disposable state is opened; no server, profile index or host adapters are installed. */
export async function runOrcadProfilePreflight(
nonce: string | undefined,
options: { nativeFeatures?: boolean } = {}
): Promise<void> {
const checkedNonce = z.string().uuid().parse(nonce)
let artifactVersion: string
try {
artifactVersion = await readOrcadArtifactIdentity(resolveOrcadInstallRoot())
} catch (cause) {
throw new OrcadBundledRuntimeError('The bundled Orca artifacts are incomplete or altered', {
cause
})
}
const result = await preflightProfileStateRuntime()
if (process.versions.bun) {
await preflightOrcadBunNativeRuntime(options)
}
const response: OrcadProfilePreflightResponse = {
type: 'orca_profile_state_ready',
nonce: checkedNonce,
runtime: process.versions.bun ? 'bun' : 'node',
runtimeVersion: process.versions.bun ?? process.versions.node,
artifactVersion,
...result
}
console.log(JSON.stringify(response))
}
async function readInstalledVersion(directory: string): Promise<string> {
try {
return orcadProfilePreflightResponseSchema.shape.artifactVersion.parse(
(await readFile(join(directory, ORCAD_VERSION_FILENAME), 'utf8')).trim()
)
} catch (cause) {
throw new OrcadBundledRuntimeError(
'The installed Orca artifact version is missing or invalid',
{
cause
}
)
}
}
@@ -1,12 +1,12 @@
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushActiveProfileBeforeFileMutation } from '../../orca-profiles/profile-persistence-deadline'
import { openProfileStateDatabaseReadOnly } from '../profile-state/profile-state-database'
import { readProfileStateSnapshot } from '../profile-state/profile-state-documents'
import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path'
import * as snapshots from '../profile-state/profile-state-database-snapshot'
import * as backupExecution from '../profile-state/profile-state-backup-worker'
import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority'
import { Store } from './store'
import { scheduleSave } from './write-scheduling'
@@ -42,6 +42,10 @@ const HOUR = 60 * 60 * 1000
const fixtures: { directory: string; store: Store; authority: ProfileStateSqliteAuthority }[] = []
const releases: (() => void)[] = []
beforeEach(() => {
vi.spyOn(backupExecution, 'runProfileStateBackup')
})
afterEach(async () => {
for (const release of releases.splice(0)) {
release()
@@ -136,17 +140,15 @@ describe('Store automatic SQLite recovery snapshots', () => {
it('acknowledges a routine flush while the previous recovery backup is still running', async () => {
const state = await fixture()
const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync
const realSnapshot = backupExecution.runProfileStateBackup
const started = Promise.withResolvers<void>()
const gate = Promise.withResolvers<void>()
releases.push(gate.resolve)
vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce(
async (db, target) => {
started.resolve()
await gate.promise
await realSnapshot(db, target)
}
)
vi.spyOn(backupExecution, 'runProfileStateBackup').mockImplementationOnce(async (job) => {
started.resolve()
await gate.promise
await realSnapshot(job)
})
state.store.updateSettings({ theme: 'dark' })
state.store.flushOrThrow()
await started.promise
@@ -168,7 +170,7 @@ describe('Store automatic SQLite recovery snapshots', () => {
'%s waits for its owned backup across Store close',
async (kind) => {
const state = await fixture()
const realSnapshot = snapshots.writeProfileStateDatabaseSnapshotAsync
const realSnapshot = backupExecution.runProfileStateBackup
let begin: () => void = () => {}
let release: () => void = () => {}
const started = new Promise<void>((resolve) => {
@@ -178,13 +180,11 @@ describe('Store automatic SQLite recovery snapshots', () => {
release = resolve
})
releases.push(release)
vi.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync').mockImplementationOnce(
async (db, target) => {
begin()
await gate
await realSnapshot(db, target)
}
)
vi.spyOn(backupExecution, 'runProfileStateBackup').mockImplementationOnce(async (job) => {
begin()
await gate
await realSnapshot(job)
})
state.store.updateSettings({ theme: 'dark' })
state.store.flushOrThrow()
await started
@@ -222,7 +222,8 @@ describe('Store automatic SQLite recovery snapshots', () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
const failure = new Error('injected backup disk failure')
const snapshot = vi
.spyOn(snapshots, 'writeProfileStateDatabaseSnapshotAsync')
.spyOn(backupExecution, 'runProfileStateBackup')
.mockClear()
.mockRejectedValueOnce(failure)
state.store.updateSettings({ theme: 'dark' })
if (flush === 'sync') {

Some files were not shown because too many files have changed in this diff Show More