feat(serve): run orca serve on the local orcad slot behind ORCA_SERVE_RUNTIME=orcad (T6-11) (#24608)

* feat(serve): run orca serve on the local orcad slot behind ORCA_SERVE_RUNTIME=orcad (T6-11)

* fix(serve): keep orcad selection app-side and wait out Windows temp cleanup

* refactor(orcad): move the data-root privacy check out of the instance lock

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-02 03:28:13 -07:00
committed by GitHub
co-authored by m4air
parent 9292d18f65
commit ff4b313f04
27 changed files with 1434 additions and 246 deletions
@@ -42,6 +42,7 @@ const PLAIN_NODE_ENTRY_NAMES = [
'parcel-watcher-process-entry',
'computer-sidecar',
'wsl-transcript-fs-process-entry',
'orcad/orcad-local-serve-selection-entry',
...CLI_MAIN_ENTRY_NAMES
] as const
@@ -32,6 +32,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } =
'src/main/orcad/orcad-node-launcher.integration.test.ts',
'src/main/orcad/orcad-stop-request-shutdown.integration.test.ts',
'src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts',
'src/main/orcad/orcad-serve-parity.integration.test.ts',
'config/scripts/zip-extractor-command.test.mjs'
]
: []),
+5
View File
@@ -9,6 +9,7 @@ import {
CLI_MAIN_ENTRY_NAMES,
createPlainNodeEntryGuardPlugin
} from './config/build-plugins/plain-node-entry-guard'
import { ORCAD_LOCAL_SERVE_SELECTION_ENTRY } from './src/shared/orcad-local-serve-selection'
import packageJson from './package.json' with { type: 'json' }
const BUNDLED_MAIN_DEPENDENCIES = new Set([
@@ -265,6 +266,10 @@ export const electronViteConfig: UserConfig = {
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
// Why: `orca serve` runs it under ELECTRON_RUN_AS_NODE so the CLI never bundles orcad prep.
[ORCAD_LOCAL_SERVE_SELECTION_ENTRY]: resolve(
'src/main/orcad/orcad-local-serve-selection-entry.ts'
),
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
// without paying for another Electron process.
'main-thread-hang-watchdog-entry': resolve(
+45 -108
View File
@@ -1,16 +1,11 @@
import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import { runProcessSync } from '../../shared/child-process/run-process'
import {
SERVE_UPDATE_HANDOFF_PATH_ENV,
getServeUpdateHandoffPath
} from '../../shared/serve-update-handoff'
import {
getEphemeralVmRecipeResultConnection,
parseEphemeralVmRecipeResult
} from '../../shared/ephemeral-vm-recipes'
import { getDefaultUserDataPath } from './metadata'
import { getMacAppBundlePath } from './mac-app-update-bundle'
import {
@@ -19,8 +14,10 @@ import {
superviseForegroundServe
} from './serve-update-supervisor'
import { RuntimeClientError } from './types'
import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection'
import { resolveLocalServeRuntime, serveWithOrcad } from './serve-orcad-launch'
import { waitForRecipeJson } from './serve-recipe-json'
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
const USER_NAMESPACE_PROBE_TIMEOUT_MS = 2_000
export function launchOrcaApp(): void {
@@ -77,18 +74,49 @@ function spawnDetached(command: string, args: string[], options: SpawnOptions):
child.unref()
}
export function serveOrcaApp(
args: {
json?: boolean
port?: string | null
pairingAddress?: string | null
noPairing?: boolean
mobilePairing?: boolean
recipeJson?: boolean
projectRoot?: string | null
} = {}
): Promise<number> {
export type ServeOrcaAppArgs = {
json?: boolean
port?: string | null
pairingAddress?: string | null
noPairing?: boolean
mobilePairing?: boolean
recipeJson?: boolean
projectRoot?: string | null
}
export function serveOrcaApp(args: ServeOrcaAppArgs = {}): Promise<number> {
const executable = resolveForegroundOrcaExecutable()
// Why synchronous unless opted in: the default path must spawn Electron exactly as before.
if (process.env[SERVE_RUNTIME_ENV] !== 'orcad') {
return serveWithElectron(executable, args)
}
if (args.recipeJson && !args.projectRoot) {
throw new RuntimeClientError('invalid_argument', 'Recipe JSON output requires --project-root.')
}
return serveWithSelectedRuntime(executable, args)
}
async function serveWithSelectedRuntime(
executable: string,
args: ServeOrcaAppArgs
): Promise<number> {
const selection = await resolveLocalServeRuntime({
executable,
appRoot: resolveAppRoot(),
userDataPath: getDefaultUserDataPath(),
usesMacUpdateHandoff: args.recipeJson !== true && getMacAppBundlePath(executable) !== null
})
if (selection.kind === 'orcad') {
process.stderr.write(`[serve] running on orcad ${selection.version}\n`)
return serveWithOrcad(selection, args, getDefaultUserDataPath(), spawnProcess)
}
if (selection.reason) {
process.stderr.write(`[serve] using Electron serve: ${selection.reason}\n`)
}
return serveWithElectron(executable, args)
}
function serveWithElectron(executable: string, args: ServeOrcaAppArgs): Promise<number> {
const childArgs = [...getExecutableAppArgs(executable)]
childArgs.push('--serve')
if (args.json) {
@@ -164,97 +192,6 @@ export function serveOrcaApp(
})
}
function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<number> {
return new Promise((resolve, reject) => {
let output = ''
let settled = false
const timeout = setTimeout(() => {
finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.'))
child.kill('SIGTERM')
}, 60000)
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
child.stdout?.off('data', onData)
child.off('error', onError)
child.off('close', onClose)
if (error) {
reject(error)
return
}
child.stdout?.destroy?.()
child.unref()
resolve(0)
}
const writeIgnoredRecipeStdout = (): void => {
// Why: non-readiness child stdout is untrusted and cannot be safely
// redacted, including schema-valid results with arbitrary user data.
process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`)
}
const processRecipeOutputLine = (line: string): void => {
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line
if (!normalizedLine.trim()) {
return
}
const parsed = parseEphemeralVmRecipeResult(normalizedLine)
if (!parsed.ok) {
writeIgnoredRecipeStdout()
return
}
if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') {
writeIgnoredRecipeStdout()
return
}
process.stdout.write(`${normalizedLine.trim()}\n`)
finish()
}
const stdoutDecoder = new StringDecoder('utf8')
const onData = (chunk: Buffer | string): void => {
output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk)
while (!settled) {
const newlineIndex = output.indexOf('\n')
if (newlineIndex === -1) {
return
}
const line = output.slice(0, newlineIndex)
output = output.slice(newlineIndex + 1)
processRecipeOutputLine(line)
}
}
const onError = (error: Error): void => {
finish(error)
}
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) {
return
}
output += stdoutDecoder.end()
if (output.trim()) {
processRecipeOutputLine(output)
}
if (settled) {
return
}
finish(
new RuntimeClientError(
'runtime_serve_failed',
typeof code === 'number'
? `Orca serve exited before printing valid recipe JSON with code ${code}.`
: `Orca serve exited before printing valid recipe JSON via ${signal}.`
)
)
}
child.stdout?.on('data', onData)
child.once('error', onError)
// Why: `exit` can precede the final piped stdout data. `close` waits until
// stdio closes so a last recipe chunk is not mistaken for missing output.
child.once('close', onClose)
})
}
export function getExecutableAppArgs(executable: string): string[] {
const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
if (shouldDisableExtractedAppImageSandbox(executable)) {
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest'
import { formatServeRuntimeSelection } from '../../shared/orcad-local-serve-selection'
import type { runProcess } from '../../shared/child-process/run-process'
import { orcadServeArgs, resolveLocalServeRuntime } from './serve-orcad-launch'
type RunProcess = typeof runProcess
function answering(stdout: string, code = 0): RunProcess {
return vi.fn<RunProcess>(async () => ({
code,
signal: null,
stdout,
stderr: '',
timedOut: false
}))
}
const options = {
executable: '/Applications/Orca.app/Contents/MacOS/Orca',
appRoot: '/Applications/Orca.app/Contents/Resources/app.asar',
userDataPath: '/Users/u/Library/Application Support/orca',
usesMacUpdateHandoff: false
}
describe('orca serve asking the app which host to run', () => {
it("runs the app's own selection entry as plain Node and reads its answer", async () => {
const selection = {
kind: 'orcad' as const,
runtime: '/rt/node',
entry: '/slot/orcad.js',
version: '1'
}
const run = answering(`noise\n${formatServeRuntimeSelection(selection)}\n`)
expect(await resolveLocalServeRuntime({ ...options, usesMacUpdateHandoff: true }, run)).toEqual(
selection
)
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
program: options.executable,
args: [
`${options.appRoot}/out/main/orcad/orcad-local-serve-selection-entry.js`,
'--user-data',
options.userDataPath,
'--app-root',
options.appRoot,
'--mac-update-handoff'
],
env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' })
})
)
})
it('serves on Electron, and says why, when the app gives no answer', async () => {
expect(await resolveLocalServeRuntime(options, answering('', 1))).toEqual({
kind: 'electron',
reason: expect.stringContaining('did not answer')
})
const failing = vi.fn<RunProcess>(async () => {
throw new Error('spawn ENOENT')
})
expect(await resolveLocalServeRuntime(options, failing)).toEqual({
kind: 'electron',
reason: expect.stringContaining('spawn ENOENT')
})
})
it('forwards every desktop serve flag, binding wide as Electron serve does', () => {
expect(
orcadServeArgs({
json: true,
port: '6768',
pairingAddress: '10.0.0.5',
noPairing: true,
mobilePairing: true,
recipeJson: true,
projectRoot: '/work/app'
})
).toEqual([
'--bind',
'0.0.0.0',
'--json',
'--port',
'6768',
'--pairing-address',
'10.0.0.5',
'--no-pairing',
'--mobile-pairing',
'--recipe-json',
'--project-root',
'/work/app'
])
expect(orcadServeArgs({})).toEqual(['--bind', '0.0.0.0'])
})
})
+114
View File
@@ -0,0 +1,114 @@
/**
* `orca serve` on this machine's orcad slot. Whether to (and the slot itself) is decided
* app-side by `src/main/orcad/orcad-local-serve-selection.ts`; the CLI only asks and runs.
*/
import { dirname, join } from 'node:path'
import { runProcess } from '../../shared/child-process/run-process'
import {
ORCAD_LOCAL_SERVE_SELECTION_ENTRY,
ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS,
parseServeRuntimeSelection,
type ServeRuntimeSelection
} from '../../shared/orcad-local-serve-selection'
import type { ServeOrcaAppArgs } from './launch'
import { waitForRecipeJson } from './serve-recipe-json'
import { superviseForegroundServe } from './serve-update-supervisor'
type SupervisorArgs = Parameters<typeof superviseForegroundServe>[0]
/** A first run may download and verify the pinned Node; bound it well past that. */
const SELECTION_TIMEOUT_MS = 10 * 60_000
/** Asks the app's own entry, run on the app's executable as plain Node, which host to serve on. */
export async function resolveLocalServeRuntime(
options: {
executable: string
appRoot: string
userDataPath: string
usesMacUpdateHandoff: boolean
},
run: typeof runProcess = runProcess
): Promise<ServeRuntimeSelection> {
const entry = join(options.appRoot, 'out', 'main', `${ORCAD_LOCAL_SERVE_SELECTION_ENTRY}.js`)
try {
const result = await run({
program: options.executable,
args: [
entry,
FLAGS.userData,
options.userDataPath,
FLAGS.appRoot,
options.appRoot,
...(options.usesMacUpdateHandoff ? [FLAGS.macUpdateHandoff] : [])
],
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
timeoutMs: SELECTION_TIMEOUT_MS
})
return (
parseServeRuntimeSelection(result.stdout) ?? {
kind: 'electron',
reason: `the app did not answer which serve host to use (exit ${String(result.code)})`
}
)
} catch (error) {
return {
kind: 'electron',
reason: `the app could not check orcad: ${error instanceof Error ? error.message : String(error)}`
}
}
}
/** Electron serve binds every interface (`exposeNetworkByDefault`); orcad does it on request. */
export function serveWithOrcad(
selection: Extract<ServeRuntimeSelection, { kind: 'orcad' }>,
args: ServeOrcaAppArgs,
userDataPath: string,
spawnProcess: SupervisorArgs['spawnChild']
): Promise<number> {
const childArgs = [selection.entry, ...orcadServeArgs(args)]
const spawnOptions: SupervisorArgs['spawnOptions'] = {
detached: args.recipeJson === true,
cwd: dirname(selection.entry),
stdio: args.recipeJson === true ? ['ignore', 'pipe', 'inherit'] : 'inherit',
env: {
...withoutElectronRunAsNode(process.env),
// The desktop's profile: its instance lock makes the two refuse each other.
ORCA_USER_DATA: userDataPath,
ORCA_VERSION: selection.version
}
}
const child = spawnProcess(selection.runtime, childArgs, spawnOptions)
if (args.recipeJson) {
return waitForRecipeJson(child)
}
return superviseForegroundServe({
executable: selection.runtime,
childArgs,
spawnOptions,
spawnChild: spawnProcess,
child,
handoffPath: null,
expectedHandoff: null
})
}
export function orcadServeArgs(args: ServeOrcaAppArgs): string[] {
return [
'--bind',
'0.0.0.0',
...(args.json ? ['--json'] : []),
...(args.port ? ['--port', args.port] : []),
...(args.pairingAddress ? ['--pairing-address', args.pairingAddress] : []),
...(args.noPairing ? ['--no-pairing'] : []),
...(args.mobilePairing ? ['--mobile-pairing'] : []),
...(args.recipeJson && args.projectRoot
? ['--recipe-json', '--project-root', args.projectRoot]
: [])
]
}
function withoutElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env }
delete next.ELECTRON_RUN_AS_NODE
return next
}
+101
View File
@@ -0,0 +1,101 @@
import type { ChildProcessHandle } from '../../shared/child-process/process-spec'
import { StringDecoder } from 'node:string_decoder'
import {
getEphemeralVmRecipeResultConnection,
parseEphemeralVmRecipeResult
} from '../../shared/ephemeral-vm-recipes'
import { RuntimeClientError } from './types'
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
/** Relays the one recipe line a detached serve child prints, then lets the CLI exit. */
export function waitForRecipeJson(child: ChildProcessHandle): Promise<number> {
return new Promise((resolve, reject) => {
let output = ''
let settled = false
const timeout = setTimeout(() => {
finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.'))
child.kill('SIGTERM')
}, 60000)
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
child.stdout?.off('data', onData)
child.off('error', onError)
child.off('close', onClose)
if (error) {
reject(error)
return
}
child.stdout?.destroy?.()
child.unref()
resolve(0)
}
const writeIgnoredRecipeStdout = (): void => {
// Why: non-readiness child stdout is untrusted and cannot be safely
// redacted, including schema-valid results with arbitrary user data.
process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`)
}
const processRecipeOutputLine = (line: string): void => {
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line
if (!normalizedLine.trim()) {
return
}
const parsed = parseEphemeralVmRecipeResult(normalizedLine)
if (!parsed.ok) {
writeIgnoredRecipeStdout()
return
}
if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') {
writeIgnoredRecipeStdout()
return
}
process.stdout.write(`${normalizedLine.trim()}\n`)
finish()
}
const stdoutDecoder = new StringDecoder('utf8')
const onData = (chunk: Buffer | string): void => {
output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk)
while (!settled) {
const newlineIndex = output.indexOf('\n')
if (newlineIndex === -1) {
return
}
const line = output.slice(0, newlineIndex)
output = output.slice(newlineIndex + 1)
processRecipeOutputLine(line)
}
}
const onError = (error: Error): void => {
finish(error)
}
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) {
return
}
output += stdoutDecoder.end()
if (output.trim()) {
processRecipeOutputLine(output)
}
if (settled) {
return
}
finish(
new RuntimeClientError(
'runtime_serve_failed',
typeof code === 'number'
? `Orca serve exited before printing valid recipe JSON with code ${code}.`
: `Orca serve exited before printing valid recipe JSON via ${signal}.`
)
)
}
child.stdout?.on('data', onData)
child.once('error', onError)
// Why: `exit` can precede the final piped stdout data. `close` waits until
// stdio closes so a last recipe chunk is not mistaken for missing output.
child.once('close', onClose)
})
}
+14 -1
View File
@@ -1,6 +1,7 @@
/** Executable entry for `orcad`. See `./orcad-entry.ts`. */
import process from 'node:process'
import { main, resolveOrcadExitCode } from './orcad-entry'
import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary'
import { runOrcadNativePreflight } from './orcad-native-preflight'
import {
ORCAD_PROFILE_PREFLIGHT_FLAG,
@@ -8,6 +9,10 @@ import {
} from '../../shared/orcad-profile-preflight'
import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight'
import { handoffToBundledOrcad } from './orcad-bundled-runtime'
import {
formatOrcadNativePreflightReport,
ORCAD_NATIVE_PREFLIGHT_FLAG
} from '../../shared/orcad-native-preflight-report'
import {
ORCAD_CANCEL_MANAGED_STOP_FLAG,
ORCAD_COMPLETE_MANAGED_STOP_FLAG
@@ -57,7 +62,13 @@ function startOrcadProcess(): void {
try {
if (!handoffToBundledOrcad()) {
const flag = process.argv[2]
if (
if (flag === ORCAD_NATIVE_PREFLIGHT_FLAG && process.argv.length === 3) {
void import('./node-pty-precondition').then(({ checkNodePtyPrecondition }) => {
const verdict = checkNodePtyPrecondition()
const report = formatOrcadNativePreflightReport(verdict.status, verdict.reason ?? null)
process.stdout.write(`${report}\n`, () => process.exit(0))
}, failStartup)
} else if (
(flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) &&
process.argv.length === 4
) {
@@ -68,6 +79,8 @@ function startOrcadProcess(): void {
.then(() => process.stdout.write('', () => process.exit(0)))
.catch(failStartup)
} else {
// Why: stdout is the serve readiness API; incidental diagnostics go to stderr.
reserveServeStdoutForReadiness()
void preflightBundledOrcadStartup()
.then(() => {
runOrcadNativePreflight()
+14
View File
@@ -21,6 +21,17 @@ export function parseArgs(argv: string[]): OrcadOptions {
options.json = true
} else if (arg === '--no-pairing') {
options.noPairing = true
} else if (arg === '--mobile-pairing') {
options.mobilePairing = true
} else if (arg === '--recipe-json') {
options.recipeJson = true
} else if (arg === '--project-root') {
const value = argv[i + 1]
if (!value) {
throw new Error('--project-root expects a value')
}
options.projectRoot = value
i += 1
} else if (arg === '--bind') {
const value = argv[i + 1]
if (value === undefined) {
@@ -39,5 +50,8 @@ export function parseArgs(argv: string[]): OrcadOptions {
throw new Error(`Unknown argument: ${arg}`)
}
}
if (options.recipeJson && !options.projectRoot) {
throw new Error('--recipe-json requires --project-root')
}
return options
}
@@ -0,0 +1,50 @@
// Test fixture: a terminal client that talks to a profile's terminal daemon directly, the way a
// paired client's terminal does, so a test can prove a session outlives a serve-host switch.
import { build } from 'esbuild'
export type DaemonSessionClientResult = { pid: number; isReattach: boolean; output: boolean }
/** Bundles the client to `outfile`; run it with Node as `<op: create|attach> <daemonDir> <sessionId> <marker> <cwd>`. */
export async function buildDaemonSessionClient(outfile: string): Promise<void> {
await build({
stdin: {
contents: `
import { DaemonPtyAdapter } from './src/main/daemon/daemon-pty-adapter'
import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './src/main/daemon/daemon-spawner'
import { setAppEnvironment } from './src/shared/app-environment'
const [op, runtimeDir, sessionId, marker, cwd] = process.argv.slice(2)
setAppEnvironment({
getPath: () => cwd, getAppPath: () => cwd, getVersion: () => 'test', isPackaged: () => true,
onWillQuit() {}, exit: code => process.exit(code), getAppMetrics: () => []
})
const adapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(runtimeDir), tokenPath: getDaemonTokenPath(runtimeDir),
pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir
})
let output = ''
adapter.onData(event => { if (event.id === sessionId) output += event.data })
const deadline = setTimeout(() => { console.error('timed out; output: ' + output); process.exit(98) }, 20_000)
;(async () => {
const spawned = await adapter.spawn(op === 'create'
? { sessionId, cols: 80, rows: 24, cwd, shellOverride: '/bin/sh' }
: { sessionId, cols: 80, rows: 24 })
adapter.write(spawned.id, "printf 'ORCA_SERVE_%s\\\\n' " + marker + "\\r")
while (!output.includes('ORCA_SERVE_' + marker)) await new Promise(r => setTimeout(r, 50))
clearTimeout(deadline)
await adapter.disconnectOnly()
console.log(JSON.stringify({ pid: spawned.pid, isReattach: spawned.isReattach === true, output: true }))
process.exit(0)
})().catch(error => { console.error(error); process.exit(1) })
`,
resolveDir: process.cwd(),
loader: 'ts'
},
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node18',
external: ['electron', 'node-pty', '@parcel/watcher', '*.node'],
outfile,
logLevel: 'silent'
})
}
+99
View File
@@ -0,0 +1,99 @@
import { chmodSync, statSync } from 'node:fs'
import process from 'node:process'
import { restrictWindowsPathSync } from '../../shared/secure-path-windows-acl'
export type OrcadInstanceLockCode =
| 'orcad_data_root_unusable'
| 'orcad_data_root_wrong_owner'
| 'orcad_data_root_shared'
| 'orcad_instance_lock_held'
| 'orcad_instance_lock_foreign_identity'
| 'orcad_instance_lock_unreadable'
export class OrcadInstanceLockError extends Error {
constructor(
readonly code: OrcadInstanceLockCode,
message: string
) {
super(message)
this.name = 'OrcadInstanceLockError'
}
}
export type OrcadDataRootPrivacyHooks = {
platform?: NodeJS.Platform
/** Windows: restrict the data root's ACL to this user; false when it could not be applied. */
restrictWindowsDataRoot?: (dataRoot: string) => boolean
}
/**
* Fail closed on a data root other identities can read or write.
*
* Why self-heal first and refuse second: orcad stores credentials unsealed (there is no OS
* keyring on this host), so a group- or world-accessible root is a real exposure — but if
* we own the directory, tightening it is strictly better than refusing to start. We refuse
* only when the permissions are not ours to fix.
*/
export function assertOrcadDataRootIsPrivate(
dataRoot: string,
hooks: OrcadDataRootPrivacyHooks
): void {
// Windows ACLs are not expressible as a POSIX mode, and `statSync().mode` there reports a
// synthesized one, so Windows restricts and verifies the ACL instead (icacls, no PowerShell).
if ((hooks.platform ?? process.platform) === 'win32') {
const restrict =
hooks.restrictWindowsDataRoot ?? ((path: string) => restrictWindowsPathSync(path, true))
if (!restrict(dataRoot)) {
throw new OrcadInstanceLockError(
'orcad_data_root_shared',
`Could not restrict the orcad data root ${dataRoot} to this user. orcad stores ` +
'credentials there unsealed, so it refuses to start. Point ORCA_USER_DATA at a ' +
'directory this account owns.'
)
}
return
}
let stats
try {
stats = statSync(dataRoot)
} catch (error) {
throw new OrcadInstanceLockError(
'orcad_data_root_unusable',
`Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}`
)
}
const uid = process.getuid?.()
if (uid !== undefined && stats.uid !== uid) {
throw new OrcadInstanceLockError(
'orcad_data_root_wrong_owner',
`The orcad data root ${dataRoot} is owned by uid ${stats.uid}, not by uid ${uid} running ` +
'this process. Give orcad its own data root (ORCA_USER_DATA) or chown this one.'
)
}
if ((stats.mode & 0o077) === 0) {
return
}
try {
chmodSync(dataRoot, 0o700)
} catch {
// Fall through to the re-stat, which produces the actionable message.
}
let mode: number
try {
mode = statSync(dataRoot).mode
} catch (error) {
throw new OrcadInstanceLockError(
'orcad_data_root_unusable',
`Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}`
)
}
if ((mode & 0o077) !== 0) {
throw new OrcadInstanceLockError(
'orcad_data_root_shared',
`The orcad data root ${dataRoot} is accessible to other users (mode ` +
`${(mode & 0o777).toString(8)}) and could not be tightened. orcad stores credentials ` +
'there unsealed, so it refuses to start. Run `chmod 700` on it, or point ORCA_USER_DATA ' +
'at a private directory.'
)
}
}
+17 -7
View File
@@ -100,6 +100,11 @@ export type OrcadOptions = {
json?: boolean
noPairing?: boolean
pairingAddress?: string
/** Desktop `orca serve` parity: a mobile-scoped offer with a terminal QR. */
mobilePairing?: boolean
/** Desktop `orca serve` parity: print only the ephemeral-VM recipe line. */
recipeJson?: boolean
projectRoot?: string
/** Literal IP to bind. Defaults to loopback; see orcad-bind-address.ts. */
bind?: string
}
@@ -145,6 +150,8 @@ async function startOrcadRuntime(
closeOrcadObservability = installOrcadObservability()
const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint')
const { ServeReadinessPublisher } = await import('../server/serve-readiness')
const { assertServeProjectRoot, renderServePairingQr } =
await import('../server/serve-pairing-output')
const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup')
const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision')
const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init')
@@ -344,8 +351,8 @@ async function startOrcadRuntime(
} as const)
: rpc.createPairingOffer({
address: options.pairingAddress,
name: `CLI ${new Date().toLocaleDateString()}`,
scope: 'runtime'
name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`,
scope: options.mobilePairing ? 'mobile' : 'runtime'
})
const readiness: ServeReadiness = {
@@ -362,8 +369,8 @@ async function startOrcadRuntime(
endpoint: offer.endpoint,
deviceId: offer.deviceId,
webClientUrl: offer.webClientUrl,
scope: 'runtime',
qr: null
scope: options.mobilePairing ? 'mobile' : 'runtime',
qr: options.mobilePairing ? await renderServePairingQr(offer.pairingUrl) : null
}
: offer,
// Why in the readiness payload: this is the one message a supervisor and a deploy
@@ -372,9 +379,12 @@ async function startOrcadRuntime(
health: await collectOrcadHealth(getAppEnvironment().getVersion(), profileStateAuthority)
}
await new ServeReadinessPublisher().publish(readiness, {
mode: options.json ? 'json' : 'human'
})
await new ServeReadinessPublisher().publish(
readiness,
options.recipeJson && options.projectRoot
? { mode: 'recipe-json', projectRoot: assertServeProjectRoot(options.projectRoot) }
: { mode: options.json ? 'json' : 'human' }
)
return { readiness }
}
@@ -254,4 +254,47 @@ describe('acquireOrcadInstanceLock', () => {
lock.release()
}
)
it('makes the desktop app and orcad refuse each other on one profile', () => {
const root = makeRoot()
const desktop = acquireOrcadInstanceLock(root, hooks({ role: 'desktop' }))
expect(JSON.parse(readFileSync(desktop.path, 'utf8')).role).toBe('desktop')
expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => true }))).toThrow(
expect.objectContaining({
code: 'orcad_instance_lock_held',
message: expect.stringContaining('The Orca desktop app')
})
)
desktop.release()
const orcad = acquireOrcadInstanceLock(root, hooks())
expect(() =>
acquireOrcadInstanceLock(root, hooks({ role: 'desktop', processIsAlive: () => true }))
).toThrow(
expect.objectContaining({
code: 'orcad_instance_lock_held',
message: expect.stringContaining('Another orcad')
})
)
orcad.release()
})
it.runIf(process.platform !== 'win32')(
"leaves the desktop profile's permissions as they were",
() => {
const root = makeRoot()
chmodSync(root, 0o755)
const restricted: string[] = []
acquireOrcadInstanceLock(root, hooks({ role: 'desktop' })).release()
acquireOrcadInstanceLock(
root,
hooks({
role: 'desktop',
platform: 'win32',
restrictWindowsDataRoot: (path) => restricted.push(path) > 0
})
).release()
expect(restricted).toEqual([])
expect(statSync(root).mode & 0o777).toBe(0o755)
}
)
})
+26 -106
View File
@@ -13,15 +13,7 @@
* it says nothing about the daemon, which is what makes a non-destructive restart possible.
*/
import { randomUUID } from 'node:crypto'
import {
chmodSync,
linkSync,
mkdirSync,
renameSync,
statSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { linkSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import { userInfo } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
@@ -30,29 +22,17 @@ import {
orcadProcessStartTimeMatches,
readOrcadProcessStartedAtMs
} from './orcad-process-start-time'
import { restrictWindowsPathSync } from '../../shared/secure-path-windows-acl'
import {
assertOrcadDataRootIsPrivate,
OrcadInstanceLockError,
type OrcadDataRootPrivacyHooks
} from './orcad-data-root-privacy'
import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader'
export const ORCAD_LOCK_FILE_NAME = 'orcad.lock'
const MAX_ORCAD_LOCK_BYTES = 64 * 1024
export type OrcadInstanceLockCode =
| 'orcad_data_root_unusable'
| 'orcad_data_root_wrong_owner'
| 'orcad_data_root_shared'
| 'orcad_instance_lock_held'
| 'orcad_instance_lock_foreign_identity'
| 'orcad_instance_lock_unreadable'
export class OrcadInstanceLockError extends Error {
constructor(
readonly code: OrcadInstanceLockCode,
message: string
) {
super(message)
this.name = 'OrcadInstanceLockError'
}
}
export { OrcadInstanceLockError, type OrcadInstanceLockCode } from './orcad-data-root-privacy'
const OrcadLockRecordSchema = z.object({
pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
@@ -63,7 +43,9 @@ const OrcadLockRecordSchema = z.object({
version: z.string().min(1).max(255),
acquiredAt: z.iso.datetime({ offset: true }),
/** Distinguishes our record from a replacement written after we lost the race. */
nonce: z.string().min(1).max(255)
nonce: z.string().min(1).max(255),
/** Absent in records orcad wrote before the desktop app shared this lock. */
role: z.enum(['orcad', 'desktop']).optional()
})
export type OrcadLockRecord = z.infer<typeof OrcadLockRecordSchema>
@@ -79,7 +61,7 @@ export type OrcadInstanceLock = {
release(): void
}
export type OrcadInstanceLockHooks = {
export type OrcadInstanceLockHooks = OrcadDataRootPrivacyHooks & {
identity?: () => string
version?: () => string
now?: () => Date
@@ -87,9 +69,8 @@ export type OrcadInstanceLockHooks = {
processIsAlive?: (pid: number) => boolean
startedAtMs?: (pid: number) => number | null
startTimeMatches?: (pid: number, expected: number | null) => boolean
platform?: NodeJS.Platform
/** Windows: restrict the data root's ACL to this user; false when it could not be applied. */
restrictWindowsDataRoot?: (dataRoot: string) => boolean
/** Who takes the profile. The desktop app takes it too, so the two refuse each other. */
role?: 'orcad' | 'desktop'
}
function defaultIdentity(): string {
@@ -123,75 +104,6 @@ export function parseOrcadInstanceLockRecord(content: string): OrcadLockRecord |
}
}
/**
* Fail closed on a data root other identities can read or write.
*
* Why self-heal first and refuse second: orcad stores credentials unsealed (there is no OS
* keyring on this host), so a group- or world-accessible root is a real exposure — but if
* we own the directory, tightening it is strictly better than refusing to start. We refuse
* only when the permissions are not ours to fix.
*/
function assertDataRootIsPrivate(dataRoot: string, hooks: OrcadInstanceLockHooks): void {
// Windows ACLs are not expressible as a POSIX mode, and `statSync().mode` there reports a
// synthesized one, so Windows restricts and verifies the ACL instead (icacls, no PowerShell).
if ((hooks.platform ?? process.platform) === 'win32') {
const restrict =
hooks.restrictWindowsDataRoot ?? ((path: string) => restrictWindowsPathSync(path, true))
if (!restrict(dataRoot)) {
throw new OrcadInstanceLockError(
'orcad_data_root_shared',
`Could not restrict the orcad data root ${dataRoot} to this user. orcad stores ` +
'credentials there unsealed, so it refuses to start. Point ORCA_USER_DATA at a ' +
'directory this account owns.'
)
}
return
}
let stats
try {
stats = statSync(dataRoot)
} catch (error) {
throw new OrcadInstanceLockError(
'orcad_data_root_unusable',
`Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}`
)
}
const uid = process.getuid?.()
if (uid !== undefined && stats.uid !== uid) {
throw new OrcadInstanceLockError(
'orcad_data_root_wrong_owner',
`The orcad data root ${dataRoot} is owned by uid ${stats.uid}, not by uid ${uid} running ` +
'this process. Give orcad its own data root (ORCA_USER_DATA) or chown this one.'
)
}
if ((stats.mode & 0o077) === 0) {
return
}
try {
chmodSync(dataRoot, 0o700)
} catch {
// Fall through to the re-stat, which produces the actionable message.
}
let mode: number
try {
mode = statSync(dataRoot).mode
} catch (error) {
throw new OrcadInstanceLockError(
'orcad_data_root_unusable',
`Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}`
)
}
if ((mode & 0o077) !== 0) {
throw new OrcadInstanceLockError(
'orcad_data_root_shared',
`The orcad data root ${dataRoot} is accessible to other users (mode ` +
`${(mode & 0o777).toString(8)}) and could not be tightened. orcad stores credentials ` +
'there unsealed, so it refuses to start. Run `chmod 700` on it, or point ORCA_USER_DATA ' +
'at a private directory.'
)
}
}
/**
* Take the lock, or throw an `OrcadInstanceLockError` naming why.
*
@@ -215,7 +127,10 @@ export function acquireOrcadInstanceLock(
`Cannot create the orcad data root ${dataRoot}: ${(error as Error).message}`
)
}
assertDataRootIsPrivate(dataRoot, hooks)
// The desktop's profile keeps the permissions it was created with; orcad tightens its own.
if ((hooks.role ?? 'orcad') === 'orcad') {
assertOrcadDataRootIsPrivate(dataRoot, hooks)
}
const lockPath = join(dataRoot, ORCAD_LOCK_FILE_NAME)
const record: OrcadLockRecord = {
@@ -224,7 +139,8 @@ export function acquireOrcadInstanceLock(
identity,
version: (hooks.version ?? (() => process.env.ORCA_VERSION ?? 'unknown'))(),
acquiredAt: (hooks.now ?? (() => new Date()))().toISOString(),
nonce: randomUUID()
nonce: randomUUID(),
role: hooks.role ?? 'orcad'
}
const serialized = JSON.stringify(record)
@@ -268,9 +184,9 @@ export function acquireOrcadInstanceLock(
if (isAlive(existing.pid) && matchesStartTime(existing.pid, existing.startedAtMs)) {
throw new OrcadInstanceLockError(
'orcad_instance_lock_held',
`Another orcad (pid ${existing.pid}, started ${existing.acquiredAt || 'unknown'}) already ` +
`owns the data root ${dataRoot}. Stop it before starting another, or use a different ` +
'ORCA_USER_DATA.'
`${describeLockHolder(existing)} (pid ${existing.pid}, started ` +
`${existing.acquiredAt || 'unknown'}) already owns the data root ${dataRoot}. Stop it ` +
'before starting another, or use a different ORCA_USER_DATA.'
)
}
// Why rename-and-then-publish rather than unlink-and-write: rename claims one exact
@@ -315,6 +231,10 @@ export function acquireOrcadInstanceLock(
return makeLock(lockPath, record)
}
function describeLockHolder(record: OrcadLockRecord): string {
return record.role === 'desktop' ? 'The Orca desktop app' : 'Another orcad'
}
/** No-clobber restore: a third contender's newer record at the canonical path stays authoritative. */
function restoreDisplacedLock(
claimPath: string,
@@ -26,6 +26,25 @@ describe('parseArgs', () => {
})
})
it('takes the desktop serve flags orca serve forwards', () => {
expect(
parseArgs([
'--mobile-pairing',
'--recipe-json',
'--project-root',
'/work/app',
'--no-pairing'
])
).toEqual({
mobilePairing: true,
recipeJson: true,
projectRoot: '/work/app',
noPairing: true
})
expect(() => parseArgs(['--recipe-json'])).toThrow('--recipe-json requires --project-root')
expect(() => parseArgs(['--project-root'])).toThrow('--project-root expects a value')
})
it('rejects --bind with no value rather than silently binding the default', () => {
expect(() => parseArgs(['--bind'])).toThrow('--bind expects a value')
expect(() => parseArgs(['--bind', '--json'])).not.toThrow()
@@ -0,0 +1,44 @@
/**
* `orca serve`'s app-side question, run by the CLI with the app's own executable under
* ELECTRON_RUN_AS_NODE: prepare this machine's orcad slot if it can serve, and print one
* `ORCA_SERVE_RUNTIME` line saying which host to run. Diagnostics go to stderr.
*/
import { join } from 'node:path'
import process from 'node:process'
import {
formatServeRuntimeSelection,
ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS
} from '../../shared/orcad-local-serve-selection'
import { selectServeRuntime } from './orcad-local-serve-selection'
function flagValue(argv: readonly string[], flag: string): string | null {
const index = argv.indexOf(flag)
return index === -1 ? null : (argv[index + 1] ?? null)
}
async function run(argv: readonly string[]): Promise<void> {
const userDataPath = flagValue(argv, FLAGS.userData)
const appRoot = flagValue(argv, FLAGS.appRoot)
if (!userDataPath || !appRoot) {
throw new Error(`${FLAGS.userData} and ${FLAGS.appRoot} are required`)
}
const selection = await selectServeRuntime({
env: process.env,
platform: process.platform,
userDataPath,
templateDirs: [
...(process.resourcesPath ? [join(process.resourcesPath, 'orcad-template')] : []),
join(appRoot, 'out', 'orcad-template')
],
usesMacUpdateHandoff: argv.includes(FLAGS.macUpdateHandoff)
})
process.stdout.write(`${formatServeRuntimeSelection(selection)}\n`, () => process.exit(0))
}
run(process.argv.slice(2)).catch((error: unknown) => {
// Any failure here is a reason to serve on Electron, never to fail `orca serve`.
const reason = `orcad selection failed: ${error instanceof Error ? error.message : String(error)}`
process.stdout.write(`${formatServeRuntimeSelection({ kind: 'electron', reason })}\n`, () =>
process.exit(0)
)
})
@@ -0,0 +1,115 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin'
import {
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
ORCAD_SERVER_TARGET_FILENAME,
ORCAD_VERSION_FILENAME,
orcadNodeRuntimeRelativePath
} from '../../shared/orcad-artifacts'
import { formatOrcadNativePreflightReport } from '../../shared/orcad-native-preflight-report'
import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection'
import { selectServeRuntime, type ServeRuntimeSelectionInput } from './orcad-local-serve-selection'
const TARGET = 'linux-x64-glibc'
const SHA = NODE_RUNTIME_ASSETS[TARGET].executableSha256
let root = ''
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-serve-runtime-'))
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
/** A materialized slot as the template would leave it: no runtime of its own yet. */
function slotFixture(): string {
const slot = join(root, 'orcad-artifacts', TARGET, '0.1.0+abc')
mkdirSync(slot, { recursive: true })
writeFileSync(join(slot, ORCAD_SERVER_TARGET_FILENAME), `${TARGET}\n`)
writeFileSync(join(slot, ORCAD_NODE_RUNTIME_MARKER_FILENAME), `${SHA}\n`)
writeFileSync(join(slot, ORCAD_VERSION_FILENAME), '0.1.0+abc\n')
writeFileSync(join(slot, 'orcad.js'), '')
return slot
}
function input(overrides: Partial<ServeRuntimeSelectionInput> = {}): ServeRuntimeSelectionInput {
const template = join(root, 'orcad-template')
mkdirSync(template, { recursive: true })
const cachedNode = join(root, 'cached-node')
writeFileSync(cachedNode, '#!/bin/sh\n')
return {
env: { [SERVE_RUNTIME_ENV]: 'orcad' },
platform: 'linux',
userDataPath: root,
templateDirs: [join(root, 'missing-template'), template],
usesMacUpdateHandoff: false,
hostTarget: () => TARGET,
materializeSlot: vi.fn(async () => slotFixture()),
materializeRuntime: vi.fn(async () => cachedNode),
nativePreflight: async () => `${formatOrcadNativePreflightReport('ok', null)}\n`,
...overrides
}
}
describe('orca serve runtime selection', () => {
it('stays on Electron, silently, unless orcad is asked for', async () => {
expect(await selectServeRuntime(input({ env: {} }))).toEqual({
kind: 'electron',
reason: null
})
})
it('runs the local slot on its pinned Node, linked into userData beside it', async () => {
const options = input()
const selection = await selectServeRuntime(options)
const slot = join(root, 'orcad-artifacts', TARGET, '0.1.0+abc')
const runtime = join(slot, ...orcadNodeRuntimeRelativePath(TARGET, SHA))
expect(selection).toEqual({
kind: 'orcad',
runtime,
entry: join(slot, 'orcad.js'),
version: '0.1.0+abc'
})
expect(existsSync(runtime)).toBe(true)
expect(runtime.startsWith(join(root, 'orcad-artifacts'))).toBe(true)
expect(options.materializeSlot).toHaveBeenCalledWith(TARGET, {
templateDir: join(root, 'orcad-template'),
cacheRoot: join(root, 'orcad-artifacts')
})
})
it.each([
['Windows', { platform: 'win32' as const }, 'local orcad on Windows is not enabled'],
['packaged macOS', { usesMacUpdateHandoff: true }, 'no macOS app-update handoff'],
['an unsupported host', { hostTarget: () => 'linux-riscv64-glibc' }, 'no orcad build exists'],
['an install without the template', { templateDirs: [] }, 'carries no orcad template'],
[
'an offline first run',
{
materializeRuntime: vi.fn(async (): Promise<string> => {
throw new Error('fetch failed')
})
},
'could not be prepared: fetch failed'
],
[
'a host whose node-pty cannot load',
{
nativePreflight: async () => formatOrcadNativePreflightReport('blocked', 'load_crashed')
},
'cannot run terminals here (blocked: load_crashed)'
],
[
'a silent preflight',
{ nativePreflight: async () => '' },
'did not answer its native preflight'
]
])('falls back to Electron on %s and says why', async (_name, overrides, reason) => {
const selection = await selectServeRuntime(input(overrides))
expect(selection).toEqual({ kind: 'electron', reason: expect.stringContaining(reason) })
})
})
@@ -0,0 +1,150 @@
/**
* Which host runs `orca serve`: orcad on this machine's packaged slot, or Electron `--serve`.
* App-side on purpose: the CLI runs it through `orcad-local-serve-selection-entry.ts`, so the
* CLI bundle never carries the materializers.
*
* Opt-in while orcad serve is new: `ORCA_SERVE_RUNTIME=orcad`. Anything that stops orcad from
* serving this machine (no slot for the target, no pinned Node, a native module it cannot load,
* a host or packaging path it does not cover yet) falls back to Electron with one stderr line
* saying why. Everything this writes stays inside the desktop's userData (design D7): the slot
* under `orcad-artifacts/`, assembled from the template inside the app bundle.
*/
import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { runProcess } from '../../shared/child-process/run-process'
import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin'
import { ORCAD_VERSION_FILENAME, orcadNodeRuntimeRelativePath } from '../../shared/orcad-artifacts'
import {
ORCAD_NATIVE_PREFLIGHT_FLAG,
parseOrcadNativePreflightReport
} from '../../shared/orcad-native-preflight-report'
import {
SERVE_RUNTIME_ENV,
type ServeRuntimeSelection
} from '../../shared/orcad-local-serve-selection'
import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime'
import { detectNativeHostAbi, nativeSlotName } from './native-host-abi'
import { materializeOrcadArtifact } from '../ssh/orcad-artifact-materializer'
import { materializeCachedNodeRuntime } from '../ssh/pinned-runtime-materializer'
const NATIVE_PREFLIGHT_TIMEOUT_MS = 30_000
export type ServeRuntimeSelectionInput = {
env: NodeJS.ProcessEnv
platform: NodeJS.Platform
userDataPath: string
templateDirs: readonly string[]
/** Packaged macOS serve keeps Electron: orcad has no app-update handoff yet. */
usesMacUpdateHandoff: boolean
hostTarget?: () => string
materializeSlot?: typeof materializeOrcadArtifact
materializeRuntime?: typeof materializeCachedNodeRuntime
nativePreflight?: (runtime: string, entry: string) => Promise<string>
}
function isServerTarget(value: string): value is ServerTarget {
return Object.keys(NODE_RUNTIME_ASSETS).includes(value)
}
function electron(reason: string): ServeRuntimeSelection {
return { kind: 'electron', reason }
}
export async function selectServeRuntime(
input: ServeRuntimeSelectionInput
): Promise<ServeRuntimeSelection> {
if (input.env[SERVE_RUNTIME_ENV] !== 'orcad') {
return { kind: 'electron', reason: null }
}
if (input.platform === 'win32') {
return electron('local orcad on Windows is not enabled in this build')
}
if (input.usesMacUpdateHandoff) {
return electron(
'orcad has no macOS app-update handoff yet; packaged macOS serve stays on Electron'
)
}
const target = (input.hostTarget ?? (() => nativeSlotName(detectNativeHostAbi())))()
if (!isServerTarget(target)) {
return electron(`no orcad build exists for this host (${target})`)
}
const templateDir = input.templateDirs.find((candidate) => existsSync(candidate))
if (!templateDir) {
return electron('this Orca install carries no orcad template')
}
const cacheRoot = join(input.userDataPath, 'orcad-artifacts')
let slotDir: string
try {
slotDir = await (input.materializeSlot ?? materializeOrcadArtifact)(target, {
templateDir,
cacheRoot
})
await placeSlotRuntime(slotDir, target, cacheRoot, input.materializeRuntime)
} catch (error) {
return electron(`the orcad slot for ${target} could not be prepared: ${errorText(error)}`)
}
let runtime: string | null
try {
runtime = resolveBundledOrcadRuntime(slotDir)
} catch (error) {
return electron(`the orcad slot is incomplete: ${errorText(error)}`)
}
if (!runtime) {
return electron('the orcad slot names no pinned runtime')
}
const entry = join(slotDir, 'orcad.js')
const report = parseOrcadNativePreflightReport(
await (input.nativePreflight ?? runNativePreflight)(runtime, entry).catch(() => '')
)
if (!report) {
return electron('orcad did not answer its native preflight')
}
if (report.status === 'blocked' || report.status === 'degraded') {
return electron(
`orcad cannot run terminals here (${report.status}: ${report.reason ?? 'unknown'})`
)
}
return {
kind: 'orcad',
runtime,
entry,
version: readFileSync(join(slotDir, ORCAD_VERSION_FILENAME), 'utf8').trim()
}
}
/** The slot references its runtime beside it; the cached pinned Node is linked into place. */
async function placeSlotRuntime(
slotDir: string,
target: ServerTarget,
cacheRoot: string,
materializeRuntime: typeof materializeCachedNodeRuntime = materializeCachedNodeRuntime
): Promise<void> {
const destination = join(
slotDir,
...orcadNodeRuntimeRelativePath(target, NODE_RUNTIME_ASSETS[target].executableSha256)
)
if (existsSync(destination)) {
return
}
const cached = await materializeRuntime(target, cacheRoot, { fetcher: fetch })
mkdirSync(dirname(destination), { recursive: true })
try {
linkSync(cached, destination)
} catch {
copyFileSync(cached, destination)
chmodSync(destination, 0o755)
}
}
async function runNativePreflight(runtime: string, entry: string): Promise<string> {
const result = await runProcess({
program: runtime,
args: [entry, ORCAD_NATIVE_PREFLIGHT_FLAG],
timeoutMs: NATIVE_PREFLIGHT_TIMEOUT_MS
})
return result.stdout
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
@@ -0,0 +1,250 @@
/**
* `orca serve` on orcad, against the packaged slot: the native preflight the launcher asks
* first, desktop-serve flag parity on stdout (the readiness contract), and the shared-profile
* refusal while the desktop app holds the profile.
*/
import { existsSync, mkdtempSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'
import { tmpdir, userInfo } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { runProcess, spawnProcess } from '../../shared/child-process/run-process'
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
import { readDaemonPidRecord } from '../daemon/daemon-endpoint-incarnation'
import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../shared/orcad-artifacts'
import {
ORCAD_NATIVE_PREFLIGHT_FLAG,
parseOrcadNativePreflightReport
} from '../../shared/orcad-native-preflight-report'
import { ORCAD_LOCK_FILE_NAME } from './orcad-instance-lock'
import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime'
import { skipForMissingInputs } from './orcad-node-slot-fixture'
import { buildDaemonSessionClient } from './orcad-daemon-session-client-fixture'
const slotDir = resolve('out/orcad')
const runtime = existsSync(join(slotDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME))
? resolveBundledOrcadRuntime(slotDir)
: null
const skip = skipForMissingInputs('artifact', runtime ? [] : ['a Node orcad slot in out/orcad'])
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) {
// The terminal daemon outlives orcad by design; on Windows its open files pin the profile.
stopProfileDaemons(root)
removeTreeSync(root)
}
})
function stopProfileDaemons(userData: string): void {
const daemonDir = join(userData, 'daemon')
for (const name of existsSync(daemonDir) ? readdirSync(daemonDir) : []) {
const pid = /^daemon-v\d+\.pid$/u.test(name)
? readDaemonPidRecord(join(daemonDir, name))?.pid
: undefined
try {
if (pid) {
process.kill(pid, 'SIGKILL')
}
} catch {}
}
}
/** Kills a serve child and waits until it has really exited, so nothing holds its files. */
async function killAndWait(child: ReturnType<typeof spawnProcess>): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) {
return
}
const exited = new Promise((settle) => child.once('exit', settle))
child.kill('SIGKILL')
await exited
}
/** Without Vitest's markers: daemon-entry.js does not start its server under VITEST. */
function serveEnv(userData: string): NodeJS.ProcessEnv {
return {
...Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith('VITEST') && key !== 'NODE_ENV')
),
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_DISABLE_MACOS_LOGIN_SHELL: '1',
ORCA_USER_DATA: userData
}
}
function profile(): string {
const root = mkdtempSync(join(tmpdir(), 'orcad-serve-parity-'))
roots.push(root)
return root
}
describe.skipIf(skip)('orca serve on orcad', () => {
it('answers the launcher native preflight with one report line', async () => {
const result = await runProcess({
program: runtime!,
args: [join(slotDir, 'orcad.js'), ORCAD_NATIVE_PREFLIGHT_FLAG],
timeoutMs: 30_000
})
expect(result.code, result.stderr).toBe(0)
expect(parseOrcadNativePreflightReport(result.stdout)?.status).toMatch(/^(ok|unverifiable)$/u)
})
it('prints only the recipe line, exactly as Electron serve does', async () => {
const projectRoot = profile()
const child = spawnProcess({
program: runtime!,
args: [
join(slotDir, 'orcad.js'),
'--bind',
'127.0.0.1',
'--port',
'0',
'--recipe-json',
'--project-root',
projectRoot
],
env: serveEnv(profile()),
timeoutMs: null
})
let stdout = ''
let stderr = ''
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
try {
await new Promise<void>((resolveLine, reject) => {
const timer = setTimeout(() => reject(new Error(`no recipe line: ${stderr}`)), 120_000)
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
if (stdout.includes('\n')) {
clearTimeout(timer)
resolveLine()
}
})
child.once('exit', (code) => {
clearTimeout(timer)
reject(new Error(`orcad exited ${String(code)}: ${stderr}`))
})
})
// Anything after the recipe line would break a reader of this contract.
await new Promise((settle) => setTimeout(settle, 1_000))
} finally {
await killAndWait(child)
}
const lines = stdout.split('\n').filter(Boolean)
expect(lines, stderr).toHaveLength(1)
expect(JSON.parse(lines[0]!)).toEqual({
schemaVersion: 1,
pairingCode: expect.any(String),
projectRoot
})
}, 150_000)
it('refuses with exit 78 while the desktop app holds the profile', async () => {
const userData = profile()
writeFileSync(
join(userData, ORCAD_LOCK_FILE_NAME),
JSON.stringify({
pid: process.pid,
startedAtMs: null,
identity: process.platform === 'win32' ? userInfo().username : String(process.getuid?.()),
version: 'desktop-test',
acquiredAt: new Date().toISOString(),
nonce: 'desktop',
role: 'desktop'
})
)
const result = await runProcess({
program: runtime!,
args: [join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'],
env: serveEnv(userData),
timeoutMs: 60_000
})
expect(result.code).toBe(78)
expect(result.stdout).toBe('')
expect(result.stderr).toContain('The Orca desktop app')
}, 90_000)
// POSIX: the client spawns /bin/sh, and a short /tmp root keeps the daemon socket path legal.
it.runIf(process.platform !== 'win32')(
'keeps a live terminal across a serve restart on the shared profile (D7)',
async () => {
const userData = realpathSync(mkdtempSync('/tmp/orca-serve-d7-'))
roots.push(userData)
const client = join(userData, 'daemon-client.cjs')
await buildDaemonSessionClient(client)
const session = async (op: 'create' | 'attach', marker: string) => {
const result = await runProcess({
program: runtime!,
args: [client, op, join(userData, 'daemon'), 'serve-d7', marker, userData],
env: serveEnv(userData),
timeoutMs: 30_000
})
expect(result.code, result.stderr).toBe(0)
return JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}')
}
const first = await serveOnce(userData)
let daemonPid: number | null = first
try {
const created = await session('create', 'BEFORE')
expect(created).toMatchObject({ isReattach: false, output: true })
await stopServe(userData)
// The same daemon, in the same \`<userData>/daemon\` Electron serve uses, is adopted.
expect(await serveOnce(userData)).toBe(first)
expect(await session('attach', 'AFTER')).toEqual({
pid: created.pid,
isReattach: true,
output: true
})
} finally {
await stopServe(userData)
if (daemonPid) {
try {
process.kill(daemonPid, 'SIGKILL')
} catch {}
daemonPid = null
}
}
},
300_000
)
})
const running = new Map<string, ReturnType<typeof spawnProcess>>()
/** Starts `orca serve --json` on orcad and returns the terminal daemon pid its readiness names. */
async function serveOnce(userData: string): Promise<number> {
const child = spawnProcess({
program: runtime!,
args: [join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'],
env: serveEnv(userData),
timeoutMs: null
})
running.set(userData, child)
let stdout = ''
let stderr = ''
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
const line = await new Promise<string>((resolveLine, reject) => {
const timer = setTimeout(() => reject(new Error(`no readiness: ${stderr}`)), 120_000)
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
const newline = stdout.indexOf('\n')
if (newline !== -1) {
clearTimeout(timer)
resolveLine(stdout.slice(0, newline))
}
})
})
const daemon = JSON.parse(line).health?.terminalDaemon
expect(daemon?.state, stderr).toBe('live')
return daemon.pid
}
/** SIGTERM is serve's graceful stop on POSIX; it leaves the daemon running by design. */
async function stopServe(userData: string): Promise<void> {
const child = running.get(userData)
running.delete(userData)
if (!child || child.exitCode !== null) {
return
}
const exited = new Promise((settle) => child.once('exit', settle))
child.kill('SIGTERM')
await exited
}
+29
View File
@@ -0,0 +1,29 @@
/** Serve-readiness pieces shared by the desktop `--serve` host and orcad, so their stdout matches. */
import { statSync } from 'node:fs'
import { isAbsolute } from 'node:path'
export async function renderServePairingQr(pairingUrl: string): Promise<string | null> {
// Why dynamic: qrcode is only reachable from mobile pairing, so launch should
// not parse it for the majority who never pair a device.
const QRCode = await import('qrcode')
try {
return await QRCode.toString(pairingUrl, { type: 'terminal', small: true })
} catch {
try {
return await QRCode.toString(pairingUrl, { type: 'utf8' })
} catch {
return null
}
}
}
/** The recipe line names this root, so it must be a real absolute directory. */
export function assertServeProjectRoot(projectRoot: string): string {
if (!isAbsolute(projectRoot)) {
throw new Error(`--serve-project-root must be absolute: ${projectRoot}`)
}
if (!statSync(projectRoot).isDirectory()) {
throw new Error(`--serve-project-root must be a directory: ${projectRoot}`)
}
return projectRoot
}
@@ -114,6 +114,9 @@ vi.mock('../server/serve-stdout-boundary')
vi.mock('./serve-desktop-activation', () => ({
createServeDesktopActivationGate: () => ({})
}))
vi.mock('./desktop-profile-instance-lock', () => ({
acquireDesktopProfileInstanceLock: vi.fn(() => ({ state: 'unavailable' }))
}))
vi.mock('./single-instance-lock', () => ({
shouldBypassSingleInstanceLock: () => false,
shouldSkipSingleInstanceLock: () => false,
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcadInstanceLockError, type OrcadInstanceLock } from '../orcad/orcad-instance-lock'
import { acquireDesktopProfileInstanceLock } from './desktop-profile-instance-lock'
describe('the desktop share of the profile instance lock', () => {
it('takes the lock as the desktop', () => {
const lock: OrcadInstanceLock = Object.assign(Object.create(null), { release: vi.fn() })
const acquire = vi.fn(() => lock)
expect(acquireDesktopProfileInstanceLock('/profile', acquire)).toEqual({
state: 'acquired',
lock
})
expect(acquire).toHaveBeenCalledWith('/profile', { role: 'desktop' })
})
it('refuses while a live orcad serves the profile, and says so', () => {
const write = vi.fn()
const result = acquireDesktopProfileInstanceLock(
'/profile',
() => {
throw new OrcadInstanceLockError('orcad_instance_lock_held', 'Another orcad (pid 7) ...')
},
write
)
expect(result).toEqual({
state: 'held',
message: '[single-instance] Another orcad (pid 7) ...'
})
expect(write).toHaveBeenCalledWith(2, '[single-instance] Another orcad (pid 7) ...\n')
})
it('still starts when the lock cannot be taken for any other reason', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(
acquireDesktopProfileInstanceLock('/profile', () => {
throw new OrcadInstanceLockError('orcad_instance_lock_unreadable', 'unreadable')
})
).toEqual({ state: 'unavailable' })
})
})
@@ -0,0 +1,41 @@
/**
* The desktop app takes orcad's instance lock on its userData profile, so `orca serve` on
* orcad and the desktop refuse each other instead of both writing one profile.
*
* Electron's single-instance lock only fences other Electron launches; orcad is a plain Node
* process that cannot see it. Sharing orcad's lock file gives both hosts one owner record.
*/
import {
acquireOrcadInstanceLock,
OrcadInstanceLockError,
type OrcadInstanceLock
} from '../orcad/orcad-instance-lock'
import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startup-diagnostics'
export type DesktopProfileLockResult =
| { state: 'acquired'; lock: OrcadInstanceLock }
| { state: 'held'; message: string }
/** The lock could not be taken for another reason; the desktop starts as it did before it. */
| { state: 'unavailable' }
export function acquireDesktopProfileInstanceLock(
userDataPath: string,
acquire: typeof acquireOrcadInstanceLock = acquireOrcadInstanceLock,
write?: StartupDiagnosticSink
): DesktopProfileLockResult {
try {
const lock = acquire(userDataPath, { role: 'desktop' })
// Best effort: a lock left by a dead pid is reclaimed on the next start anyway.
process.once('exit', () => lock.release())
return { state: 'acquired', lock }
} catch (error) {
if (error instanceof OrcadInstanceLockError && error.code === 'orcad_instance_lock_held') {
const message = `[single-instance] ${error.message}`
writeStartupDiagnosticLine(message, write)
return { state: 'held', message }
}
// Why not refuse: only a proven live holder may stop a desktop that never took this lock before.
console.warn('[single-instance] Could not take the shared profile lock:', error)
return { state: 'unavailable' }
}
}
@@ -43,6 +43,7 @@ import {
import { setSpawnObserver } from '../../shared/child-process/spawn-observer'
import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation'
import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary'
import { acquireDesktopProfileInstanceLock } from './desktop-profile-instance-lock'
import { createServeDesktopActivationGate } from './serve-desktop-activation'
import {
shouldBypassSingleInstanceLock,
@@ -261,6 +262,14 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)
return false
}
// Why after Electron's lock: that one fences other desktops; this one fences orcad `orca serve`.
if (!skip && !bypass) {
const profileLock = acquireDesktopProfileInstanceLock(getCanonicalUserDataPath())
if (profileLock.state === 'held') {
app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)
return false
}
}
state.profileStateAdmission = acquireProfileStateRuntimeAdmission(getCanonicalUserDataPath())
// Renderer and worker defaults must be fixed before any session exists.
initializeBrowserProcessUserAgent(
+5 -24
View File
@@ -1,8 +1,9 @@
import { existsSync, statSync } from 'node:fs'
import { isAbsolute, join } from 'node:path'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { app } from 'electron'
import { resolveAdvertisedPairingEndpoint } from '../runtime/pairing-endpoint'
import { notifyServeSupervisorReady } from '../serve-update-handoff'
import { assertServeProjectRoot, renderServePairingQr } from '../server/serve-pairing-output'
import { mainProcessState as state } from './main-process-state'
import { getServeOptions, type ServeOptions } from './serve-options'
@@ -18,21 +19,6 @@ export function getBundledWebClientRoot(): string | undefined {
return roots.find((root) => existsSync(join(root, 'web-index.html')))
}
async function renderTerminalPairingQr(pairingUrl: string): Promise<string | null> {
// Why dynamic: qrcode is only reachable from mobile pairing, so launch should
// not parse it for the majority who never pair a device.
const QRCode = await import('qrcode')
try {
return await QRCode.toString(pairingUrl, { type: 'terminal', small: true })
} catch {
try {
return await QRCode.toString(pairingUrl, { type: 'utf8' })
} catch {
return null
}
}
}
export async function printServeReady(options: ServeOptions): Promise<void> {
const runtime = state.runtime
const runtimeRpc = state.runtimeRpc
@@ -43,12 +29,7 @@ export async function printServeReady(options: ServeOptions): Promise<void> {
if (!options.projectRoot) {
throw new Error('--serve-recipe-json requires --serve-project-root')
}
if (!isAbsolute(options.projectRoot)) {
throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`)
}
if (!statSync(options.projectRoot).isDirectory()) {
throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`)
}
assertServeProjectRoot(options.projectRoot)
}
const boundEndpoint = runtimeRpc.getWebSocketEndpoint()
const advertised = boundEndpoint
@@ -67,7 +48,7 @@ export async function printServeReady(options: ServeOptions): Promise<void> {
})
const pairingQr =
pairing.available && options.mobilePairing
? await renderTerminalPairingQr(pairing.pairingUrl)
? await renderServePairingQr(pairing.pairingUrl)
: null
await state.serveReadinessPublisher.publish(
{
+58
View File
@@ -0,0 +1,58 @@
/**
* The contract between `orca serve` (CLI) and the app-side entry that decides whether this
* machine can serve on orcad. The CLI never loads that logic: it runs the packaged app's
* `out/main/<ORCAD_LOCAL_SERVE_SELECTION_ENTRY>.js` under ELECTRON_RUN_AS_NODE and reads one line.
*/
export const SERVE_RUNTIME_ENV = 'ORCA_SERVE_RUNTIME'
export const ORCAD_LOCAL_SERVE_SELECTION_ENTRY = 'orcad/orcad-local-serve-selection-entry'
export const ORCAD_LOCAL_SERVE_SELECTION_FLAGS = {
userData: '--user-data',
appRoot: '--app-root',
macUpdateHandoff: '--mac-update-handoff'
} as const
const RESULT_MARKER = 'ORCA_SERVE_RUNTIME'
export type ServeRuntimeSelection =
| { kind: 'orcad'; runtime: string; entry: string; version: string }
/** `reason` is null when orcad was not asked for, so nothing is printed. */
| { kind: 'electron'; reason: string | null }
export function formatServeRuntimeSelection(selection: ServeRuntimeSelection): string {
return `${RESULT_MARKER} ${JSON.stringify(selection)}`
}
/** The last result line in `output`, or null when none parses. */
export function parseServeRuntimeSelection(output: string): ServeRuntimeSelection | null {
const line = output
.split(/\r?\n/u)
.findLast((candidate) => candidate.startsWith(`${RESULT_MARKER} `))
if (!line) {
return null
}
try {
const parsed: unknown = JSON.parse(line.slice(RESULT_MARKER.length + 1))
if (!parsed || typeof parsed !== 'object' || !('kind' in parsed)) {
return null
}
const record = Object.fromEntries(Object.entries(parsed))
if (
record.kind === 'orcad' &&
typeof record.runtime === 'string' &&
typeof record.entry === 'string' &&
typeof record.version === 'string'
) {
return {
kind: 'orcad',
runtime: record.runtime,
entry: record.entry,
version: record.version
}
}
if (record.kind === 'electron') {
return { kind: 'electron', reason: typeof record.reason === 'string' ? record.reason : null }
}
return null
} catch {
return null
}
}
@@ -0,0 +1,47 @@
/**
* `orcad.js --orcad-native-preflight`: the node-pty precondition orcad checks at boot, run on
* its own so `orca serve` can choose Electron before handing the terminal to an orcad that
* would refuse to start. One JSON line on stdout, exit 0 whatever the verdict.
*/
export const ORCAD_NATIVE_PREFLIGHT_FLAG = '--orcad-native-preflight'
const REPORT_TYPE = 'orcad_native_preflight'
export type OrcadNativePreflightReport = {
type: typeof REPORT_TYPE
status: 'ok' | 'degraded' | 'blocked' | 'unverifiable'
reason: string | null
}
export function formatOrcadNativePreflightReport(
status: OrcadNativePreflightReport['status'],
reason: string | null
): string {
return JSON.stringify({ type: REPORT_TYPE, status, reason })
}
/** The last report line in `output`, or null when none parses. */
export function parseOrcadNativePreflightReport(output: string): OrcadNativePreflightReport | null {
for (const line of output.split(/\r?\n/u).toReversed()) {
try {
const parsed: unknown = JSON.parse(line)
if (
parsed &&
typeof parsed === 'object' &&
'type' in parsed &&
parsed.type === REPORT_TYPE &&
'status' in parsed &&
(parsed.status === 'ok' ||
parsed.status === 'degraded' ||
parsed.status === 'blocked' ||
parsed.status === 'unverifiable')
) {
const reason =
'reason' in parsed && typeof parsed.reason === 'string' ? parsed.reason : null
return { type: REPORT_TYPE, status: parsed.status, reason }
}
} catch {
// Not the report line.
}
}
return null
}