mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
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:
@@ -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
|
||||
@@ -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{,/**/*}',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
: [])
|
||||
])
|
||||
])
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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' })
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user