fix: harden npm server lifecycle and release flow

This commit is contained in:
Jinwoo-H
2026-08-12 22:45:01 -04:00
committed by Jinwoo-H
parent 91c8468da9
commit af6fe0acf3
25 changed files with 600 additions and 166 deletions
+4 -3
View File
@@ -716,6 +716,7 @@ jobs:
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
# assumptions and any lifecycle scripts that would run on bump).
npm version "$VERSION" --no-git-tag-version --allow-same-version
npm --prefix resources/npm-server version "$VERSION" --no-git-tag-version --allow-same-version
# Why: the cut is the only point where committed skill bytes become a
# released revision. Without this row the ledger never advances, so the
# next skill change rebuilds the revision this tag ships over different
@@ -728,7 +729,7 @@ jobs:
echo "::error::Refusing to record release provenance for v$VERSION: the committed skill artifacts do not match this ref. Land a regeneration on main, then re-run the cut." >&2
exit 1
fi
git add package.json resources/skills/release-mapping.json
git add package.json resources/npm-server/package.json resources/skills/release-mapping.json
commit_message="release: v$VERSION"
if [[ "$EVENT_NAME" == "schedule" ]]; then
commit_message="$commit_message [rc-slot:$SLOT]"
@@ -752,9 +753,9 @@ jobs:
# -m --first-parent: plain diff-tree prints NOTHING for a merge commit,
# which would make this guard pass silently rather than fail closed.
committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD |
grep -vxF -e 'package.json' -e 'resources/skills/release-mapping.json' || true)"
grep -vxF -e 'package.json' -e 'resources/npm-server/package.json' -e 'resources/skills/release-mapping.json' || true)"
if [[ -n "$committed" ]]; then
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json and the skill release-mapping row may ship in a version commit." >&2
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package manifests and the skill release-mapping row may ship in a version commit." >&2
exit 1
fi
git tag -a "v$VERSION" -m "v$VERSION"
@@ -565,13 +565,13 @@ describe('skill bundle manifest generator', () => {
const bumpStep = runSteps.find((step) => step.name === 'Bump package.json and tag')
// The load-bearing check: whatever staged it and however the commit was
// spelled, only these two paths may ship. Asserted on the commit rather than
// spelled, only these paths may ship. Asserted on the commit rather than
// the index because `git commit -a/-i/--only/<pathspec>` bypasses the index.
// -F is part of the contract; without it `.` admits a path like packageXjson.
// Flags pinned, not just the command: a `--diff-filter` slipped in here would
// silence modifications, and dropping -m makes a merge commit report nothing.
expect(bumpStep.run).toMatch(
/git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD\s*\|\s*grep -vxF -e 'package\.json' -e 'resources\/skills\/release-mapping\.json'/
/git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD\s*\|\s*grep -vxF -e 'package\.json' -e 'resources\/npm-server\/package\.json' -e 'resources\/skills\/release-mapping\.json'/
)
expect(bumpStep.run.indexOf('grep -vxF')).toBeLessThan(bumpStep.run.indexOf('git tag'))
// ...and that it aborts. A guard degraded to a warning still reads as covered.
@@ -13,6 +13,7 @@ import {
startNodeServerVerifierProcess,
stopNodeServerVerifierProcess,
terminateNodeServerVerifierProcess,
verifyNodeServerStartupCancellation,
type NodeServerVerifierProcess
} from './node-server-verifier-server-process'
const cliPath = resolve(readArgument('--cli') ?? 'resources/npm-server/dist/cli.js')
@@ -29,6 +30,7 @@ async function main(): Promise<void> {
try {
initializeNodeServerVerifierGitWorkspace(gitPath)
mkdirSync(folderPath)
await verifyNodeServerStartupCancellation({ cliPath, dataPath })
const first = await startNodeServerVerifierProcess({ cliPath, dataPath })
activeServer = first
@@ -22,30 +22,60 @@ export type NodeServerVerifierProcess = {
stderr: string[]
}
export async function verifyNodeServerStartupCancellation(args: {
cliPath: string
dataPath: string
}): Promise<void> {
if (process.platform === 'win32') {
return
}
const child = spawn(process.execPath, serverArguments(args.cliPath), {
env: {
...process.env,
ORCA_E2E_DAEMON_INIT_DELAY_MS: '30000',
ORCA_SERVER_DATA_DIR: args.dataPath,
ORCA_STARTUP_DIAGNOSTICS: '1'
},
stdio: ['ignore', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk: string) => {
stdout += chunk
})
child.stderr?.setEncoding('utf8')
child.stderr?.on('data', (chunk: string) => {
stderr += chunk
})
try {
await waitForDaemonInitMarker(child, () => stderr)
if (!child.kill('SIGTERM')) {
throw new Error('server rejected the startup shutdown signal')
}
const result = await waitForInstalledServerExit(child)
if (!isExpectedInstalledServerStopResult(result, process.platform)) {
throw new Error(`server startup shutdown failed (${result.code ?? result.signal}): ${stderr}`)
}
if (stdout.length > 0) {
throw new Error(`server published readiness during startup shutdown: ${stdout}`)
}
} finally {
await terminateNodeServerVerifierProcess(child)
}
}
export function startNodeServerVerifierProcess(args: {
cliPath: string
dataPath: string
}): Promise<NodeServerVerifierProcess> {
return new Promise((resolveStart, rejectStart) => {
const stderr: string[] = []
const child = spawn(
process.execPath,
[
args.cliPath,
'serve',
'--port',
'0',
'--listen',
'127.0.0.1',
'--pairing-address',
'127.0.0.1',
'--json'
],
{
env: { ...process.env, ORCA_SERVER_DATA_DIR: args.dataPath },
stdio: ['ignore', 'pipe', 'pipe']
}
)
const child = spawn(process.execPath, serverArguments(args.cliPath), {
env: { ...process.env, ORCA_SERVER_DATA_DIR: args.dataPath },
stdio: ['ignore', 'pipe', 'pipe']
})
let stdout = ''
let settled = false
const timeout = setTimeout(() => fail(new Error('server readiness timed out')), 30_000)
@@ -107,6 +137,54 @@ export function startNodeServerVerifierProcess(args: {
})
}
function serverArguments(cliPath: string): string[] {
return [
cliPath,
'serve',
'--port',
'0',
'--listen',
'127.0.0.1',
'--pairing-address',
'127.0.0.1',
'--json'
]
}
function waitForDaemonInitMarker(child: ChildProcess, readStderr: () => string): Promise<void> {
return new Promise((resolveMarker, rejectMarker) => {
const timeout = setTimeout(
() => finish(new Error(`daemon init marker timed out: ${readStderr()}`)),
10_000
)
child.stderr?.on('data', onStderr)
child.once('error', finish)
child.once('exit', onExit)
function onStderr(): void {
if (readStderr().includes('[startup] daemon-init-start')) {
finish()
}
}
function onExit(code: number | null): void {
finish(new Error(`server exited before daemon init marker (${code}): ${readStderr()}`))
}
function finish(error?: unknown): void {
clearTimeout(timeout)
child.stderr?.off('data', onStderr)
child.off('error', finish)
child.off('exit', onExit)
if (error) {
rejectMarker(error)
} else {
resolveMarker()
}
}
})
}
export async function terminateNodeServerVerifierProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) {
return
@@ -328,7 +328,7 @@ describe('Electron runtime package contract', () => {
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
})
it('advances only the skill release ledger in a taggable release-cut commit', () => {
it('keeps package manifests synchronized in a taggable release-cut commit', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
@@ -354,15 +354,22 @@ describe('Electron runtime package contract', () => {
const mentioned = new Set(commands.match(/resources[/\\]skills[^\s'"]*/g))
expect(checkoutStep.with['fetch-depth']).toBe(0)
expect(bumpIndex).toBeGreaterThanOrEqual(0)
expect(bumpStep.run).toContain(
'npm --prefix resources/npm-server version "$VERSION" --no-git-tag-version --allow-same-version'
)
// Why: the cut is the only point that advances the release ledger, so this
// tag's revision is never rebuilt later — it appends that row, nothing else.
expect(generateIndex).toBeGreaterThan(bumpIndex)
expect(bumpStep.run.indexOf('git add package.json')).toBeGreaterThan(generateIndex)
expect(stagedPaths).toEqual(['package.json', 'resources/skills/release-mapping.json'])
expect(stagedPaths).toEqual([
'package.json',
'resources/npm-server/package.json',
'resources/skills/release-mapping.json'
])
// Every distinct mention must be staged, so a copy, a redirect, or a path
// held in a variable cannot reach the content-addressed artifacts. Matched
// without a trailing slash so `dir="resources/skills"` still counts.
expect([...mentioned]).toEqual(stagedPaths.slice(1))
expect([...mentioned]).toEqual(['resources/skills/release-mapping.json'])
// Regeneration is banned job-wide by the generator suite. Here: `-a`, `-am`,
// and `--all` sweep unstaged artifacts in; `--allow-empty` below must not.
expect(commands).not.toMatch(/\bcommit\b[^\n]*(?:\s-[a-z]*a[a-z]*\b|\s--all\b)/)
+31 -14
View File
@@ -202,10 +202,11 @@ considered only after the server build and native dependency matrix pass there.
## Shutdown and persistence
SIGINT, SIGTERM, and startup failure join one sequential teardown: stop RPC listeners; cancel local
or SSH prechecks, abort and drain headless automations, and wait for AgentHook shutdown through the
composition root; disconnect from the terminal daemon; flush the store; then release the profile
lock. A failed step is recorded while later steps still run. A normal server shutdown does not kill
SIGINT and SIGTERM synchronously cancel startup before joining its current phase. Teardown then
stops RPC listeners; cancels local or SSH prechecks, aborts and drains headless automations, and
waits for AgentHook shutdown through the composition root; disconnects from the terminal daemon;
flushes the store; and releases the profile lock. Startup failures join the same coordinator. A
failed step is recorded while later steps still run. A normal server shutdown does not kill
daemon-owned terminals. Startup reconnects to the same daemon and device identity.
Every listener, socket, timer, and signal handler installed by the Node composition root has a
@@ -316,6 +317,8 @@ the terminal marker.
- [x] Omit browser capabilities and fail remote browser requests explicitly.
- [x] Join RPC stop, composition drain, daemon disconnect, store flush, and lock release in order on
signals and startup errors.
- [x] Cancel startup before signal teardown and prove pre-readiness SIGTERM prints no credential,
exits cleanly, and leaves the profile reusable.
### CLI and networking
@@ -397,46 +400,60 @@ the terminal marker.
The installed-tarball history covers its inventory, executable, license, dependency, web-client,
real E2EE, workspace, Git, PTY, shutdown, and restart-continuity oracle on macOS; Ubuntu 20.04,
22.04, and 24.04 amd64; and Ubuntu 20.04 arm64. The final package was rerun on macOS arm64 and
Ubuntu 20.04 amd64. The Ubuntu 20.04 runs used stock Git 2.25.1 and verified the active native PTY
against glibc 2.31 without a compiler, Electron, Chromium, Xvfb, or FUSE installed.
22.04, and 24.04 amd64; and Ubuntu 20.04 arm64. The final package was rerun on macOS arm64 and all
three amd64 Ubuntu versions. The Ubuntu 20.04 runs used stock Git 2.25.1 and verified the active
native PTY against glibc 2.31 without a compiler, Electron, Chromium, Xvfb, or FUSE installed.
The current focused selector passed 525 tests across 22 server, RPC, cross-version wire, onboarding,
browserless composition, automation, precheck, profile-lock, readiness, installed-process,
AgentHook, and shutdown files. The two workflow contract files passed 17 tests:
The current focused selector passed 552 tests across 31 server, RPC, cross-version wire,
onboarding, browserless composition, automation, precheck, profile-lock, readiness,
installed-process, AgentHook, and shutdown files. Four workflow and release-contract files passed
62 tests with one platform-specific skip:
```bash
pnpm exec vitest run --config config/vitest.config.ts \
config/scripts/node-server-installed-process-harness.test.ts \
config/scripts/node-server-verifier-daemon-cleanup.test.ts \
config/scripts/node-server-verifier-git-fixture.test.ts \
config/scripts/node-server-verifier-host-path.test.ts \
src/cli/runtime-client-deferral.test.ts \
src/main/agent-hooks/server.test.ts \
src/main/automations/headless-completion-settlement.test.ts \
src/main/automations/headless-work-drain.test.ts \
src/main/automations/precheck-runner.test.ts \
src/main/automations/service-precheck.test.ts \
src/main/automations/service.test.ts \
src/main/runtime/pairing-endpoint.test.ts \
src/main/runtime/runtime-rpc.test.ts \
src/main/server/serve-readiness.test.ts \
src/node-server/browserless-automation-dispatcher.test.ts \
src/node-server/browserless-runtime-composition.test.ts \
src/node-server/package-cli-deferral.test.ts \
src/node-server/server-address-discovery.test.ts \
src/node-server/server-bind-host.test.ts \
src/node-server/server-cli-arguments.test.ts \
src/node-server/server-paths.test.ts \
src/node-server/server-profile-environment.test.ts \
src/node-server/server-profile-process-lock.test.ts \
src/node-server/server-shutdown-coordinator.test.ts \
src/node-server/server-signal-shutdown.test.ts \
src/node-server/server-websocket-readiness.test.ts \
src/node-server/server-window-graph.test.ts \
src/renderer/src/components/settings/NodeServerSetupCallout.test.tsx \
src/renderer/src/components/settings/RuntimeHostAccessForm.test.tsx \
src/renderer/src/components/sidebar/AddRemoteHostFields.test.tsx \
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
pnpm exec vitest run --config config/vitest.config.ts \
config/scripts/pr-workflow-parallelism.test.mjs \
config/scripts/node-server-package-workflow-contract.test.mjs
config/scripts/node-server-package-workflow-contract.test.mjs \
config/scripts/generate-skill-bundle-manifest.test.mjs \
config/scripts/package-electron-runtime-contract.test.mjs
```
The final macOS arm64 installed-tarball oracle passed with 18,114,920 packed bytes and 55,066,757
unpacked bytes. Ubuntu 20.04, 22.04, and 24.04 amd64 passed the same installed runtime oracle in
Docker; Ubuntu 20.04 also verified the glibc 2.31 and `GLIBCXX_3.4.28` floors.
The final macOS arm64 installed-tarball oracle passed with 18,203,728 packed bytes and 55,879,495
unpacked bytes. Its POSIX startup oracle also sent SIGTERM before readiness, observed no credential
output, and restarted the same profile successfully. Ubuntu 20.04, 22.04, and 24.04 amd64 passed
the same installed runtime oracle in Docker; Ubuntu 20.04 also verified the glibc 2.31 and
`GLIBCXX_3.4.28` floors.
Ten fresh-profile installed-package trials on macOS arm64 reached the first schema-v1 readiness
line in 352.705–748.323 ms, with a 369.474 ms median. Recursive process snapshots at one and three
+1 -1
View File
@@ -1,6 +1,6 @@
# Orca Server
Requires Node.js 24 or newer.
Requires Node.js 24.x.
Run Orca on a development server without installing the Electron desktop application:
+15 -1
View File
@@ -928,7 +928,8 @@ export async function initDaemonPtyProvider(
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout.
const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS)
if (Number.isFinite(e2eInitDelayMs) && e2eInitDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, e2eInitDelayMs))
await waitForDaemonInitDelay(e2eInitDelayMs, signal)
signal?.throwIfAborted()
}
const runtimeDir = getRuntimeDir()
@@ -1037,6 +1038,19 @@ export async function initDaemonPtyProvider(
await reconcileSeededClaudeLivePtys(routedAdapter)
}
function waitForDaemonInitDelay(delayMs: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timeout = setTimeout(finish, delayMs)
signal?.addEventListener('abort', finish, { once: true })
function finish(): void {
clearTimeout(timeout)
signal?.removeEventListener('abort', finish)
resolve()
}
})
}
// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token.
async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise<void> {
if (!hasSeededUnconfirmedClaudePtys()) {
+10
View File
@@ -22,6 +22,16 @@ const ready: ServeReadiness = {
}
describe('ServeReadinessPublisher', () => {
it('does not publish readiness after startup cancellation', async () => {
const write = vi.fn(async () => {})
const publisher = new ServeReadinessPublisher(write)
await expect(
publisher.publish(ready, { mode: 'human' }, AbortSignal.abort())
).rejects.toMatchObject({ name: 'AbortError' })
expect(write).not.toHaveBeenCalled()
})
it('writes one complete human-readable ready block', async () => {
const write = vi.fn(async () => {})
const publisher = new ServeReadinessPublisher(write)
+6 -1
View File
@@ -37,10 +37,15 @@ export class ServeReadinessPublisher {
constructor(private readonly write: ReadinessWrite = writeStdout) {}
async publish(readiness: ServeReadiness, output: ServeReadinessOutput): Promise<void> {
async publish(
readiness: ServeReadiness,
output: ServeReadinessOutput,
signal?: AbortSignal
): Promise<void> {
if (this.state !== 'pending') {
throw new Error(`Serve readiness publication already ${this.state}`)
}
signal?.throwIfAborted()
this.state = 'publishing'
try {
await this.write(`${renderServeReadiness(readiness, output)}\n`)
@@ -123,48 +123,43 @@ describe('browserless automation dispatcher', () => {
expect(runtime.waitForTerminal).not.toHaveBeenCalled()
})
it('consumes a late worktree-create rejection without continuing dispatch', async () => {
vi.useFakeTimers()
try {
let rejectCreate!: (error: Error) => void
const create = new Promise<never>((_resolve, reject) => {
rejectCreate = reject
})
const runtime = {
createManagedWorktree: vi.fn(() => create),
launchAgentTerminal: vi.fn(),
showManagedWorktree: vi.fn(),
waitForTerminal: vi.fn(),
readTerminal: vi.fn()
} as unknown as OrcaRuntimeService
const controller = new AbortController()
const dispatch = createBrowserlessAutomationDispatcher(runtime)
const launched = dispatch({
automation: {
workspaceMode: 'new_per_run',
agentId: 'claude',
prompt: 'Run checks'
},
run: { id: 'run-1', title: 'Checks', scheduledFor: Date.now() },
target: { repo: { id: 'repo-1' } },
signal: controller.signal
} as never)
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
const rejection = expect(launched).rejects.toThrow(
'Automation stopped during server shutdown.'
)
it('joins an in-flight worktree creation before shutdown settles', async () => {
let rejectCreate!: (error: Error) => void
const create = new Promise<never>((_resolve, reject) => {
rejectCreate = reject
})
const runtime = {
createManagedWorktree: vi.fn(() => create),
launchAgentTerminal: vi.fn(),
showManagedWorktree: vi.fn(),
waitForTerminal: vi.fn(),
readTerminal: vi.fn()
} as unknown as OrcaRuntimeService
const controller = new AbortController()
const dispatch = createBrowserlessAutomationDispatcher(runtime)
let settled = false
const launched = dispatch({
automation: {
workspaceMode: 'new_per_run',
agentId: 'claude',
prompt: 'Run checks'
},
run: { id: 'run-1', title: 'Checks', scheduledFor: Date.now() },
target: { repo: { id: 'repo-1' } },
signal: controller.signal
} as never).finally(() => {
settled = true
})
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
controller.abort()
await vi.runAllTimersAsync()
await rejection
controller.abort()
await new Promise<void>((resolve) => setImmediate(resolve))
expect(settled).toBe(false)
rejectCreate(new Error('late create failure'))
await Promise.resolve()
expect(runtime.launchAgentTerminal).not.toHaveBeenCalled()
expect(runtime.waitForTerminal).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
rejectCreate(new Error('late create failure'))
await expect(launched).rejects.toThrow('Automation stopped during server shutdown.')
expect(runtime.launchAgentTerminal).not.toHaveBeenCalled()
expect(runtime.waitForTerminal).not.toHaveBeenCalled()
})
it('bounds shutdown while resolving the launched workspace display', async () => {
@@ -31,7 +31,8 @@ export function createBrowserlessAutomationDispatcher(
runtime.createManagedWorktree(
buildHeadlessAutomationWorktreeCreateArgs({ automation, run, repo: target.repo })
),
signal
signal,
'join'
)
terminalHandle = created.startupTerminal?.handle ?? ''
terminalSessionId = created.startupTerminal?.tabId ?? null
@@ -142,7 +143,8 @@ function shutdownCompletion(): {
async function awaitRuntimeOperation<T>(
operation: () => Promise<T>,
signal?: AbortSignal
signal?: AbortSignal,
shutdownSettlement: 'grace' | 'join' = 'grace'
): Promise<T> {
if (signal?.aborted) {
throw new Error(SHUTDOWN_ERROR)
@@ -171,9 +173,11 @@ async function awaitRuntimeOperation<T>(
return unwrapRuntimeOperationSettlement(first)
}
// Why: some runtime operations have no cancellation contract. Observe their
// eventual outcome, but bound shutdown and never continue after the abort.
await waitForRuntimeOperationShutdownGrace(settlement)
// Why: read-only runtime operations may lack cancellation; observe their
// outcome without letting them block shutdown indefinitely.
await (shutdownSettlement === 'join'
? settlement
: waitForRuntimeOperationShutdownGrace(settlement))
throw new Error(SHUTDOWN_ERROR)
}
+81 -56
View File
@@ -10,15 +10,22 @@ import { OrcaRuntimeRpcServer } from '../main/runtime/runtime-rpc'
import { ServeReadinessPublisher } from '../main/server/serve-readiness'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../shared/runtime-types'
import { configureNodeHostEnvironment } from './node-host-electron-facade'
import { createBrowserlessRuntimeComposition } from './browserless-runtime-composition'
import {
createBrowserlessRuntimeComposition,
type BrowserlessRuntimeComposition
} from './browserless-runtime-composition'
import { discoverServerPairingAddress } from './server-address-discovery'
import { resolveServerBindHost } from './server-bind-host'
import { parseServerCliArguments, renderServerHelp } from './server-cli-arguments'
import { ensureServerDataPath, resolveServerDataPath } from './server-paths'
import { configureServerProfileEnvironment } from './server-profile-environment'
import {
acquireServerProfileProcessLock,
ServerProfileProcessLockError
} from './server-profile-process-lock'
import { createServerWindowGraph } from './server-window-graph'
import { createServerShutdownCoordinator } from './server-shutdown-coordinator'
import { installServerSignalShutdown } from './server-signal-shutdown'
import { requireServerWebSocketEndpoint } from './server-websocket-readiness'
export async function runNodeServer(argv = process.argv.slice(2)): Promise<void> {
@@ -34,6 +41,7 @@ export async function runNodeServer(argv = process.argv.slice(2)): Promise<void>
const appPath = __dirname
const dataPath = resolveServerDataPath(args.dataPath)
configureServerProfileEnvironment(dataPath)
ensureServerDataPath(dataPath)
configureNodeHostEnvironment({ appPath, dataPath, version: packageJson.version })
process.env.ORCA_APP_VERSION = packageJson.version
@@ -43,24 +51,23 @@ export async function runNodeServer(argv = process.argv.slice(2)): Promise<void>
}
throw error
})
initDataPath()
const store = new Store({ dataFile: join(dataPath, 'orca-data.json') })
const composition = createBrowserlessRuntimeComposition({ store, dataPath })
const { runtime } = composition
let store: Store | null = null
let composition: BrowserlessRuntimeComposition | null = null
let rpc: OrcaRuntimeRpcServer | null = null
let shuttingDown = false
const shutdown = async (exitCode: number): Promise<void> => {
if (shuttingDown) {
return
}
shuttingDown = true
let startupPromise: Promise<void> | null = null
const startupAbortController = new AbortController()
const shutdown = createServerShutdownCoordinator(async (getRequestedExitCode) => {
const lifecycleResults = await settleLifecycleSteps([
() =>
startupPromise?.then(
() => undefined,
() => undefined
) ?? Promise.resolve(),
() => rpc?.stop() ?? Promise.resolve(),
() => composition.stop(),
() => composition?.stop() ?? Promise.resolve(),
() => disconnectDaemon(),
() => store.flushAsync()
() => store?.flushAsync() ?? Promise.resolve()
])
const lifecycleFailed = lifecycleResults.some((result) => result.status === 'rejected')
const lockReleased = await profileLock.release().then(
@@ -72,49 +79,63 @@ export async function runNodeServer(argv = process.argv.slice(2)): Promise<void>
return false
}
)
process.exitCode = lifecycleFailed || !lockReleased ? 1 : exitCode
}
process.exitCode = lifecycleFailed || !lockReleased ? 1 : getRequestedExitCode()
})
installServerSignalShutdown({
emitter: process,
shutdown,
exit: (exitCode) => process.exit(exitCode),
getExitCode: () => (typeof process.exitCode === 'number' ? process.exitCode : 0),
onSignal: () => startupAbortController.abort()
})
try {
await initDaemonPtyProvider(undefined, { macosLoginSessionWatch: false })
await composition.startAfterDaemon()
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, createServerWindowGraph(store))
const pairingAddress = await discoverServerPairingAddress(args.pairingAddress)
validatePairingAddress(pairingAddress.address)
rpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: dataPath,
enableWebSocket: true,
preferPinnedWsPort: args.port !== 0,
wsBindHost:
args.listenHost ??
(pairingAddress.source === 'tailscale'
? pairingAddress.address
: pairingAddress.source === 'explicit'
? '0.0.0.0'
: '127.0.0.1'),
wsPort: args.port,
webClientRoot: resolveWebClientRoot(appPath)
})
await rpc.start()
requireServerWebSocketEndpoint(rpc.getWebSocketEndpoint())
await publishReadiness(runtime, rpc, args, pairingAddress.address)
if (!args.json && pairingAddress.source === 'loopback' && process.env.SSH_CONNECTION) {
const resolvedPort = new URL(rpc.getWebSocketEndpoint()!).port
process.stdout.write(
`SSH tunnel: ssh -L ${resolvedPort}:127.0.0.1:${resolvedPort} ${process.env.USER ?? 'user'}@<server>\n`
)
}
const stopForSignal = (): void => {
void shutdown(0).then(() =>
process.exit(typeof process.exitCode === 'number' ? process.exitCode : 0)
)
}
process.once('SIGINT', stopForSignal)
process.once('SIGTERM', stopForSignal)
initDataPath()
store = new Store({ dataFile: join(dataPath, 'orca-data.json') })
composition = createBrowserlessRuntimeComposition({ store, dataPath })
const activeStore = store
const activeComposition = composition
const { runtime } = activeComposition
startupPromise = (async () => {
const { signal } = startupAbortController
signal.throwIfAborted()
await initDaemonPtyProvider(signal, { macosLoginSessionWatch: false })
signal.throwIfAborted()
await activeComposition.startAfterDaemon()
signal.throwIfAborted()
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, createServerWindowGraph(activeStore))
const pairingAddress = await discoverServerPairingAddress(args.pairingAddress)
signal.throwIfAborted()
validatePairingAddress(pairingAddress.address)
rpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: dataPath,
enableWebSocket: true,
preferPinnedWsPort: args.port !== 0,
wsBindHost: resolveServerBindHost(args.listenHost, pairingAddress),
wsPort: args.port,
webClientRoot: resolveWebClientRoot(appPath)
})
await rpc.start()
signal.throwIfAborted()
requireServerWebSocketEndpoint(rpc.getWebSocketEndpoint())
await publishReadiness(runtime, rpc, args, pairingAddress.address, signal)
signal.throwIfAborted()
if (!args.json && pairingAddress.source === 'loopback' && process.env.SSH_CONNECTION) {
const resolvedPort = new URL(rpc.getWebSocketEndpoint()!).port
process.stdout.write(
`SSH tunnel (run locally with the same SSH target): ssh -L ${resolvedPort}:127.0.0.1:${resolvedPort} ${process.env.USER ?? 'user'}@<same-ssh-host>\n`
)
}
})()
await startupPromise
} catch (error) {
await shutdown(1)
throw error
if (startupAbortController.signal.aborted) {
await shutdown(0)
} else {
await shutdown(1)
throw error
}
}
}
@@ -149,8 +170,10 @@ async function publishReadiness(
runtime: OrcaRuntimeService,
rpc: OrcaRuntimeRpcServer,
args: ReturnType<typeof parseServerCliArguments>,
pairingAddress: string
pairingAddress: string,
signal: AbortSignal
): Promise<void> {
signal.throwIfAborted()
const boundEndpoint = requireServerWebSocketEndpoint(rpc.getWebSocketEndpoint())
const advertised = resolveAdvertisedPairingEndpoint(boundEndpoint, pairingAddress)
const pairing = args.noPairing
@@ -160,6 +183,7 @@ async function publishReadiness(
guidance: 'Restart without --no-pairing to create a client pairing offer.'
}
: rpc.createPairingOffer({ address: pairingAddress, name: 'Orca npm server', scope: 'runtime' })
signal.throwIfAborted()
await new ServeReadinessPublisher().publish(
{
runtimeId: runtime.getRuntimeId(),
@@ -178,7 +202,8 @@ async function publishReadiness(
}
: pairing
},
{ mode: args.json ? 'json' : 'human' }
{ mode: args.json ? 'json' : 'human' },
signal
)
}
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
configureProfile: vi.fn(),
controlMain: vi.fn(),
serverEntryLoaded: false
}))
vi.mock('../cli/cli-program', () => ({ main: state.controlMain }))
vi.mock('./server-profile-environment', () => ({
configureServerControlProfileEnvironment: state.configureProfile
}))
vi.mock('./index', () => {
state.serverEntryLoaded = true
return { reportNodeServerFailure: vi.fn(), runNodeServer: vi.fn() }
})
describe('package CLI module deferral', () => {
beforeEach(() => {
state.controlMain.mockReset()
state.configureProfile.mockReset()
state.serverEntryLoaded = false
})
it('does not evaluate the server entry for control CLI help', async () => {
const originalArgv = process.argv
process.argv = [process.execPath, 'cli.js', '--help']
try {
await import('./package-cli')
await vi.waitFor(() => expect(state.controlMain).toHaveBeenCalledWith(['--help']))
expect(state.configureProfile).toHaveBeenCalledOnce()
expect(state.serverEntryLoaded).toBe(false)
} finally {
process.argv = originalArgv
}
})
})
+10 -6
View File
@@ -1,7 +1,5 @@
#!/usr/bin/env node
import { main as runControlCli } from '../cli/cli-program'
import { reportNodeServerFailure, runNodeServer } from './index'
import { resolveServerDataPath } from './server-paths'
export {}
const argv = process.argv.slice(2)
const serverCommand =
@@ -12,8 +10,14 @@ const serverCommand =
argv[0] === '-v'
if (serverCommand) {
void runNodeServer(argv).catch(reportNodeServerFailure)
void import('./index').then(({ reportNodeServerFailure, runNodeServer }) =>
runNodeServer(argv).catch(reportNodeServerFailure)
)
} else {
process.env.ORCA_USER_DATA_PATH ??= resolveServerDataPath()
void runControlCli(argv)
void Promise.all([import('../cli/cli-program'), import('./server-profile-environment')]).then(
([{ main: runControlCli }, { configureServerControlProfileEnvironment }]) => {
configureServerControlProfileEnvironment()
return runControlCli(argv)
}
)
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { resolveServerBindHost } from './server-bind-host'
describe('server bind host', () => {
it('binds explicit IPv6 advertisements on an IPv6 listener', () => {
expect(resolveServerBindHost(undefined, { address: '::1', source: 'explicit' })).toBe('::')
expect(
resolveServerBindHost(undefined, {
address: 'ws://[2001:db8::4]:6768',
source: 'explicit'
})
).toBe('::')
})
it('preserves explicit listeners and existing IPv4 defaults', () => {
expect(resolveServerBindHost('127.0.0.2', { address: '::1', source: 'explicit' })).toBe(
'127.0.0.2'
)
expect(resolveServerBindHost(undefined, { address: 'host.example', source: 'explicit' })).toBe(
'0.0.0.0'
)
expect(resolveServerBindHost(undefined, { address: '100.64.1.2', source: 'tailscale' })).toBe(
'100.64.1.2'
)
expect(resolveServerBindHost(undefined, { address: '127.0.0.1', source: 'loopback' })).toBe(
'127.0.0.1'
)
})
})
+24
View File
@@ -0,0 +1,24 @@
import { isIPv6 } from 'node:net'
import { resolveAdvertisedPairingHostname } from '../main/runtime/pairing-endpoint'
type PairingAddress = {
address: string
source: 'explicit' | 'tailscale' | 'loopback'
}
export function resolveServerBindHost(
listenHost: string | undefined,
pairingAddress: PairingAddress
): string {
if (listenHost) {
return listenHost
}
if (pairingAddress.source === 'tailscale') {
return pairingAddress.address
}
if (pairingAddress.source === 'loopback') {
return '127.0.0.1'
}
const advertisedHostname = resolveAdvertisedPairingHostname(pairingAddress.address)
return advertisedHostname && isIPv6(advertisedHostname) ? '::' : '0.0.0.0'
}
@@ -0,0 +1,54 @@
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveOrcaManagedCodexHomePath } from '../main/codex/codex-home-paths'
import {
configureServerControlProfileEnvironment,
configureServerProfileEnvironment
} from './server-profile-environment'
const originalEnvironment = {
serverDataPath: process.env.ORCA_SERVER_DATA_DIR,
userDataPath: process.env.ORCA_USER_DATA_PATH
}
afterEach(() => {
if (originalEnvironment.userDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = originalEnvironment.userDataPath
}
if (originalEnvironment.serverDataPath === undefined) {
delete process.env.ORCA_SERVER_DATA_DIR
} else {
process.env.ORCA_SERVER_DATA_DIR = originalEnvironment.serverDataPath
}
})
describe('server profile environment', () => {
it('roots managed Codex state in the selected server profile', () => {
const dataPath = join('custom', 'server-profile')
configureServerProfileEnvironment(dataPath)
expect(resolveOrcaManagedCodexHomePath()).toBe(join(dataPath, 'codex-runtime-home', 'home'))
})
it('gives an explicit server profile precedence over an inherited desktop profile', () => {
process.env.ORCA_USER_DATA_PATH = join('desktop', 'profile')
process.env.ORCA_SERVER_DATA_DIR = join('server', 'profile')
configureServerControlProfileEnvironment()
expect(process.env.ORCA_USER_DATA_PATH).toBe(join(process.cwd(), 'server', 'profile'))
})
it('preserves an inherited profile when no server override is explicit', () => {
const inherited = join('desktop', 'profile')
process.env.ORCA_USER_DATA_PATH = inherited
delete process.env.ORCA_SERVER_DATA_DIR
configureServerControlProfileEnvironment()
expect(process.env.ORCA_USER_DATA_PATH).toBe(inherited)
})
})
@@ -0,0 +1,11 @@
import { resolveServerDataPath } from './server-paths'
export function configureServerProfileEnvironment(dataPath: string): void {
process.env.ORCA_USER_DATA_PATH = dataPath
}
export function configureServerControlProfileEnvironment(): void {
if (process.env.ORCA_SERVER_DATA_DIR || !process.env.ORCA_USER_DATA_PATH) {
process.env.ORCA_USER_DATA_PATH = resolveServerDataPath()
}
}
@@ -14,6 +14,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as managedHookLockClaims from '../main/agent-hooks/managed-hook-lock-claims'
import { isManagedHookLockOwnerActive } from '../main/agent-hooks/managed-hook-lock-records'
import {
readManagedHookHostIdentity,
readManagedHookProcessIdentity
@@ -300,12 +301,17 @@ describe('server profile process lock', () => {
it('retries release after a transient removal failure', async () => {
const root = await createRoot()
const lock = await acquireServerProfileProcessLock(root)
const owner = JSON.parse(await readFile(paths(root).lock, 'utf8')) as { token: string }
const removeManagedHookLock = managedHookLockClaims.removeManagedHookLock
const removeLock = vi.spyOn(managedHookLockClaims, 'removeManagedHookLock')
removeLock.mockResolvedValueOnce('unverifiable')
removeLock.mockResolvedValueOnce('unverifiable').mockImplementationOnce(async (...args) => {
expect(isManagedHookLockOwnerActive(owner.token)).toBe(true)
return removeManagedHookLock(...args)
})
await expect(lock.release()).rejects.toMatchObject({ reason: 'release_failed' })
await expect(lock.release()).resolves.toBeUndefined()
expect(removeLock).toHaveBeenCalledTimes(2)
expect(isManagedHookLockOwnerActive(owner.token)).toBe(false)
})
it('surfaces a typed error when the current process cannot be identified', async () => {
+27 -14
View File
@@ -14,6 +14,7 @@ import {
} from '../main/agent-hooks/managed-hook-owner-identity'
const MAX_RECOVERY_ATTEMPTS = 32
const RELEASE_RETRY_DELAYS_MS = [0, 25, 100] as const
export type ServerProfileProcessLock = {
ownerPid: number
@@ -166,25 +167,37 @@ function createOwnedLock(
if (released) {
return
}
let lastFailure: unknown
try {
const removal = await removeManagedHookLock(
lockPath,
lockParent,
owner,
hostIdentity,
processIdentity
)
if (removal !== 'removed') {
throw new ServerProfileProcessLockError(
'release_failed',
`Could not release the Orca server profile lock: ${removal}`,
owner.pid
)
for (const delayMs of RELEASE_RETRY_DELAYS_MS) {
if (delayMs > 0) {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs))
}
try {
const removal = await removeManagedHookLock(
lockPath,
lockParent,
owner,
hostIdentity,
processIdentity
)
if (removal === 'removed') {
released = true
return
}
lastFailure = new ServerProfileProcessLockError(
'release_failed',
`Could not release the Orca server profile lock: ${removal}`,
owner.pid
)
} catch (error) {
lastFailure = error
}
}
released = true
} finally {
deactivateManagedHookLockOwner(owner.token)
}
throw lastFailure
}
}
}
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from 'vitest'
import { createServerShutdownCoordinator } from './server-shutdown-coordinator'
describe('server shutdown coordinator', () => {
it('joins concurrent shutdown requests and retains a later failure code', async () => {
let finish!: () => void
const barrier = new Promise<void>((resolve) => {
finish = resolve
})
const observedExitCodes: number[] = []
const run = vi.fn(async (getRequestedExitCode: () => number) => {
await barrier
observedExitCodes.push(getRequestedExitCode())
})
const shutdown = createServerShutdownCoordinator(run)
const first = shutdown(0)
const second = shutdown(1)
expect(second).toBe(first)
expect(run).toHaveBeenCalledTimes(1)
finish()
await first
expect(observedExitCodes).toEqual([1])
})
})
@@ -0,0 +1,11 @@
export function createServerShutdownCoordinator(
run: (getRequestedExitCode: () => number) => Promise<void>
): (exitCode: number) => Promise<void> {
let requestedExitCode = 0
let shutdownPromise: Promise<void> | null = null
return (exitCode) => {
requestedExitCode = Math.max(requestedExitCode, exitCode)
shutdownPromise ??= run(() => requestedExitCode)
return shutdownPromise
}
}
@@ -0,0 +1,33 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { createServerShutdownCoordinator } from './server-shutdown-coordinator'
import { installServerSignalShutdown } from './server-signal-shutdown'
describe('server signal shutdown', () => {
it('retains signal handlers until repeated signals join teardown', async () => {
const emitter = new EventEmitter()
let finish!: () => void
const barrier = new Promise<void>((resolve) => {
finish = resolve
})
const run = vi.fn(async () => barrier)
const shutdown = createServerShutdownCoordinator(async () => run())
const exit = vi.fn()
const onSignal = vi.fn()
installServerSignalShutdown({ emitter, shutdown, exit, getExitCode: () => 0, onSignal })
emitter.emit('SIGTERM')
emitter.emit('SIGTERM')
expect(onSignal).toHaveBeenCalledTimes(2)
expect(run).toHaveBeenCalledTimes(1)
expect(exit).not.toHaveBeenCalled()
expect(emitter.listenerCount('SIGTERM')).toBe(1)
finish()
await barrier
await new Promise<void>((resolve) => setImmediate(resolve))
expect(exit).toHaveBeenCalledOnce()
expect(emitter.listenerCount('SIGINT')).toBe(0)
expect(emitter.listenerCount('SIGTERM')).toBe(0)
})
})
+28
View File
@@ -0,0 +1,28 @@
type SignalEmitter = {
on(signal: NodeJS.Signals, listener: () => void): unknown
off(signal: NodeJS.Signals, listener: () => void): unknown
}
export function installServerSignalShutdown(args: {
emitter: SignalEmitter
shutdown: (exitCode: number) => Promise<void>
exit: (exitCode: number) => void
getExitCode: () => number
onSignal?: () => void
}): void {
let exiting = false
const stopForSignal = (): void => {
args.onSignal?.()
void args.shutdown(0).then(() => {
if (exiting) {
return
}
exiting = true
args.emitter.off('SIGINT', stopForSignal)
args.emitter.off('SIGTERM', stopForSignal)
args.exit(args.getExitCode())
})
}
args.emitter.on('SIGINT', stopForSignal)
args.emitter.on('SIGTERM', stopForSignal)
}