From 6b2652070a5d823481c7bd6a391b1cbc0ceff87e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:02:57 -0700 Subject: [PATCH 1/5] fix(orcad): answer host paths honestly and ship the watcher child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orcad's AppEnvironment implemented three of seven AppPathNames and returned the userData directory for the rest — including 'exe', where a data directory is not an executable. Every name now has a Node answer: 'appData' is the platform's per-user application-data root, 'logs' lives inside the data root so a headless deployment stays one removable directory, 'downloads' honours XDG_DOWNLOAD_DIR, and 'exe' is the Node binary. getAppPath() is the directory orcad was launched from rather than cwd, so children resolve against the bundle instead of wherever the supervisor happened to be. The watcher child was the load-bearing consequence: resolveWatcherProcessEntryPath probed for the adjacent entry only when !isPackaged, so orcad resolved a desktop out/main path that no deployment has — and build-orcad never emitted the child anyway. isPackaged stays true (consumers read it as "production, not a dev checkout" and it gates HTTPS-only skill downloads); the resolver now asks whether the app root is an asar archive, which is the question it actually meant. The child ships beside orcad.js, and the build forks it to prove it runs. --- config/scripts/build-orcad.mjs | 77 ++++++++++- .../ipc/parcel-watcher-entry-path.test.ts | 19 +++ src/main/ipc/parcel-watcher-entry-path.ts | 8 +- src/main/orcad/orcad-app-paths.test.ts | 122 ++++++++++++++++++ src/main/orcad/orcad-app-paths.ts | 82 ++++++++++++ src/main/orcad/orcad-entry.ts | 22 ++-- .../orcad/orcad-forked-child-paths.test.ts | 61 +++++++++ 7 files changed, 372 insertions(+), 19 deletions(-) create mode 100644 src/main/orcad/orcad-app-paths.test.ts create mode 100644 src/main/orcad/orcad-app-paths.ts create mode 100644 src/main/orcad/orcad-forked-child-paths.test.ts diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index c3f57a7f4cab..05948acd529f 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -7,16 +7,21 @@ * the only ones that statically import `node:sqlite`, so dropping them is what keeps * the host Node floor at 18 instead of 22.5+. */ -import { spawnSync } from 'node:child_process' +import { fork, spawnSync } from 'node:child_process' import { build } from 'esbuild' -import { chmodSync, copyFileSync, mkdirSync, rmSync } from 'node:fs' -import { arch, platform } from 'node:os' +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { arch, platform, tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = join(ROOT, 'out', 'orcad') const ENTRY = join(ROOT, 'src/main/orcad/main.ts') +// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher +// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath` +// looks for it in the app root. A deployment has no desktop out/main to fall back to. +const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts') +const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}` const OUT_FILE = join(OUT_DIR, 'orcad.js') const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME) @@ -59,6 +64,21 @@ if (process.platform !== 'win32') { chmodSync(AGENT_BROWSER_OUTPUT, 0o755) } +await build({ + entryPoints: [WATCHER_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: WATCHER_OUT_FILE, + external: EXTERNAL, + plugins: [externalNativeAddons], + minify: true, + sourcemap: false, + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'error' +}) + const result = await build({ entryPoints: [ENTRY], bundle: true, @@ -144,6 +164,13 @@ if (graphErrors.length > 0) { ) process.exitCode = 1 } + const watcherFailure = await smokeLoadWatcherChild() + if (watcherFailure) { + console.error( + `[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}` + ) + process.exitCode = 1 + } } if (process.exitCode !== 1) { @@ -151,3 +178,47 @@ if (process.exitCode !== 1) { `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.` ) } + +/** + * Fork the shipped watcher child and drive one message through it. + * + * Why a real fork and not existsSync: the file being present says nothing about whether + * its graph resolves under plain Node, and this child is only ever reached through + * `fork()` at runtime — a broken one degrades silently to in-process watching. + * `subscribe-started` is acked before the native module is touched, so this passes on a + * build machine with no compiled @parcel/watcher. + */ +async function smokeLoadWatcherChild() { + const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-')) + const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr += String(chunk) + }) + try { + return await new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill('SIGKILL') + resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`) + }, 30_000) + const settle = (failure) => { + clearTimeout(timer) + resolve(failure) + } + child.on('message', (message) => { + if (message?.op === 'subscribe-started') { + child.disconnect() + } + }) + child.on('error', (error) => settle(`fork failed: ${error.message}`)) + // Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code + // or a signal here is a load failure rather than a clean teardown. + child.on('exit', (code, signal) => + settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`) + ) + child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} }) + }) + } finally { + rmSync(probeDir, { recursive: true, force: true }) + } +} diff --git a/src/main/ipc/parcel-watcher-entry-path.test.ts b/src/main/ipc/parcel-watcher-entry-path.test.ts index 2b29c6c9016d..1331cb2b7a9f 100644 --- a/src/main/ipc/parcel-watcher-entry-path.test.ts +++ b/src/main/ipc/parcel-watcher-entry-path.test.ts @@ -41,6 +41,25 @@ describe('resolveWatcherProcessEntryPath', () => { ) }) + it('uses the adjacent entry for a packaged host whose app root is not an asar', () => { + // orcad: a packaged Node bundle that ships this child beside orcad.js. Gating the + // adjacent probe on isPackaged sent it to a desktop out/main that never exists here. + const orcadRoot = path.join(path.sep, 'opt', 'orca') + const adjacentEntry = path.join(orcadRoot, 'parcel-watcher-process-entry.js') + + expect( + resolveWatcherProcessEntryPath(orcadRoot, true, (candidate) => candidate === adjacentEntry) + ).toBe(adjacentEntry) + }) + + it('falls back to the nested entry when a non-asar packaged host ships no adjacent child', () => { + const orcadRoot = path.join(path.sep, 'opt', 'orca') + + expect(resolveWatcherProcessEntryPath(orcadRoot, true, () => false)).toBe( + path.join(orcadRoot, 'out', 'main', 'parcel-watcher-process-entry.js') + ) + }) + it('uses resourcesPath for packaged Electron-as-Node serve processes', () => { const resourcesPath = path.join('Applications', 'Orca.app', 'Contents', 'Resources') const packagedEntry = path.join( diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts index a61d240f2bb9..229dcdc65e28 100644 --- a/src/main/ipc/parcel-watcher-entry-path.ts +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -18,11 +18,15 @@ export function resolveWatcherProcessEntryPath( ): string { // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the // packaged entry must be forked from app.asar.unpacked. - const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const usesAsarArchive = isPackaged && appPath.includes('app.asar') + const basePath = usesAsarArchive ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath const adjacentBuildEntry = join(basePath, 'parcel-watcher-process-entry.js') // Why: electron-vite's unpackaged appPath is already out/main. Appending // out/main again silently disables crash isolation in dev and E2E builds. - if (!isPackaged && pathExists(adjacentBuildEntry)) { + // Why asar and not isPackaged: orcad is a packaged non-Electron host whose app root + // holds orcad.js and this child side by side. Only the asar layout nests it under + // out/main, and only there is the adjacent probe guaranteed to miss. + if (!usesAsarArchive && pathExists(adjacentBuildEntry)) { return adjacentBuildEntry } return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js') diff --git a/src/main/orcad/orcad-app-paths.test.ts b/src/main/orcad/orcad-app-paths.test.ts new file mode 100644 index 000000000000..12ec51e29802 --- /dev/null +++ b/src/main/orcad/orcad-app-paths.test.ts @@ -0,0 +1,122 @@ +import { homedir, tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppPathName } from '../../shared/app-environment' +import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' + +const ALL_PATH_NAMES: AppPathName[] = [ + 'userData', + 'home', + 'appData', + 'temp', + 'downloads', + 'logs', + 'exe' +] + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value }) +} + +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + vi.unstubAllEnvs() +}) + +describe('resolveUserDataPath', () => { + it('prefers ORCA_USER_DATA, then XDG_DATA_HOME, then ~/.orca', () => { + vi.stubEnv('ORCA_USER_DATA', join(sep, 'srv', 'orca-state')) + vi.stubEnv('XDG_DATA_HOME', join(sep, 'xdg')) + expect(resolveUserDataPath()).toBe(join(sep, 'srv', 'orca-state')) + + vi.stubEnv('ORCA_USER_DATA', '') + expect(resolveUserDataPath()).toBe(join(sep, 'xdg', 'Orca')) + + vi.stubEnv('XDG_DATA_HOME', '') + expect(resolveUserDataPath()).toBe(join(homedir(), '.orca')) + }) +}) + +describe('resolveOrcadPath', () => { + it('answers every path name without ever falling back to the data directory', () => { + vi.stubEnv('ORCA_USER_DATA', join(sep, 'srv', 'orca-state')) + const answers = new Map(ALL_PATH_NAMES.map((name) => [name, resolveOrcadPath(name)])) + + for (const [name, answer] of answers) { + expect(answer, `${name} answered nothing`).toBeTruthy() + if (name !== 'userData') { + // The catch-all this replaced returned the data directory for four of seven + // names, 'exe' included — a data directory is not an executable. + expect(answer, `${name} answered the userData directory`).not.toBe( + join(sep, 'srv', 'orca-state') + ) + } + } + }) + + it("answers 'exe' with the Node binary running this process", () => { + expect(resolveOrcadPath('exe')).toBe(process.execPath) + }) + + it("keeps 'logs' inside the data root so the whole deployment is one directory", () => { + vi.stubEnv('ORCA_USER_DATA', join(sep, 'srv', 'orca-state')) + expect(resolveOrcadPath('logs')).toBe(join(sep, 'srv', 'orca-state', 'logs')) + }) + + it("answers 'home' and 'temp' from the OS", () => { + expect(resolveOrcadPath('home')).toBe(homedir()) + expect(resolveOrcadPath('temp')).toBe(tmpdir()) + }) + + it("answers 'appData' with the per-user application-data root of each platform", () => { + setPlatform('darwin') + expect(resolveOrcadPath('appData')).toBe(join(homedir(), 'Library', 'Application Support')) + + setPlatform('win32') + vi.stubEnv('APPDATA', join('C:', 'Users', 'orca', 'AppData', 'Roaming')) + expect(resolveOrcadPath('appData')).toBe(join('C:', 'Users', 'orca', 'AppData', 'Roaming')) + vi.stubEnv('APPDATA', '') + expect(resolveOrcadPath('appData')).toBe(join(homedir(), 'AppData', 'Roaming')) + + setPlatform('linux') + vi.stubEnv('XDG_CONFIG_HOME', join(sep, 'xdg-config')) + expect(resolveOrcadPath('appData')).toBe(join(sep, 'xdg-config')) + vi.stubEnv('XDG_CONFIG_HOME', '') + expect(resolveOrcadPath('appData')).toBe(join(homedir(), '.config')) + }) + + it("answers 'downloads' from XDG_DOWNLOAD_DIR before the home default", () => { + vi.stubEnv('XDG_DOWNLOAD_DIR', join(sep, 'srv', 'incoming')) + expect(resolveOrcadPath('downloads')).toBe(join(sep, 'srv', 'incoming')) + + vi.stubEnv('XDG_DOWNLOAD_DIR', '') + expect(resolveOrcadPath('downloads')).toBe(join(homedir(), 'Downloads')) + }) +}) + +describe('resolveOrcadInstallRoot', () => { + it('is the directory holding the running bundle, not the working directory', () => { + expect(resolveOrcadInstallRoot(join(sep, 'opt', 'orca', 'orcad.js'))).toBe( + join(sep, 'opt', 'orca') + ) + }) + + it('absolutizes a relative script path against the working directory', () => { + expect(resolveOrcadInstallRoot(join('out', 'orcad', 'orcad.js'))).toBe( + join(process.cwd(), 'out', 'orcad') + ) + }) + + it('refuses instead of guessing when the process has no main script', () => { + const originalArgv = process.argv + // `node -e` leaves argv[1] unset; cwd would be a guess, not an answer. + process.argv = [process.execPath] + try { + expect(() => resolveOrcadInstallRoot()).toThrow(/orcad_install_root_unavailable/) + } finally { + process.argv = originalArgv + } + }) +}) diff --git a/src/main/orcad/orcad-app-paths.ts b/src/main/orcad/orcad-app-paths.ts new file mode 100644 index 000000000000..7b31404e9ddf --- /dev/null +++ b/src/main/orcad/orcad-app-paths.ts @@ -0,0 +1,82 @@ +/** + * The path half of orcad's `AppEnvironment`: every `AppPathName` gets a real answer for + * a Node host, or throws. + * + * Why not a catch-all: returning the data directory for names this host had not thought + * about — `'exe'` above all — hands the caller a plausible string it then forks, installs + * beside, or resolves resources against. The failure surfaces far from here, as a missing + * file rather than a missing implementation. + */ +import { homedir, tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' +import type { AppPathName } from '../../shared/app-environment' + +/** Empty is unset: a supervisor that exports `APPDATA=` has configured nothing. */ +function env(name: string): string | null { + const value = process.env[name] + return value ? value : null +} + +/** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */ +export function resolveUserDataPath(): string { + const explicit = env('ORCA_USER_DATA') + if (explicit) { + return explicit + } + const xdg = env('XDG_DATA_HOME') + return xdg ? join(xdg, 'Orca') : join(homedir(), '.orca') +} + +/** Electron's `'appData'` definition, computed without Electron. */ +function resolveAppDataPath(): string { + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support') + } + if (process.platform === 'win32') { + return env('APPDATA') ?? join(homedir(), 'AppData', 'Roaming') + } + return env('XDG_CONFIG_HOME') ?? join(homedir(), '.config') +} + +/** + * The directory orcad was launched from — the bundle root that `orcad.js` and the + * children shipped beside it (the watcher entry, the agent-browser binary) live in. + * + * Why argv[1] and not cwd: cwd is wherever the operator or supervisor happened to be, + * so resolving siblings against it finds them only by luck. Node resolves argv[1] to an + * absolute path before we see it; `resolve` covers a caller-supplied relative one. + */ +export function resolveOrcadInstallRoot(scriptPath = process.argv[1]): string { + if (!scriptPath) { + throw new Error( + 'orcad_install_root_unavailable — process.argv[1] is unset, so this process has no ' + + 'bundle root to resolve sibling entry points against' + ) + } + return dirname(resolve(scriptPath)) +} + +export function resolveOrcadPath(name: AppPathName): string { + switch (name) { + case 'userData': + return resolveUserDataPath() + case 'home': + return homedir() + case 'temp': + return tmpdir() + case 'appData': + return resolveAppDataPath() + // Why inside userData rather than Electron's macOS ~/Library/Logs: a headless + // deployment's whole state is one removable directory, and a service account + // often has no Library tree to write into. + case 'logs': + return join(resolveUserDataPath(), 'logs') + // The Node binary running this bundle. There is no app executable to point at. + case 'exe': + return process.execPath + // The XDG user-dirs override, else the cross-platform default location. + case 'downloads': + return env('XDG_DOWNLOAD_DIR') ?? join(homedir(), 'Downloads') + } +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index d01c2227a406..105264dd8e71 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -11,28 +11,17 @@ * the runtime factory, but only when an Electron serve sidecar or an operator-supplied * Chromium proves available at startup. */ -import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' import process from 'node:process' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' +import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' -/** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */ -function resolveUserDataPath(): string { - const explicit = process.env.ORCA_USER_DATA - if (explicit) { - return explicit - } - const xdg = process.env.XDG_DATA_HOME - return xdg ? join(xdg, 'Orca') : join(homedir(), '.orca') -} let runOrcadQuitHandlers = (): void => {} function createNodeAppEnvironment(): AppEnvironment { - const userData = resolveUserDataPath() const quitHandlers: (() => void)[] = [] // The main signal handler awaits runtime and browser teardown before process.exit. // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. @@ -46,9 +35,14 @@ function createNodeAppEnvironment(): AppEnvironment { } } return { - getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData), - getAppPath: () => process.cwd(), + getPath: resolveOrcadPath, + getAppPath: () => resolveOrcadInstallRoot(), getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', + // Why still true: consumers read this as "production build, not a dev checkout" — + // it gates HTTPS-only skill downloads, the real CLI command name, and shell-PATH + // hydration. Answering false to satisfy a path resolver would relax a security + // posture. Layout questions must ask whether the app root is an asar archive + // instead (see parcel-watcher-entry-path.ts). isPackaged: () => true, onWillQuit: (handler) => quitHandlers.push(handler), exit: (code = 0) => process.exit(code), diff --git a/src/main/orcad/orcad-forked-child-paths.test.ts b/src/main/orcad/orcad-forked-child-paths.test.ts new file mode 100644 index 000000000000..e66b3c44702a --- /dev/null +++ b/src/main/orcad/orcad-forked-child-paths.test.ts @@ -0,0 +1,61 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getAppEnvironment } from '../../shared/app-environment' +import { getWatcherProcessEntryPath } from '../ipc/parcel-watcher-entry-path' +import { installOrcadHostAdapters } from './orcad-entry' + +/** + * The composition, not the pieces: orcad's real host adapters against the real resolver + * and a real deployment layout on disk. Both halves of this passed their own unit tests + * while the running server forked a path that has never existed in an orcad deployment. + */ +describe('orcad forked-child paths', () => { + let deployRoot: string + let originalArgv: string[] + + beforeEach(() => { + deployRoot = mkdtempSync(join(tmpdir(), 'orcad-deploy-')) + // The layout build-orcad.mjs emits: the bundle and its children side by side. + writeFileSync(join(deployRoot, 'orcad.js'), '') + writeFileSync(join(deployRoot, 'parcel-watcher-process-entry.js'), '') + originalArgv = process.argv + process.argv = [process.execPath, join(deployRoot, 'orcad.js')] + installOrcadHostAdapters() + }) + + afterEach(() => { + process.argv = originalArgv + rmSync(deployRoot, { recursive: true, force: true }) + }) + + it('forks the watcher child shipped beside orcad.js', () => { + expect(getWatcherProcessEntryPath()).toBe(join(deployRoot, 'parcel-watcher-process-entry.js')) + }) + + it('ignores a decoy child under the working directory', () => { + // A cwd-derived app root finds the real child only when the operator happens to + // launch from the install directory, which a supervisor never does — and here it + // would find someone else's build instead. + const elsewhere = mkdtempSync(join(tmpdir(), 'orcad-cwd-')) + mkdirSync(join(elsewhere, 'out', 'main'), { recursive: true }) + writeFileSync(join(elsewhere, 'out', 'main', 'parcel-watcher-process-entry.js'), '') + writeFileSync(join(elsewhere, 'parcel-watcher-process-entry.js'), '') + const originalCwd = process.cwd() + process.chdir(elsewhere) + try { + expect(getWatcherProcessEntryPath()).toBe(join(deployRoot, 'parcel-watcher-process-entry.js')) + } finally { + process.chdir(originalCwd) + rmSync(elsewhere, { recursive: true, force: true }) + } + }) + + it('reports the install root and the Node binary, not the data directory', () => { + const environment = getAppEnvironment() + expect(environment.getAppPath()).toBe(deployRoot) + expect(environment.getPath('exe')).toBe(process.execPath) + expect(environment.getPath('exe')).not.toBe(environment.getPath('userData')) + }) +}) From 22a507054f3e1a456a945bc0a5013a654107d039 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:19:56 -0700 Subject: [PATCH 2/5] fix(ports): stop joining an undefined resourcesPath on a non-Electron host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveWorkerEntryPath` branched on `isPackaged` alone and joined `process.resourcesPath`. orcad reports `isPackaged` true — correctly, it is a production build, and ~15 consumers read it that way to gate HTTPS-only skill downloads and the real CLI name — but `process.resourcesPath` is Electron-only and `undefined` under plain Node. So the packaged branch threw `TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string` where a clean "worker unavailable" was the honest outcome. The type said `resourcesPath: string`, which is how it went unnoticed; it is now `string | undefined`, so the compiler carries the fact. A host with no Electron resources tree has no asar to look in, so it falls back to the module directory and lets the caller report a missing worker. Found by the item 1 agent while auditing the same `isPackaged` defect class in the watcher. Verified in both directions: reverting the guard reproduces the TypeError. --- .../ports/port-scan-command-client.test.ts | 26 +++++++++++++++++++ src/main/ports/port-scan-command-client.ts | 10 +++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/main/ports/port-scan-command-client.test.ts b/src/main/ports/port-scan-command-client.test.ts index d1a15d297749..c2bece624f88 100644 --- a/src/main/ports/port-scan-command-client.test.ts +++ b/src/main/ports/port-scan-command-client.test.ts @@ -298,3 +298,29 @@ describe('PortScanCommandClient on a real worker thread', () => { } }, 30_000) }) + +describe('resolveWorkerEntryPath on a non-Electron host', () => { + // Why: orcad reports isPackaged true (it is a production build), but + // process.resourcesPath is Electron-only and undefined there. Joining undefined threw + // a TypeError instead of failing as a missing worker — a crash where a clean + // "worker unavailable" was the honest outcome. + it('does not join an undefined resourcesPath', () => { + expect(() => + resolveWorkerEntryPath({ + isPackaged: true, + resourcesPath: undefined, + moduleDir: '/opt/orcad' + }) + ).not.toThrow() + }) + + it('falls back to the module directory when there is no resources tree', () => { + expect( + resolveWorkerEntryPath({ + isPackaged: true, + resourcesPath: undefined, + moduleDir: '/opt/orcad' + }) + ).toBe(join('/opt/orcad', 'port-scan-command-worker-entry.js')) + }) +}) diff --git a/src/main/ports/port-scan-command-client.ts b/src/main/ports/port-scan-command-client.ts index c2bc80bc8830..9c01b4dccdac 100644 --- a/src/main/ports/port-scan-command-client.ts +++ b/src/main/ports/port-scan-command-client.ts @@ -303,7 +303,8 @@ const WORKER_ENTRY_FILENAME = 'port-scan-command-worker-entry.js' /** Where the built worker entry can live: packaged resources or the build dir. */ export type WorkerEntryLayout = { isPackaged: boolean - resourcesPath: string + /** Undefined on a non-Electron host: `process.resourcesPath` is Electron-only. */ + resourcesPath: string | undefined moduleDir: string } @@ -318,7 +319,12 @@ export function resolveWorkerEntryPath(layout: WorkerEntryLayout): string { // the bundler's __dirname, matching the shipped stt/warp/opencode workers. // Split out from the electron read so the packaged branch is testable without // a packaged build. - if (layout.isPackaged) { + // Why the resourcesPath guard: `isPackaged` is true on orcad too, but + // `process.resourcesPath` is Electron-only and undefined under plain Node — joining + // it threw a TypeError rather than failing as a missing worker. A host without an + // Electron resources tree has no asar to look in, so fall back to the module dir and + // let the caller report a missing worker honestly. + if (layout.isPackaged && layout.resourcesPath) { return join(layout.resourcesPath, 'app.asar', 'out', 'main', WORKER_ENTRY_FILENAME) } return join(layout.moduleDir, WORKER_ENTRY_FILENAME) From 00f8f587a20196c7b98925b4136dce00fa671ac3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:31:01 -0700 Subject: [PATCH 3/5] feat(orcad): prove node-pty loads before anything requires it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Of the two ways node-pty fails, only one is catchable. A missing module throws MODULE_NOT_FOUND. A module built against the wrong libc or Node ABI is refused by the dynamic loader, and in the worst case takes the process down before any handler exists — that is #9902, which crashed the desktop app on Ubuntu 20.04 before a window appeared. There was no libc or ABI precondition anywhere in the tree. So orcad now proves the load in a CHILD process, from main.ts, before anything requires node-pty. Whatever the child does — throw, abort, die on a signal — is data rather than our own death, and the operator gets a sentence naming the host's libc, Node ABI and prebuild slot plus the command to run. Proven-unloadable exits 78 (EX_CONFIG), so a supervisor does not restart an unequippable host forever. A probe that never answered is unverifiable, not blocked: refusing to boot on an inconclusive signal would take down hosts that work. The child dlopens the file node-pty would have chosen, before requiring the package. node-pty's loader walks several directories and rethrows only the LAST error, so a refused binary reads as "Cannot find module ./prebuilds/..." — which sends the operator to install a module that is already there. It also reports through stdout: node echoes the whole -e source above a stack trace, and matching tokens against stderr made the probe's own source text answer for the verdict. Verdicts reach clients as a terminal_unavailable degradation alongside the existing browser_unavailable one, through the same cause-registry shape. degradations[].code is now an open vocabulary; clients already render only `message`. Prebuilds are compiled from PATCHED sources — the patch IS the glibc-floor fix, so an upstream tarball reproduces #9902 — into linux-{x64,arm64}-{glibc,musl} and darwin-{x64,arm64} slots. libc is in the slot name because node-pty's loader falls back to prebuilds/- and cannot tell glibc from musl. orcad installs the matching slot at boot, so a host with no compiler serves terminals. The relay's five pure toolchain-diagnosis functions moved to a transport-free module so the Node bundle can reuse them without dragging ssh2 in behind them; the relay keeps its API by re-export. macOS gets `xcode-select --install` rather than the cross-distro apt/dnf/pacman/apk menu, every line of which is wrong there. --- config/scripts/build-orcad-prebuilds.mjs | 212 ++++++++++ config/scripts/build-orcad-prebuilds.test.mjs | 121 ++++++ docs/reference/linux-glibc-compatibility.md | 26 ++ package.json | 1 + src/main/orcad/main-preflight-order.test.ts | 30 ++ src/main/orcad/main.ts | 8 + src/main/orcad/native-host-abi.test.ts | 110 ++++++ src/main/orcad/native-host-abi.ts | 127 ++++++ src/main/orcad/node-pty-prebuilt-slot.test.ts | 119 ++++++ src/main/orcad/node-pty-prebuilt-slot.ts | 113 ++++++ src/main/orcad/node-pty-precondition.test.ts | 279 ++++++++++++++ src/main/orcad/node-pty-precondition.ts | 362 ++++++++++++++++++ .../orcad-bundle-native-load-order.test.ts | 91 +++++ src/main/orcad/orcad-native-preflight.test.ts | 125 ++++++ src/main/orcad/orcad-native-preflight.ts | 72 ++++ .../runtime/native-terminal-availability.ts | 51 +++ src/main/runtime/orca-runtime.test.ts | 39 ++ src/main/runtime/orca-runtime.ts | 7 + src/main/ssh/build-toolchain-diagnosis.ts | 166 ++++++++ src/main/ssh/ssh-relay-build-toolchain.ts | 176 +-------- src/shared/runtime-types.ts | 61 ++- 21 files changed, 2135 insertions(+), 161 deletions(-) create mode 100644 config/scripts/build-orcad-prebuilds.mjs create mode 100644 config/scripts/build-orcad-prebuilds.test.mjs create mode 100644 src/main/orcad/main-preflight-order.test.ts create mode 100644 src/main/orcad/native-host-abi.test.ts create mode 100644 src/main/orcad/native-host-abi.ts create mode 100644 src/main/orcad/node-pty-prebuilt-slot.test.ts create mode 100644 src/main/orcad/node-pty-prebuilt-slot.ts create mode 100644 src/main/orcad/node-pty-precondition.test.ts create mode 100644 src/main/orcad/node-pty-precondition.ts create mode 100644 src/main/orcad/orcad-bundle-native-load-order.test.ts create mode 100644 src/main/orcad/orcad-native-preflight.test.ts create mode 100644 src/main/orcad/orcad-native-preflight.ts create mode 100644 src/main/runtime/native-terminal-availability.ts create mode 100644 src/main/ssh/build-toolchain-diagnosis.ts diff --git a/config/scripts/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs new file mode 100644 index 000000000000..679625b9f6de --- /dev/null +++ b/config/scripts/build-orcad-prebuilds.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * Build one node-pty prebuilt for the CURRENT platform/arch/libc and file it in orcad's + * prebuilds matrix, so a deployment target needs no C/C++ toolchain. + * + * node-pty is the only ABI-sensitive native module orcad requires. It is also PATCHED in + * this repo (config/patches/node-pty@1.1.0.patch), and that patch is the glibc-floor fix: + * `.symver` pins on openpty/forkpty/pthread_sigmask plus the `--no-as-needed` ldflags that + * keep libutil/libpthread in DT_NEEDED. An upstream prebuilt has none of it and reproduces + * #9902. So the matrix is compiled from patched sources here, and this script refuses to + * run if the patch is not in the tree it is about to compile. + * + * orcad pins its own Node runtime, so the ABI dimension is fixed and the matrix varies + * only platform/arch/libc: + * linux-x64-glibc, linux-arm64-glibc, linux-x64-musl, linux-arm64-musl, + * darwin-x64, darwin-arm64 + * + * CI runs this once per slot, each inside the container that owns that libc/arch, and + * merges the resulting `out/orcad/prebuilds` trees. `--slot=` forces the label so + * the glibc/musl distinction is recorded from the container rather than detected. + * + * Usage: + * node config/scripts/build-orcad-prebuilds.mjs [--slot=linux-x64-musl] + * node config/scripts/build-orcad-prebuilds.mjs --require-slots # release gate + */ +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { spawnSync } from 'node:child_process' + +const require = createRequire(import.meta.url) +const ROOT = join(import.meta.dirname, '..', '..') +const PREBUILDS_DIR = join(ROOT, 'out', 'orcad', 'prebuilds') + +/** Every slot a shipped matrix must fill. The single source of truth for the matrix. */ +export const MATRIX_SLOTS = [ + 'linux-x64-glibc', + 'linux-arm64-glibc', + 'linux-x64-musl', + 'linux-arm64-musl', + 'darwin-x64', + 'darwin-arm64' +] + +/** + * Why the report header and not `ldd`: `glibcVersionRuntime` is present only when the + * process is linked against glibc, and musl images have no `ldd` worth parsing. + */ +export function detectLibc(platform = process.platform, header = readReportHeader()) { + if (platform !== 'linux') { + return 'none' + } + return header && typeof header === 'object' && 'glibcVersionRuntime' in header ? 'glibc' : 'musl' +} + +function readReportHeader() { + try { + return process.report?.getReport?.()?.header + } catch { + return undefined + } +} + +export function slotName(argv = process.argv, platform = process.platform, arch = process.arch) { + const forced = argv.find((arg) => arg.startsWith('--slot=')) + if (forced) { + return forced.slice('--slot='.length) + } + const libc = detectLibc(platform) + return libc === 'none' ? `${platform}-${arch}` : `${platform}-${arch}-${libc}` +} + +/** + * The patch is what holds the Ubuntu 20.04 floor. Compiling without it produces a binary + * that loads fine on the build host and dies on the target — the exact failure the matrix + * exists to prevent, now baked into a shipped artifact instead of a first-connect error. + */ +export function assertNodePtyPatchApplied(nodePtyDir) { + const bindingGyp = readFileSync(join(nodePtyDir, 'binding.gyp'), 'utf8') + const ptySource = readFileSync(join(nodePtyDir, 'src', 'unix', 'pty.cc'), 'utf8') + const missing = [] + if (!bindingGyp.includes('--no-as-needed,-l:libutil.so.1')) { + missing.push("binding.gyp is missing the '--no-as-needed,-l:libutil.so.1' ldflag") + } + if (!ptySource.includes('.symver openpty,openpty@')) { + missing.push('src/unix/pty.cc is missing the .symver glibc pins') + } + if (missing.length > 0) { + throw new Error( + [ + '[orcad-prebuilds] refusing to build: config/patches/node-pty@1.1.0.patch is not applied.', + ...missing.map((line) => ` - ${line}`), + 'A prebuilt compiled without it will not load on Ubuntu 20.04 (see', + 'docs/reference/linux-glibc-compatibility.md and #9902). Run `pnpm install` to apply patches.' + ].join('\n') + ) + } +} + +export function readManifest(prebuildsDir) { + try { + return JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) + } catch { + return null + } +} + +/** + * Why merge rather than overwrite: CI builds one slot per container and merges the trees. + * A manifest that records only the last slot would erase every other container's record, + * and `--require-slots` would then reject a complete matrix. + */ +export function mergeManifest(existing, next) { + const slots = new Set([...(existing?.slots ?? []), next.slot]) + return { + module: 'node-pty', + version: next.version, + nodeAbi: next.nodeAbi, + slots: [...slots].sort() + } +} + +function nodePtyDir() { + return dirname(require.resolve('node-pty/package.json')) +} + +function compileNodePty(dir) { + const built = join(dir, 'build', 'Release', 'pty.node') + if (existsSync(built)) { + console.log(`[orcad-prebuilds] reusing existing build at ${built}`) + return built + } + console.log('[orcad-prebuilds] compiling node-pty from patched source ...') + const result = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['node-gyp', 'rebuild'], { + cwd: dir, + stdio: 'inherit', + env: process.env, + windowsHide: true + }) + if (result.status !== 0) { + throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`) + } + if (!existsSync(built)) { + throw new Error(`[orcad-prebuilds] node-gyp succeeded but ${built} is missing`) + } + return built +} + +function requireSlots() { + const manifest = readManifest(PREBUILDS_DIR) + const have = new Set(manifest?.slots ?? []) + const missing = MATRIX_SLOTS.filter((slot) => !have.has(slot)) + if (missing.length > 0) { + console.error( + `[orcad-prebuilds] matrix incomplete — missing ${missing.join(', ')}. ` + + 'Hosts on those slots fall back to a source build and need a C/C++ toolchain.' + ) + process.exitCode = 1 + return + } + console.log(`[orcad-prebuilds] matrix complete — ${MATRIX_SLOTS.length} slots`) +} + +function build() { + const dir = nodePtyDir() + assertNodePtyPatchApplied(dir) + const slot = slotName() + const slotDir = join(PREBUILDS_DIR, slot) + mkdirSync(slotDir, { recursive: true }) + + const builtBinary = compileNodePty(dir) + copyFileSync(builtBinary, join(slotDir, 'pty.node')) + console.log(`[orcad-prebuilds] stored ${slot}/pty.node`) + + // Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper, + // so a slot without it installs cleanly and then fails ENOENT the first time a user + // opens a terminal. Windows has no spawn-helper. + if (process.platform !== 'win32') { + const helperSource = join(dirname(builtBinary), 'spawn-helper') + if (!existsSync(helperSource)) { + throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`) + } + copyFileSync(helperSource, join(slotDir, 'spawn-helper')) + console.log(`[orcad-prebuilds] stored ${slot}/spawn-helper`) + } + + // The static floor gate, applied to the artifact we are about to ship rather than only + // to the packaged desktop app. objdump is Linux-only, which is where the floor lives. + if (process.platform === 'linux') { + const { verifyLinuxGlibcFloor } = require('./verify-linux-glibc-floor.cjs') + verifyLinuxGlibcFloor(slotDir) + } + + const manifest = mergeManifest(readManifest(PREBUILDS_DIR), { + slot, + version: require('node-pty/package.json').version, + nodeAbi: process.versions.modules + }) + writeFileSync(join(PREBUILDS_DIR, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) + console.log( + `[orcad-prebuilds] manifest: node-pty ${manifest.version}, ABI ${manifest.nodeAbi}, slots ${manifest.slots.join(', ')}` + ) +} + +if (process.argv[1] && process.argv[1].endsWith('build-orcad-prebuilds.mjs')) { + if (process.argv.includes('--require-slots')) { + requireSlots() + } else { + build() + } +} diff --git a/config/scripts/build-orcad-prebuilds.test.mjs b/config/scripts/build-orcad-prebuilds.test.mjs new file mode 100644 index 000000000000..84c0e83c18f0 --- /dev/null +++ b/config/scripts/build-orcad-prebuilds.test.mjs @@ -0,0 +1,121 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + assertNodePtyPatchApplied, + detectLibc, + MATRIX_SLOTS, + mergeManifest, + readManifest, + slotName +} from './build-orcad-prebuilds.mjs' + +const PATCHED_BINDING_GYP = "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']" +const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);' + +const dirs = [] +const stage = (bindingGyp, ptyCc) => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-src-')) + dirs.push(dir) + mkdirSync(join(dir, 'src', 'unix'), { recursive: true }) + writeFileSync(join(dir, 'binding.gyp'), bindingGyp) + writeFileSync(join(dir, 'src', 'unix', 'pty.cc'), ptyCc) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('assertNodePtyPatchApplied', () => { + it('accepts a tree with both halves of the glibc-floor fix', () => { + expect(() => + assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, PATCHED_PTY_CC)) + ).not.toThrow() + }) + + it('refuses to build when the ldflags half is missing', () => { + // The .symver pins alone let gcc's --as-needed drop libutil/libpthread from + // DT_NEEDED, which loads on the build host and fails on Ubuntu 20.04 — #9902 again, + // this time baked into a shipped prebuilt. + expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", PATCHED_PTY_CC))).toThrow( + /--no-as-needed,-l:libutil\.so\.1/ + ) + }) + + it('refuses to build when the .symver pins are missing', () => { + expect(() => assertNodePtyPatchApplied(stage(PATCHED_BINDING_GYP, '// upstream'))).toThrow( + /\.symver glibc pins/ + ) + }) + + it('names the patch and the doc so the fix is findable', () => { + expect(() => assertNodePtyPatchApplied(stage("'ldflags': []", '// upstream'))).toThrow( + /config\/patches\/node-pty@1\.1\.0\.patch/ + ) + }) +}) + +describe('slot naming', () => { + it('covers every platform orcad ships to', () => { + expect([...MATRIX_SLOTS].sort()).toEqual([ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64-glibc', + 'linux-arm64-musl', + 'linux-x64-glibc', + 'linux-x64-musl' + ]) + }) + + it('lets CI force the label so the container decides glibc vs musl', () => { + // Detection inside a container that happens to run a differently-linked Node would + // file the build under the wrong slot. The forced label must beat detection outright, + // so assert against one detection could never produce for this platform/arch. + expect(slotName(['node', 'x', '--slot=linux-x64-glibc'], 'linux', 'arm64')).toBe( + 'linux-x64-glibc' + ) + }) + + it('omits the libc dimension off Linux', () => { + expect(slotName([], 'darwin', 'arm64')).toBe('darwin-arm64') + }) + + it('reads glibc from the report header and musl from its absence', () => { + expect(detectLibc('linux', { glibcVersionRuntime: '2.31' })).toBe('glibc') + expect(detectLibc('linux', {})).toBe('musl') + expect(detectLibc('darwin', { glibcVersionRuntime: '2.31' })).toBe('none') + }) +}) + +describe('mergeManifest', () => { + it('accumulates slots across the per-container CI runs that build them', () => { + // Overwriting would erase every other container's record, and the release gate would + // then reject a matrix that is actually complete. + const first = mergeManifest(null, { slot: 'linux-x64-glibc', version: '1.1.0', nodeAbi: '127' }) + const second = mergeManifest(first, { + slot: 'linux-arm64-musl', + version: '1.1.0', + nodeAbi: '127' + }) + + expect(second.slots).toEqual(['linux-arm64-musl', 'linux-x64-glibc']) + expect(second).toMatchObject({ module: 'node-pty', version: '1.1.0', nodeAbi: '127' }) + }) + + it('does not duplicate a slot rebuilt twice', () => { + const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }) + expect(mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots) + .toEqual(['darwin-arm64']) + }) +}) + +describe('readManifest', () => { + it('returns null instead of throwing when no matrix has been built', () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-prebuild-manifest-')) + dirs.push(dir) + expect(readManifest(dir)).toBeNull() + }) +}) diff --git a/docs/reference/linux-glibc-compatibility.md b/docs/reference/linux-glibc-compatibility.md index 60d855c7372f..bf98dbdcb804 100644 --- a/docs/reference/linux-glibc-compatibility.md +++ b/docs/reference/linux-glibc-compatibility.md @@ -82,6 +82,32 @@ libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the app itself still launches on stock 20.04. +**3. Check before loading, on hosts that ship without a compiler (`orcad`).** +The two gates above protect the packaged desktop app, where the binary is built and +verified by the same pipeline. `orcad` is deployed to hosts Orca never built on, so it +adds a runtime precondition +([`src/main/orcad/node-pty-precondition.ts`](../../src/main/orcad/node-pty-precondition.ts)), +run from `main.ts` before anything requires `node-pty`. It loads the addon in a **child +process**, so a binary the loader refuses — or one that aborts outright — is data rather +than this process's death, and the operator gets a sentence naming the host's libc, its +Node ABI, its prebuild slot and the command to run. A proven-unloadable binary exits 78 +(`EX_CONFIG`) instead of reaching the `require`; a probe that never answered is reported +as unverifiable and boots anyway, because a silent probe is not evidence. Whatever it +finds is published in `status.get`'s `degradations[]` under `terminal_unavailable`. + +**4. Ship the binary, built from patched sources.** +[`config/scripts/build-orcad-prebuilds.mjs`](../../config/scripts/build-orcad-prebuilds.mjs) +(`pnpm run build:orcad-prebuilds`, after `build:orcad`) compiles node-pty for the current +host and files it under `out/orcad/prebuilds//`, where a slot is +`linux-{x64,arm64}-{glibc,musl}` or `darwin-{x64,arm64}`. libc is part of the slot name +because node-pty's own loader falls back to `prebuilds/-` and cannot tell +glibc from musl — a glibc binary parked there is loaded on Alpine and dies at `dlopen`. +The script refuses to compile a tree where `config/patches/node-pty@1.1.0.patch` is not +applied: without the patch the prebuilt is a #9902 crash shipped as an artifact rather +than a first-connect error. CI runs it once per slot inside the matching container +(`--slot=` forces the label), merges the trees, and `--require-slots` fails a release with +a hole in the matrix. + ## Adding or upgrading a native dependency - Prefer packages that ship prebuilt binaries compiled against an old toolchain diff --git a/package.json b/package.json index 933741f8cc42..1ef901e6772e 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", "build:orcad": "node config/scripts/build-orcad.mjs", + "build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs", "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad", "smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts new file mode 100644 index 000000000000..bd130bfb33fe --- /dev/null +++ b/src/main/orcad/main-preflight-order.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' + +/** + * The precondition is only worth anything if it runs first. A loader failure is not + * catchable, so a preflight that lands after `main()` has already reached + * `await import('../ipc/pty')` prevents nothing. + */ +const order: string[] = [] + +vi.mock('./orcad-native-preflight', () => ({ + runOrcadNativePreflight: () => { + order.push('preflight') + return true + } +})) + +vi.mock('./orcad-entry', () => ({ + main: async () => { + order.push('main') + } +})) + +describe('orcad entry', () => { + it('runs the native preflight before starting the runtime', async () => { + await import('./main') + await vi.waitFor(() => expect(order).toContain('main')) + + expect(order).toEqual(['preflight', 'main']) + }) +}) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index e76ed873cb03..bc59ddb19ab3 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -1,6 +1,14 @@ /** Executable entry for `orcad`. See `./orcad-entry.ts`. */ import process from 'node:process' import { main } from './orcad-entry' +import { runOrcadNativePreflight } from './orcad-native-preflight' + +// Why here and not inside startOrcad: this must run before anything requires node-pty, +// and `orcad-entry` reaches it through `await import('../ipc/pty')`. Static imports are +// evaluated before this statement, so the guarantee is that no module in the graph +// requires node-pty at import time — which the bundle's lazy `require("node-pty")` in +// local-pty-provider satisfies. See ./node-pty-precondition.ts for why a child process. +runOrcadNativePreflight() main().catch((error: unknown) => { console.error('orcad: failed to start:', error) diff --git a/src/main/orcad/native-host-abi.test.ts b/src/main/orcad/native-host-abi.test.ts new file mode 100644 index 000000000000..44f469f0faab --- /dev/null +++ b/src/main/orcad/native-host-abi.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + compareDottedVersions, + detectLibcFromReportHeader, + detectNativeHostAbi, + GLIBC_FLOOR, + isBelowGlibcFloor, + nativeSlotName, + parseNodeAbiMismatch, + parseUnmetGlibcVersion +} from './native-host-abi' + +describe('detectLibcFromReportHeader', () => { + it('reads glibc and its version from a glibc host report', () => { + expect(detectLibcFromReportHeader('linux', { glibcVersionRuntime: '2.31' })).toEqual({ + libc: 'glibc', + glibcVersion: '2.31' + }) + }) + + it('calls a Linux report with no glibcVersionRuntime musl', () => { + // Alpine's Node omits the key entirely; that absence is the only signal available + // without shelling out to ldd, which musl images do not usefully provide. + expect(detectLibcFromReportHeader('linux', { arch: 'x64' })).toEqual({ + libc: 'musl', + glibcVersion: null + }) + }) + + it('does not invent a libc dimension for macOS or Windows', () => { + // A darwin-arm64-glibc slot would never match anything CI builds. + expect(detectLibcFromReportHeader('darwin', { glibcVersionRuntime: '2.31' }).libc).toBe('none') + expect(detectLibcFromReportHeader('win32', undefined).libc).toBe('none') + }) + + it('treats an unreadable report as glibc with an unknown version, not as musl', () => { + // Guessing musl would send a glibc host looking for a slot that does not exist. + expect(detectLibcFromReportHeader('linux', undefined)).toEqual({ + libc: 'glibc', + glibcVersion: null + }) + }) +}) + +describe('nativeSlotName', () => { + it('carries libc on Linux and omits it elsewhere', () => { + expect(nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'glibc' })).toBe( + 'linux-x64-glibc' + ) + expect(nativeSlotName({ platform: 'linux', arch: 'arm64', libc: 'musl' })).toBe( + 'linux-arm64-musl' + ) + expect(nativeSlotName({ platform: 'darwin', arch: 'arm64', libc: 'none' })).toBe('darwin-arm64') + }) + + it('never lets a glibc slot answer for a musl host', () => { + // node-pty's own loader checks prebuilds/- with no libc, which is the + // exact confusion this name exists to prevent. + expect(nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'glibc' })).not.toBe( + nativeSlotName({ platform: 'linux', arch: 'x64', libc: 'musl' }) + ) + }) +}) + +describe('glibc floor', () => { + it('compares dotted versions numerically, not lexically', () => { + // '2.9' > '2.31' under string compare; that ordering would pass a broken host. + expect(compareDottedVersions('2.9', '2.31')).toBe(-1) + expect(compareDottedVersions('2.31', '2.31.0')).toBe(0) + expect(compareDottedVersions('2.34', GLIBC_FLOOR)).toBe(1) + }) + + it('answers null when the version is unknown rather than claiming the floor is met', () => { + expect(isBelowGlibcFloor(null)).toBeNull() + expect(isBelowGlibcFloor('2.28')).toBe(true) + expect(isBelowGlibcFloor('2.31')).toBe(false) + }) +}) + +describe('loader error parsing', () => { + it('extracts the unmet symbol version from the #9902 message', () => { + expect( + parseUnmetGlibcVersion( + "/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /app/node_modules/node-pty/build/Release/pty.node)" + ) + ).toBe('2.34') + }) + + it('ignores unrelated loader noise', () => { + expect(parseUnmetGlibcVersion('Error: Cannot find module ./pty.node')).toBeNull() + }) + + it('extracts both ABI numbers from a NODE_MODULE_VERSION mismatch', () => { + expect( + parseNodeAbiMismatch( + 'was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.' + ) + ).toEqual({ built: '115', host: '127' }) + }) +}) + +describe('detectNativeHostAbi', () => { + it('describes the host this test is running on', () => { + const abi = detectNativeHostAbi() + expect(abi.platform).toBe(process.platform) + expect(abi.arch).toBe(process.arch) + expect(abi.nodeAbi).toBe(process.versions.modules) + expect(abi.libc).toBe(process.platform === 'linux' ? abi.libc : 'none') + }) +}) diff --git a/src/main/orcad/native-host-abi.ts b/src/main/orcad/native-host-abi.ts new file mode 100644 index 000000000000..4afb8ac7bfaa --- /dev/null +++ b/src/main/orcad/native-host-abi.ts @@ -0,0 +1,127 @@ +/** + * What this host would load a native addon against: platform, arch, libc flavour and + * Node's ABI number, plus the prebuild slot name those four pick. + * + * Why libc is a first-class dimension: node-pty's own loader + * (`node_modules/node-pty/lib/utils.js`) falls back to `prebuilds/-`, + * which does NOT distinguish glibc from musl. A glibc binary dropped in that directory + * is loaded on Alpine and dies inside the dynamic loader. Slot names carry the libc so + * a mismatch is a miss rather than a crash. + * + * Everything here is pure apart from `detectNativeHostAbi`, so the classification can be + * tested for hosts this machine is not. + */ +import process from 'node:process' + +export type LibcFlavor = 'glibc' | 'musl' | 'none' + +export type NativeHostAbi = { + platform: NodeJS.Platform + arch: string + libc: LibcFlavor + /** Runtime glibc version ('2.31'), or null on musl, non-Linux, and unreadable reports. */ + glibcVersion: string | null + /** `NODE_MODULE_VERSION` — the addon ABI this runtime accepts. */ + nodeAbi: string +} + +/** Stock Ubuntu 20.04. See docs/reference/linux-glibc-compatibility.md. */ +export const GLIBC_FLOOR = '2.31' + +type ReportHeader = { glibcVersionRuntime?: unknown } + +/** + * Why absence means musl: `glibcVersionRuntime` is written by Node's report only when the + * process is linked against glibc. Alpine's Node omits it. This avoids shelling out to + * `ldd`, which is not present on every image and prints to stderr on musl. + * + * Non-Linux hosts get 'none': macOS and Windows have one system libc, so the dimension + * carries no information and must not widen the slot matrix. + */ +export function detectLibcFromReportHeader( + platform: NodeJS.Platform, + header: unknown +): { libc: LibcFlavor; glibcVersion: string | null } { + if (platform !== 'linux') { + return { libc: 'none', glibcVersion: null } + } + if (!header || typeof header !== 'object') { + // Why glibc and not 'unknown': an unreadable report is not evidence of musl, and + // glibc is the overwhelmingly common Linux case. The null version keeps the floor + // check from claiming a number it does not have. + return { libc: 'glibc', glibcVersion: null } + } + const runtime = (header as ReportHeader).glibcVersionRuntime + if (typeof runtime === 'string' && runtime.length > 0) { + return { libc: 'glibc', glibcVersion: runtime } + } + if ('glibcVersionRuntime' in (header as object)) { + return { libc: 'glibc', glibcVersion: null } + } + return { libc: 'musl', glibcVersion: null } +} + +export function detectNativeHostAbi(): NativeHostAbi { + let header: unknown + try { + header = (process.report?.getReport?.() as { header?: unknown } | undefined)?.header + } catch { + header = undefined + } + const { libc, glibcVersion } = detectLibcFromReportHeader(process.platform, header) + return { + platform: process.platform, + arch: process.arch, + libc, + glibcVersion, + nodeAbi: process.versions.modules + } +} + +/** `linux-x64-glibc`, `linux-arm64-musl`, `darwin-arm64`, `win32-x64`. */ +export function nativeSlotName(abi: Pick): string { + return abi.libc === 'none' + ? `${abi.platform}-${abi.arch}` + : `${abi.platform}-${abi.arch}-${abi.libc}` +} + +/** Numeric dotted compare: -1 / 0 / 1. Missing components read as 0, so '2.31' === '2.31.0'. */ +export function compareDottedVersions(left: string, right: string): number { + const a = left.split('.').map((part) => Number.parseInt(part, 10) || 0) + const b = right.split('.').map((part) => Number.parseInt(part, 10) || 0) + for (let i = 0; i < Math.max(a.length, b.length); i += 1) { + const diff = (a[i] ?? 0) - (b[i] ?? 0) + if (diff !== 0) { + return diff > 0 ? 1 : -1 + } + } + return 0 +} + +/** Null when the version is unknown — an unread report must not read as "below the floor". */ +export function isBelowGlibcFloor(glibcVersion: string | null): boolean | null { + if (!glibcVersion) { + return null + } + return compareDottedVersions(glibcVersion, GLIBC_FLOOR) < 0 +} + +/** + * The symbol version node's loader complained about, e.g. + * `libc.so.6: version 'GLIBC_2.34' not found (required by .../pty.node)` -> '2.34'. + * This is the fingerprint of #9902: a binary built on a newer glibc than the target. + */ +export function parseUnmetGlibcVersion(loaderError: string): string | null { + const match = loaderError.match(/version `?GLIBC_([0-9][0-9.]*)'? not found/) + return match ? match[1] : null +} + +/** `NODE_MODULE_VERSION 115 ... requires NODE_MODULE_VERSION 127` -> { built: '115', host: '127' }. */ +export function parseNodeAbiMismatch( + loaderError: string +): { built: string; host: string } | null { + const match = loaderError.match( + /NODE_MODULE_VERSION\s+(\d+)\D+NODE_MODULE_VERSION\s+(\d+)/ + ) + return match ? { built: match[1], host: match[2] } : null +} diff --git a/src/main/orcad/node-pty-prebuilt-slot.test.ts b/src/main/orcad/node-pty-prebuilt-slot.test.ts new file mode 100644 index 000000000000..00880eee1e5f --- /dev/null +++ b/src/main/orcad/node-pty-prebuilt-slot.test.ts @@ -0,0 +1,119 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + installPrebuiltSlot, + readPrebuiltSlotManifest, + resolveOrcadPrebuildsDir +} from './node-pty-prebuilt-slot' +import type { NativeHostAbi } from './native-host-abi' + +const LINUX_GLIBC: NativeHostAbi = { + platform: 'linux', + arch: 'x64', + libc: 'glibc', + glibcVersion: '2.31', + nodeAbi: '127' +} + +const dirs: string[] = [] +const temp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-slot-')) + dirs.push(dir) + return dir +} +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + delete process.env.ORCA_ORCAD_PREBUILDS_DIR +}) + +const stageSlot = (prebuilds: string, slot: string): void => { + mkdirSync(join(prebuilds, slot), { recursive: true }) + writeFileSync(join(prebuilds, slot, 'pty.node'), 'binary') + writeFileSync(join(prebuilds, slot, 'spawn-helper'), 'helper') +} + +describe('resolveOrcadPrebuildsDir', () => { + it('looks beside the running bundle', () => { + expect(resolveOrcadPrebuildsDir('/opt/orcad/orcad.js')).toBe(join('/opt/orcad', 'prebuilds')) + }) + + it('honours an explicit override', () => { + process.env.ORCA_ORCAD_PREBUILDS_DIR = '/custom/prebuilds' + expect(resolveOrcadPrebuildsDir('/opt/orcad/orcad.js')).toBe('/custom/prebuilds') + }) +}) + +describe('installPrebuiltSlot', () => { + it('installs the slot binary and spawn-helper into build/Release', () => { + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'linux-x64-glibc') + + const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds }) + + expect(outcome).toEqual({ installed: true, slot: 'linux-x64-glibc', spawnHelper: true }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(true) + // Without the executable bit every spawn fails EACCES at the moment a user opens a terminal. + const helper = statSync(join(nodePtyDir, 'build', 'Release', 'spawn-helper')) + expect(helper.mode & 0o111).not.toBe(0) + }) + + it('will not load a glibc slot on a musl host', () => { + // node-pty's own loader cannot tell these apart; the slot name is the only thing that can. + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'linux-x64-glibc') + + const outcome = installPrebuiltSlot({ + abi: { ...LINUX_GLIBC, libc: 'musl' }, + nodePtyDir, + prebuildsDir: prebuilds + }) + + expect(outcome).toEqual({ installed: false, slot: 'linux-x64-musl', why: 'no-slot' }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) + }) + + it('refuses an ABI-mismatched matrix instead of installing a binary that cannot load', () => { + // Installing it would turn "no prebuilt for this host" into a loader failure that + // reads as a corrupt install. + const prebuilds = temp() + const nodePtyDir = temp() + stageSlot(prebuilds, 'linux-x64-glibc') + writeFileSync( + join(prebuilds, 'manifest.json'), + JSON.stringify({ module: 'node-pty', version: '1.1.0', nodeAbi: '115', slots: [] }) + ) + + const outcome = installPrebuiltSlot({ abi: LINUX_GLIBC, nodePtyDir, prebuildsDir: prebuilds }) + + expect(outcome).toMatchObject({ installed: false, why: 'abi-mismatch' }) + expect(existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))).toBe(false) + }) + + it('reports a missing prebuilds directory distinctly from a missing slot', () => { + // They mean different things: no matrix shipped at all, versus a matrix with a hole. + expect( + installPrebuiltSlot({ + abi: LINUX_GLIBC, + nodePtyDir: temp(), + prebuildsDir: join(temp(), 'absent') + }) + ).toEqual({ installed: false, slot: 'linux-x64-glibc', why: 'no-prebuilds-dir' }) + }) +}) + +describe('readPrebuiltSlotManifest', () => { + it('returns null for absent or malformed manifests rather than a half-built object', () => { + const prebuilds = temp() + expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() + writeFileSync(join(prebuilds, 'manifest.json'), '{ not json') + expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() + writeFileSync(join(prebuilds, 'manifest.json'), JSON.stringify({ version: '1.1.0' })) + expect(readPrebuiltSlotManifest(prebuilds)).toBeNull() + }) +}) diff --git a/src/main/orcad/node-pty-prebuilt-slot.ts b/src/main/orcad/node-pty-prebuilt-slot.ts new file mode 100644 index 000000000000..992cbe705ee7 --- /dev/null +++ b/src/main/orcad/node-pty-prebuilt-slot.ts @@ -0,0 +1,113 @@ +/** + * Install the shipped node-pty prebuilt that matches this host, so a deployment needs + * no C/C++ toolchain. + * + * Why copy into `build/Release` rather than leave it in `prebuilds/`: node-pty's own + * loader falls back to `prebuilds/-` with no libc in the name, so a + * glibc binary parked there is loaded on Alpine and dies in the dynamic loader. Putting + * the chosen slot's binary in `build/Release` is what makes the libc dimension real — + * node-pty only ever sees the one we picked. + * + * The prebuilds themselves are built from the PATCHED source (config/patches/node-pty@1.1.0.patch) + * by config/scripts/build-orcad-prebuilds.mjs. An upstream tarball would not do: the patch + * carries the `.symver` pins and the `--no-as-needed` libutil/libpthread flags that hold the + * Ubuntu 20.04 / glibc 2.31 floor (docs/reference/linux-glibc-compatibility.md). + */ +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { nativeSlotName, type NativeHostAbi } from './native-host-abi' + +export type PrebuiltSlotManifest = { + module: string + version: string + nodeAbi: string + slots: string[] +} + +export type PrebuiltSlotOutcome = + | { installed: true; slot: string; spawnHelper: boolean } + | { installed: false; slot: string; why: 'no-slot' | 'no-prebuilds-dir' | 'abi-mismatch'; detail?: string } + +/** + * Where a deployment's prebuilds live: beside the bundle that is running. `argv[1]` is + * `orcad.js` itself, so this stays correct wherever the install directory ends up. + */ +export function resolveOrcadPrebuildsDir(entryScript = process.argv[1]): string | null { + const override = process.env.ORCA_ORCAD_PREBUILDS_DIR + if (override) { + return override + } + return entryScript ? join(dirname(entryScript), 'prebuilds') : null +} + +export function readPrebuiltSlotManifest(prebuildsDir: string): PrebuiltSlotManifest | null { + try { + const parsed = JSON.parse(readFileSync(join(prebuildsDir, 'manifest.json'), 'utf8')) as unknown + if (!parsed || typeof parsed !== 'object') { + return null + } + const manifest = parsed as Partial + if (typeof manifest.nodeAbi !== 'string' || typeof manifest.version !== 'string') { + return null + } + return { + module: typeof manifest.module === 'string' ? manifest.module : 'node-pty', + version: manifest.version, + nodeAbi: manifest.nodeAbi, + slots: Array.isArray(manifest.slots) ? manifest.slots.filter((s) => typeof s === 'string') : [] + } + } catch { + return null + } +} + +/** + * Copy `//pty.node` (and `spawn-helper`) into node-pty's `build/Release`. + * + * Refuses on an ABI mismatch instead of copying: a binary built for another + * `NODE_MODULE_VERSION` cannot load, and installing it would replace a "no prebuilt" + * diagnosis with a loader failure that reads as a corrupt install. + */ +export function installPrebuiltSlot(options: { + abi: NativeHostAbi + nodePtyDir: string + prebuildsDir?: string | null +}): PrebuiltSlotOutcome { + const slot = nativeSlotName(options.abi) + const prebuildsDir = options.prebuildsDir ?? resolveOrcadPrebuildsDir() + if (!prebuildsDir || !existsSync(prebuildsDir)) { + return { installed: false, slot, why: 'no-prebuilds-dir' } + } + const manifest = readPrebuiltSlotManifest(prebuildsDir) + if (manifest && manifest.nodeAbi !== options.abi.nodeAbi) { + return { + installed: false, + slot, + why: 'abi-mismatch', + detail: `shipped prebuilds target Node ABI ${manifest.nodeAbi}, this host runs ABI ${options.abi.nodeAbi}` + } + } + const source = join(prebuildsDir, slot, 'pty.node') + if (!existsSync(source)) { + return { installed: false, slot, why: 'no-slot' } + } + const releaseDir = join(options.nodePtyDir, 'build', 'Release') + mkdirSync(releaseDir, { recursive: true }) + copyFileSync(source, join(releaseDir, 'pty.node')) + + // Why this matters as much as pty.node: on Unix node-pty posix_spawns + // build/Release/spawn-helper. Without it every spawn fails with ENOENT at the moment + // a user opens a terminal, long after the "install succeeded" line. + let spawnHelper = false + if (options.abi.platform !== 'win32') { + const helperSource = join(prebuildsDir, slot, 'spawn-helper') + if (existsSync(helperSource)) { + const helperDest = join(releaseDir, 'spawn-helper') + copyFileSync(helperSource, helperDest) + chmodSync(helperDest, 0o755) + spawnHelper = true + } + } + return { installed: true, slot, spawnHelper } +} diff --git a/src/main/orcad/node-pty-precondition.test.ts b/src/main/orcad/node-pty-precondition.test.ts new file mode 100644 index 000000000000..3854a5bd3b69 --- /dev/null +++ b/src/main/orcad/node-pty-precondition.test.ts @@ -0,0 +1,279 @@ +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildNodePtyLoadProbeScript, + checkNodePtyPrecondition, + classifyNodePtyProbeResult, + formatNodePtyPreconditionReport, + probeLocalBuildToolchainHints +} from './node-pty-precondition' +import { detectNativeHostAbi } from './native-host-abi' + +const require = createRequire(import.meta.url) +const REAL_NODE_PTY = dirname(require.resolve('node-pty/package.json')) + +const probe = (overrides: Partial[0]> = {}) => + classifyNodePtyProbeResult({ + code: 1, + signal: null, + stdout: '', + stderr: '', + timedOut: false, + ...overrides + }) + +const reported = (message: string) => + classifyNodePtyProbeResult({ + code: 4, + signal: null, + stdout: `ORCA_NODE_PTY_LOAD_ERROR ${JSON.stringify(message)}\n`, + stderr: '', + timedOut: false + }) + +describe('classifyNodePtyProbeResult', () => { + it('accepts only a clean exit that printed the token', () => { + expect(probe({ code: 0, stdout: 'ORCA_NODE_PTY_LOAD_OK /x/build/Release' })).toBeNull() + // A zero exit with no token means the probe never reached the load. + expect(probe({ code: 0, stdout: '' })?.reason).toBe('load_failed') + }) + + it('names the glibc floor for the #9902 loader message', () => { + expect( + reported( + "/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by /app/node_modules/node-pty/build/Release/pty.node)" + ) + ).toEqual({ + status: 'blocked', + reason: 'libc_floor', + detail: 'the binary requires GLIBC_2.34' + }) + }) + + it('names a libstdc++ floor break too', () => { + expect(reported("version `GLIBCXX_3.4.29' not found")?.reason).toBe('libc_floor') + }) + + it('separates a Node ABI mismatch from a libc mismatch', () => { + // These need different fixes — rebuild against this Node vs. build on an older libc — + // so collapsing them sends the operator to the wrong one. + expect( + reported( + 'was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.' + ) + ).toEqual({ + status: 'blocked', + reason: 'abi_mismatch', + detail: 'built for Node ABI 115, this host runs ABI 127' + }) + }) + + it('reports a signalled probe as a crash, even with no output at all', () => { + // The uncatchable case: a binary that aborts inside the loader never reaches the + // child's catch and often prints nothing. + expect(probe({ code: null, signal: 'SIGSEGV' })).toEqual({ + status: 'blocked', + reason: 'load_crashed', + detail: 'the load probe was killed by SIGSEGV' + }) + }) + + it('distinguishes no binary anywhere from a binary the loader refused', () => { + // "install node-pty" and "rebuild node-pty for this libc" are different instructions. + expect(probe({ code: 3, stdout: 'ORCA_NODE_PTY_NO_BINARY\n' })).toEqual({ + status: 'blocked', + reason: 'dependency_missing', + detail: 'node-pty is installed but has no compiled binary for this platform' + }) + expect(reported('dlopen(...): slice is not valid mach-o file')?.reason).toBe('load_failed') + }) + + it('ignores its own token strings echoed back inside the child stderr', () => { + // node prints the whole `-e` source above the stack trace, and that source contains + // every token below. Matching on stderr made a refused binary read as "not installed". + const echoedSource = + '[eval]:1\nif(!f){console.log("ORCA_NODE_PTY_NO_BINARY");process.exit(3)}\n' + + ' ^\n\nError: dlopen(/app/pty.node): slice is not valid mach-o file\n' + + expect( + classifyNodePtyProbeResult({ + code: 4, + signal: null, + stdout: `ORCA_NODE_PTY_LOAD_ERROR ${JSON.stringify('dlopen(/app/pty.node): slice is not valid mach-o file')}`, + stderr: echoedSource, + timedOut: false + }) + ).toMatchObject({ reason: 'load_failed' }) + }) + + it('reads past node\u2019s echoed source line when it can only use stderr', () => { + const failure = probe({ + stderr: + "[eval]:1\nprocess.dlopen({exports:{}},f);\n ^\n\nError: something specific went wrong\n" + }) + expect(failure?.detail).toBe('Error: something specific went wrong') + }) + + it('calls a timeout unverifiable rather than blocked', () => { + // A probe that never answered is not evidence that node-pty is broken, and refusing + // to boot on it would take down hosts that work. + expect(probe({ timedOut: true })).toEqual({ + status: 'unverifiable', + reason: 'unknown', + detail: 'the node-pty load probe did not finish in time, so nothing was established' + }) + }) +}) + +describe('buildNodePtyLoadProbeScript', () => { + it('loads through absolute paths so the child cannot resolve a different copy', () => { + const script = buildNodePtyLoadProbeScript('/opt/app/node_modules/node-pty') + expect(script).toContain('"/opt/app/node_modules/node-pty/lib/index.js"') + expect(script).toContain('"/opt/app/node_modules/node-pty/lib/utils.js"') + expect(script).toContain('loadNativeModule') + // Windows defers conpty.node to first spawn, so requiring the package proves nothing there. + expect(script).toContain('conpty') + // The raw dlopen must precede requiring the package, or node-pty's own loader + // re-wraps the loader error into a misleading "Cannot find module". + expect(script.indexOf('process.dlopen')).toBeLessThan(script.indexOf('lib/index.js')) + }) +}) + +describe('checkNodePtyPrecondition', () => { + const temporaryDirs: string[] = [] + const stageNodePty = (): string => { + const root = mkdtempSync(join(tmpdir(), 'orcad-node-pty-')) + temporaryDirs.push(root) + const dir = join(root, 'node-pty') + mkdirSync(join(dir, 'build', 'Release'), { recursive: true }) + cpSync(join(REAL_NODE_PTY, 'lib'), join(dir, 'lib'), { recursive: true }) + cpSync(join(REAL_NODE_PTY, 'package.json'), join(dir, 'package.json')) + return dir + } + + afterEach(() => { + for (const dir of temporaryDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('survives a native binary that the dynamic loader refuses', () => { + // The whole reason the probe is a child process: this file is loaded with dlopen, and + // an in-process require of it can take the host down before any handler runs. Reaching + // the assertion below at all is the evidence. + const dir = stageNodePty() + writeFileSync(join(dir, 'build', 'Release', 'pty.node'), Buffer.from('not a native addon')) + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir: null }) + + expect(verdict.status).toBe('blocked') + expect(verdict.reason).toBeDefined() + expect(verdict.reason).not.toBe('spawn_helper_missing') + }) + + it('blocks when node-pty is not resolvable at all', () => { + const verdict = checkNodePtyPrecondition({ nodePtyDir: null, prebuildsDir: null }) + expect(verdict).toMatchObject({ status: 'blocked', reason: 'dependency_missing' }) + }) + + it('passes against the node-pty this repo actually builds', () => { + const verdict = checkNodePtyPrecondition({ prebuildsDir: null }) + expect(verdict.status).toBe('ok') + expect(verdict.slot).toBe( + process.platform === 'linux' + ? `linux-${process.arch}-${verdict.abi.libc}` + : `${process.platform}-${process.arch}` + ) + }) + + it.runIf(process.platform !== 'win32')( + 'degrades rather than blocks when only spawn-helper is missing', + () => { + // node-pty posix_spawns spawn-helper, so this host loads fine and then fails ENOENT + // the first time someone opens a terminal. Everything else it serves still works. + const dir = stageNodePty() + cpSync( + join(REAL_NODE_PTY, 'build', 'Release', 'pty.node'), + join(dir, 'build', 'Release', 'pty.node') + ) + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir: null }) + + expect(verdict).toMatchObject({ status: 'degraded', reason: 'spawn_helper_missing' }) + } + ) + + it('installs the matching shipped prebuilt slot when nothing is compiled', () => { + const dir = stageNodePty() + const abi = detectNativeHostAbi() + const slot = + abi.libc === 'none' ? `${abi.platform}-${abi.arch}` : `${abi.platform}-${abi.arch}-${abi.libc}` + const prebuildsDir = mkdtempSync(join(tmpdir(), 'orcad-prebuilds-')) + temporaryDirs.push(prebuildsDir) + mkdirSync(join(prebuildsDir, slot), { recursive: true }) + cpSync( + join(REAL_NODE_PTY, 'build', 'Release', 'pty.node'), + join(prebuildsDir, slot, 'pty.node') + ) + if (process.platform !== 'win32') { + cpSync( + join(REAL_NODE_PTY, 'build', 'Release', 'spawn-helper'), + join(prebuildsDir, slot, 'spawn-helper') + ) + } + + const verdict = checkNodePtyPrecondition({ nodePtyDir: dir, prebuildsDir }) + + expect(verdict.prebuilt).toMatchObject({ installed: true, slot }) + expect(verdict.status).toBe('ok') + }) +}) + +describe('formatNodePtyPreconditionReport', () => { + it('names the host, the slot and the action', () => { + const report = formatNodePtyPreconditionReport( + { + status: 'blocked', + slot: 'linux-x64-musl', + abi: { + platform: 'linux', + arch: 'x64', + libc: 'musl', + glibcVersion: null, + nodeAbi: '127' + }, + reason: 'dependency_missing', + prebuilt: { installed: false, slot: 'linux-x64-musl', why: 'no-slot' } + }, + 'Terminals are unavailable on this host.', + [' sudo apk add build-base python3'] + ) + + expect(report).toContain('platform linux/x64') + expect(report).toContain('libc musl') + expect(report).toContain('prebuild slot linux-x64-musl') + expect(report).toContain('No shipped prebuilt matches slot linux-x64-musl.') + expect(report).toContain('sudo apk add build-base python3') + }) +}) + +describe('probeLocalBuildToolchainHints', () => { + it('gives macOS the Xcode command line tools, not a Linux package manager', () => { + // The relay's hint list answers with a cross-distro apt/dnf/pacman/apk menu when it + // finds no package manager. On macOS every line of that menu is wrong. + const hints = probeLocalBuildToolchainHints('darwin') + expect(hints).toEqual([' xcode-select --install']) + expect(hints.join('\n')).not.toMatch(/apt-get|dnf|pacman|apk/) + }) + + it('says nothing on Windows, where node-pty ships prebuilds', () => { + expect(probeLocalBuildToolchainHints('win32')).toEqual([]) + }) + + it.runIf(process.platform === 'linux')('reuses the relay diagnosis on Linux', () => { + expect(probeLocalBuildToolchainHints('linux').length).toBeGreaterThan(0) + }) +}) diff --git a/src/main/orcad/node-pty-precondition.ts b/src/main/orcad/node-pty-precondition.ts new file mode 100644 index 000000000000..07ecb2c3645b --- /dev/null +++ b/src/main/orcad/node-pty-precondition.ts @@ -0,0 +1,362 @@ +/** + * Prove `node-pty` can be loaded on this host BEFORE anything in the process requires it. + * + * Why this exists: of the two ways node-pty fails, only one is catchable. A missing + * module throws `MODULE_NOT_FOUND` and a caller can degrade. A module that is present but + * built against the wrong libc or Node ABI is refused by the dynamic loader, and in the + * worst case takes the process down before any handler exists — that is #9902, which + * crashed the desktop app on Ubuntu 20.04 before a window appeared + * (docs/reference/linux-glibc-compatibility.md). + * + * So the load happens in a CHILD process. Whatever the child does — throw, abort, die on + * a signal — is data to us rather than our own death, and the operator gets a sentence + * naming what to change instead of a loader stack trace. + * + * The cost is one short-lived `node -e` at startup. That is the price of turning an + * uncatchable failure into a catchable one, and it is paid once per boot. + */ +import { existsSync, accessSync, constants } from 'node:fs' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { runProcessSync, type ProcessResult } from '../../shared/child-process/run-process' +import type { RuntimeTerminalUnavailableReason } from '../../shared/runtime-types' +import { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + toolchainInstallHintLines +} from '../ssh/build-toolchain-diagnosis' +import { + detectNativeHostAbi, + nativeSlotName, + parseNodeAbiMismatch, + parseUnmetGlibcVersion, + type NativeHostAbi +} from './native-host-abi' +import { installPrebuiltSlot, type PrebuiltSlotOutcome } from './node-pty-prebuilt-slot' + +// Why every verdict travels on STDOUT: node echoes the whole `-e` source into stderr +// before the stack trace, so any substring test against stderr also matches this file's +// own token strings. stdout carries only what the child chose to print. +const PROBE_OK_TOKEN = 'ORCA_NODE_PTY_LOAD_OK' +const NO_BINARY_TOKEN = 'ORCA_NODE_PTY_NO_BINARY' +const LOAD_ERROR_TOKEN = 'ORCA_NODE_PTY_LOAD_ERROR' +const PROBE_TIMEOUT_MS = 20_000 + +/** + * `ok` — proved loadable. `degraded` — loads, but something only spawn-time needs is + * broken, so the host should still serve everything else. `blocked` — proved unloadable, + * so nothing in this process may require it. `unverifiable` — the probe itself did not + * answer, which is not evidence either way. + * + * Why `unverifiable` is separate from `blocked`: a probe that times out or cannot spawn + * says nothing about node-pty, and refusing to boot on it would brick working hosts for + * a reason that was never established. Same verdict discipline as + * docs/reference/ssh-execution-boundary.md — loss of contact is not proof of death. + */ +export type NodePtyPreconditionStatus = 'ok' | 'degraded' | 'blocked' | 'unverifiable' + +export type NodePtyPreconditionVerdict = { + status: NodePtyPreconditionStatus + slot: string + abi: NativeHostAbi + reason?: RuntimeTerminalUnavailableReason + detail?: string + /** What the slot install did, when one was attempted. */ + prebuilt?: PrebuiltSlotOutcome +} + +export type NodePtyProbeFailure = { + status: 'blocked' | 'unverifiable' + reason: RuntimeTerminalUnavailableReason + detail: string +} + +/** + * Read the child's exit into a cause. Pure, so every failure shape is testable from a + * host that cannot reproduce it — the whole point, since the shapes that matter belong + * to Alpine and Ubuntu 20.04. + */ +export function classifyNodePtyProbeResult( + result: Pick +): NodePtyProbeFailure | null { + const stdout = result.stdout + if (result.code === 0 && stdout.includes(PROBE_OK_TOKEN)) { + return null + } + if (result.timedOut) { + return { + status: 'unverifiable', + reason: 'unknown', + detail: 'the node-pty load probe did not finish in time, so nothing was established' + } + } + // Why signal before anything the child said: a binary that aborts or segfaults inside + // the loader never reaches the catch, and often prints nothing at all. That silence is + // exactly the uncatchable case this probe is a separate process for. + if (result.signal) { + return { + status: 'blocked', + reason: 'load_crashed', + detail: `the load probe was killed by ${result.signal}` + } + } + if (stdout.includes(NO_BINARY_TOKEN)) { + return { + status: 'blocked', + reason: 'dependency_missing', + detail: 'node-pty is installed but has no compiled binary for this platform' + } + } + const reported = readReportedLoadError(stdout) + if (reported !== null) { + return classifyLoaderMessage(reported) + } + return { + status: 'blocked', + reason: 'load_failed', + detail: firstLine(result.stderr) || `the load probe exited with code ${result.code}` + } +} + +/** The message the child caught, or null when it never got that far. */ +function readReportedLoadError(stdout: string): string | null { + const line = stdout.split('\n').find((candidate) => candidate.startsWith(LOAD_ERROR_TOKEN)) + if (!line) { + return null + } + try { + return JSON.parse(line.slice(LOAD_ERROR_TOKEN.length).trim()) as string + } catch { + return line.slice(LOAD_ERROR_TOKEN.length).trim() + } +} + +/** Read a dynamic-loader message. Pure, so shapes this host cannot reproduce are testable. */ +export function classifyLoaderMessage(message: string): NodePtyProbeFailure { + const abiMismatch = parseNodeAbiMismatch(message) + if (abiMismatch) { + return { + status: 'blocked', + reason: 'abi_mismatch', + detail: `built for Node ABI ${abiMismatch.built}, this host runs ABI ${abiMismatch.host}` + } + } + const unmetGlibc = parseUnmetGlibcVersion(message) + if (unmetGlibc) { + return { + status: 'blocked', + reason: 'libc_floor', + detail: `the binary requires GLIBC_${unmetGlibc}` + } + } + if (/(GLIBCXX_|CXXABI_)[0-9.]+'? not found/.test(message)) { + return { status: 'blocked', reason: 'libc_floor', detail: firstLine(message) } + } + if (/MODULE_NOT_FOUND|Cannot find module/.test(message)) { + return { status: 'blocked', reason: 'dependency_missing', detail: firstLine(message) } + } + return { status: 'blocked', reason: 'load_failed', detail: firstLine(message) } +} + +/** + * Why not simply the first non-empty line: when the child dies without catching, node + * prints the offending source line and a caret before the error, so line one is the + * script rather than the diagnosis. Prefer the first line that reads as an error. + */ +function firstLine(text: string): string { + const lines = text.split('\n').filter((candidate) => candidate.trim().length > 0) + const errorLine = lines.find((candidate) => /^[A-Za-z]*(Error|Exception):/.test(candidate.trim())) + return (errorLine ?? lines[0] ?? text).trim().slice(0, 400) +} + +/** + * The script the child runs. + * + * Why it dlopens the file itself rather than trusting node-pty's loader: that loader + * tries several directories and rethrows only the LAST error, so a `pty.node` the + * dynamic loader refused is reported as `Cannot find module './prebuilds/...'`. Acting on + * that sends the operator to install a module that is already there. The dlopen has to + * come BEFORE `require(index.js)` for the same reason: node-pty's unixTerminal calls the + * loader at module scope, so requiring the package first re-wraps the error we came for. + * + * Why it catches and prints instead of throwing: a thrown error reaches us as a stack + * trace with the script source echoed above it, and the message we need is then one line + * inside a blob that also contains these very tokens. What the child cannot catch — a + * loader that aborts the process — still reaches us as a signal, which is the case this + * whole indirection exists for. + */ +export function buildNodePtyLoadProbeScript(nodePtyDir: string): string { + const entry = JSON.stringify(join(nodePtyDir, 'lib', 'index.js')) + const utils = JSON.stringify(join(nodePtyDir, 'lib', 'utils.js')) + const root = JSON.stringify(nodePtyDir) + // Same directory order node-pty's own loader walks, so the file opened here is the file + // it would load. Windows defers conpty.node to the first spawn, which is why the name is + // chosen the way node-pty chooses it rather than always being 'pty'. + return [ + `const fs=require('fs'),p=require('path');`, + `const n=process.platform==='win32'&&Number(require('os').release().split('.')[2])>=18309?'conpty':'pty';`, + `let f=null;`, + `for(const d of ['build/Release','build/Debug','prebuilds/'+process.platform+'-'+process.arch]){`, + `for(const r of [${root},p.join(${root},'lib')]){`, + `const c=p.join(r,d,n+'.node');if(fs.existsSync(c)){f=c;break}}if(f)break}`, + `if(!f){console.log(${JSON.stringify(NO_BINARY_TOKEN)});process.exit(3)}`, + `try{`, + `process.dlopen({exports:{}},f);`, + `require(${entry});`, + `require(${utils}).loadNativeModule(n);`, + `console.log(${JSON.stringify(PROBE_OK_TOKEN)}+' '+p.dirname(f));`, + `}catch(e){`, + `console.log(${JSON.stringify(LOAD_ERROR_TOKEN)}+' '+JSON.stringify(String((e&&e.message)||e)));`, + `process.exit(4)}` + ].join('') +} + +function resolveNodePtyDir(): string | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- resolution only; the load itself happens in the child. + return dirname(require.resolve('node-pty/package.json')) + } catch { + return null + } +} + +/** Local equivalent of the relay's remote toolchain probe, reusing its pure half. */ +export function probeLocalBuildToolchainHints(platform: NodeJS.Platform): string[] { + if (platform === 'win32') { + return [] + } + // Why macOS is not routed through the relay's hints: that function answers with a + // cross-distro apt/dnf/pacman/apk menu when it finds no package manager, and none of + // those lines is the macOS answer. Printing them here would be confidently wrong. + if (platform === 'darwin') { + return [' xcode-select --install'] + } + try { + const result = runProcessSync({ + program: '/bin/sh', + args: ['-c', buildToolchainProbeCommand()], + timeoutMs: 10_000 + }) + return toolchainInstallHintLines(parseBuildToolchainProbe(result.stdout)) + } catch { + return [] + } +} + +export function checkNodePtyPrecondition( + options: { nodePtyDir?: string | null; abi?: NativeHostAbi; prebuildsDir?: string | null } = {} +): NodePtyPreconditionVerdict { + const abi = options.abi ?? detectNativeHostAbi() + const slot = nativeSlotName(abi) + // Why `in` and not `??`: an explicit `null` means "this host cannot resolve node-pty", + // which is a case tests must be able to state. `??` would silently re-detect instead. + const nodePtyDir = 'nodePtyDir' in options ? options.nodePtyDir : resolveNodePtyDir() + if (!nodePtyDir) { + return { + status: 'blocked', + slot, + abi, + reason: 'dependency_missing', + detail: 'node-pty is not resolvable from this install' + } + } + + // Why install before probing: on a toolchain-free deployment the compiled binary does + // not exist yet, and the shipped slot is the only thing that can make the probe pass. + let prebuilt: PrebuiltSlotOutcome | undefined + if (!existsSync(join(nodePtyDir, 'build', 'Release', 'pty.node'))) { + prebuilt = installPrebuiltSlot({ + abi, + nodePtyDir, + ...(options.prebuildsDir === undefined ? {} : { prebuildsDir: options.prebuildsDir }) + }) + } + + let result: ProcessResult + try { + result = runProcessSync({ + program: process.execPath, + args: ['-e', buildNodePtyLoadProbeScript(nodePtyDir)], + timeoutMs: PROBE_TIMEOUT_MS + }) + } catch (error) { + return { + status: 'unverifiable', + slot, + abi, + reason: 'unknown', + detail: `the node-pty load probe could not be started: ${(error as Error).message}`, + ...(prebuilt ? { prebuilt } : {}) + } + } + const failure = classifyNodePtyProbeResult(result) + if (failure) { + return { + status: failure.status, + slot, + abi, + reason: failure.reason, + detail: failure.detail, + ...(prebuilt ? { prebuilt } : {}) + } + } + + // Loaded. The remaining way terminals fail is spawn-time: node-pty posix_spawns + // build/Release/spawn-helper, and a missing one turns every terminal.create into ENOENT + // on a host that otherwise looks healthy. That is a degradation, not a boot blocker. + const loadedDir = result.stdout.split(PROBE_OK_TOKEN)[1]?.trim().split('\n')[0]?.trim() + if (abi.platform !== 'win32') { + const helper = join(loadedDir || join(nodePtyDir, 'build', 'Release'), 'spawn-helper') + if (!isExecutableFile(helper)) { + return { + status: 'degraded', + slot, + abi, + reason: 'spawn_helper_missing', + detail: `expected an executable at ${helper}`, + ...(prebuilt ? { prebuilt } : {}) + } + } + } + return { status: 'ok', slot, abi, ...(prebuilt ? { prebuilt } : {}) } +} + +function isExecutableFile(path: string): boolean { + try { + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** The operator-facing report. Names the host, the cause, and the next action. */ +export function formatNodePtyPreconditionReport( + verdict: NodePtyPreconditionVerdict, + message: string, + toolchainHints: string[] = [] +): string { + const { abi } = verdict + const host = [ + `platform ${abi.platform}/${abi.arch}`, + abi.libc === 'none' ? null : `libc ${abi.libc}${abi.glibcVersion ? ` ${abi.glibcVersion}` : ''}`, + `Node ABI ${abi.nodeAbi}`, + `prebuild slot ${verdict.slot}` + ] + .filter((part): part is string => part !== null) + .join(', ') + const lines = [message, '', `Host: ${host}`] + if (verdict.prebuilt && !verdict.prebuilt.installed) { + lines.push( + verdict.prebuilt.why === 'no-slot' + ? `No shipped prebuilt matches slot ${verdict.slot}.` + : verdict.prebuilt.why === 'no-prebuilds-dir' + ? 'This install ships no prebuilds directory.' + : `Shipped prebuilds are unusable here: ${verdict.prebuilt.detail ?? 'ABI mismatch'}.` + ) + } + if (toolchainHints.length > 0) { + lines.push('', 'To build node-pty on this host, install a C/C++ toolchain:', ...toolchainHints) + } + return lines.join('\n') +} diff --git a/src/main/orcad/orcad-bundle-native-load-order.test.ts b/src/main/orcad/orcad-bundle-native-load-order.test.ts new file mode 100644 index 000000000000..1c26501b6a68 --- /dev/null +++ b/src/main/orcad/orcad-bundle-native-load-order.test.ts @@ -0,0 +1,91 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcessSync } from '../../shared/child-process/run-process' + +/** + * The preflight only prevents a loader crash if nothing in the bundle's import graph has + * already required node-pty by the time it runs. esbuild wraps `local-pty-provider` in a + * lazy initializer because `orcad-entry` reaches it through `await import('../ipc/pty')`, + * and that laziness is load-bearing rather than incidental — a single top-level static + * import anywhere in the graph would hoist `require("node-pty")` above every statement in + * `main.ts`, including the preflight. + * + * Why this builds the bundle instead of skipping without one: no CI job builds orcad and + * runs vitest. `smoke:orcad-terminal` builds it in the static-analysis job, which never + * runs vitest; the `orcad_browser` job runs vitest but deliberately does not build orcad. + * A `runIf(existsSync(...))` guard therefore skips in every shard, forever — the same way + * an unset ORCA_BROWSER_EXECUTABLE kept the browser provider uncovered. + * + * Why not fail-when-CI instead: the wiring that would satisfy it lives in `.github/`, so + * that turns a silent gap into a red build someone else has to fix. Building costs well + * under a second (esbuild), works in every shard and on every machine, and needs no job + * to cooperate. What it must never do is skip. + */ +const REPO_ROOT = join(__dirname, '..', '..', '..') +const BUNDLE = join(REPO_ROOT, 'out', 'orcad', 'orcad.js') +const BUILD_SCRIPT = join(REPO_ROOT, 'config', 'scripts', 'build-orcad.mjs') + +/** + * Why it throws rather than skipping when the build fails: a bundle that cannot be built + * is a louder problem than the one this test checks, and swallowing it here is exactly + * how the assertion would go missing. + */ +function ensureOrcadBundle(): void { + if (existsSync(BUNDLE)) { + return + } + const build = runProcessSync({ + program: process.execPath, + args: [BUILD_SCRIPT], + cwd: REPO_ROOT, + timeoutMs: 300_000 + }) + if (!existsSync(BUNDLE)) { + const output = `${build.stdout}${build.stderr}`.slice(0, 4000) + throw new Error( + `could not build ${BUNDLE} (exit ${build.code}); the load-order assertion cannot run:\n${output}` + ) + } +} + +describe('orcad bundle native load order', () => { + const dirs: string[] = [] + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('does not require node-pty in-process before the entry rejects its argv', () => { + ensureOrcadBundle() + const dir = mkdtempSync(join(tmpdir(), 'orcad-load-order-')) + dirs.push(dir) + const harness = join(dir, 'harness.cjs') + writeFileSync( + harness, + [ + "const Module = require('module')", + 'const original = Module._load', + 'Module._load = function (request, ...rest) {', + " if (request === 'node-pty') { console.log('IN_PROCESS_NODE_PTY_REQUIRE') }", + ' return original.call(this, request, ...rest)', + '}', + "process.argv.push('--orcad-load-order-check')", + `require(${JSON.stringify(BUNDLE)})` + ].join('\n') + ) + + const result = runProcessSync({ + program: process.execPath, + args: [harness], + timeoutMs: 120_000 + }) + const output = `${result.stdout}${result.stderr}` + + // Proof the graph fully loaded and reached argv parsing rather than dying early. + expect(output).toContain('Unknown argument: --orcad-load-order-check') + expect(output).not.toContain('IN_PROCESS_NODE_PTY_REQUIRE') + }, 360_000) +}) diff --git a/src/main/orcad/orcad-native-preflight.test.ts b/src/main/orcad/orcad-native-preflight.test.ts new file mode 100644 index 000000000000..6c697a3ecce4 --- /dev/null +++ b/src/main/orcad/orcad-native-preflight.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ORCAD_NATIVE_PRECONDITION_EXIT_CODE, + runOrcadNativePreflight +} from './orcad-native-preflight' +import { + runtimeTerminalDegradation, + setRuntimeTerminalUnavailableCause +} from '../runtime/native-terminal-availability' +import type { NodePtyPreconditionVerdict } from './node-pty-precondition' + +const ABI = { + platform: 'linux' as NodeJS.Platform, + arch: 'x64', + libc: 'glibc' as const, + glibcVersion: '2.31', + nodeAbi: '127' +} + +const verdict = (over: Partial): NodePtyPreconditionVerdict => ({ + status: 'ok', + slot: 'linux-x64-glibc', + abi: ABI, + ...over +}) + +const harness = (given: NodePtyPreconditionVerdict) => { + const warn = vi.fn() + const fail = vi.fn() + const exit = vi.fn(() => undefined as never) + const continued = runOrcadNativePreflight({ + check: () => given, + toolchainHints: () => [' sudo apt-get install -y build-essential python3'], + warn, + fail, + exit + }) + return { warn, fail, exit, continued } +} + +afterEach(() => { + setRuntimeTerminalUnavailableCause(null) +}) + +describe('runOrcadNativePreflight', () => { + it('stops the boot on a proven-unloadable binary instead of reaching the require', () => { + // Continuing here would hit the very dlopen the probe just proved fatal, and the + // operator would get the loader's stack trace instead of the sentence below. + const { fail, exit, warn } = harness( + verdict({ status: 'blocked', reason: 'libc_floor', detail: 'the binary requires GLIBC_2.34' }) + ) + + expect(exit).toHaveBeenCalledWith(ORCAD_NATIVE_PRECONDITION_EXIT_CODE) + expect(warn).not.toHaveBeenCalled() + const message = fail.mock.calls[0][0] as string + expect(message).toContain('newer C library') + expect(message).toContain('the binary requires GLIBC_2.34') + expect(message).toContain('libc glibc 2.31') + // The toolchain hint is what makes it actionable rather than merely accurate. + expect(message).toContain('sudo apt-get install -y build-essential python3') + }) + + it('exits with EX_CONFIG so a supervisor does not restart an unequippable host forever', () => { + expect(ORCAD_NATIVE_PRECONDITION_EXIT_CODE).toBe(78) + expect(ORCAD_NATIVE_PRECONDITION_EXIT_CODE).not.toBe(1) + }) + + it('publishes the blocked cause as a status degradation', () => { + harness(verdict({ status: 'blocked', reason: 'abi_mismatch', detail: 'built for ABI 115' })) + + expect(runtimeTerminalDegradation()).toEqual({ + code: 'terminal_unavailable', + capability: 'terminal.pty.v1', + reason: 'abi_mismatch', + detail: 'built for ABI 115', + message: + "This host's node-pty binary was built for a different Node ABI than the running Node, so it cannot be loaded. Rebuild node-pty against this Node version. (built for ABI 115)" + }) + }) + + it('boots on a spawn-time-only fault and reports it rather than refusing to serve', () => { + const { continued, exit, warn } = harness( + verdict({ status: 'degraded', reason: 'spawn_helper_missing', detail: 'no spawn-helper' }) + ) + + expect(continued).toBe(true) + expect(exit).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledOnce() + expect(runtimeTerminalDegradation()?.reason).toBe('spawn_helper_missing') + }) + + it('boots when the probe established nothing, because that is not evidence of a fault', () => { + const { continued, exit } = harness( + verdict({ status: 'unverifiable', reason: 'unknown', detail: 'probe timed out' }) + ) + + expect(continued).toBe(true) + expect(exit).not.toHaveBeenCalled() + // Still reported: an unverifiable host must not look identical to a proven-healthy one. + expect(runtimeTerminalDegradation()?.reason).toBe('unknown') + }) + + it('reports nothing when the load was proved good', () => { + setRuntimeTerminalUnavailableCause({ reason: 'load_failed' }) + + const { continued, warn, fail } = harness(verdict({ status: 'ok' })) + + expect(continued).toBe(true) + expect(warn).not.toHaveBeenCalled() + expect(fail).not.toHaveBeenCalled() + expect(runtimeTerminalDegradation()).toBeNull() + }) + + it('does not run the toolchain probe for a verdict that is not about a missing build', () => { + const toolchainHints = vi.fn(() => [] as string[]) + runOrcadNativePreflight({ + check: () => verdict({ status: 'degraded', reason: 'spawn_helper_missing' }), + toolchainHints, + warn: vi.fn(), + fail: vi.fn(), + exit: vi.fn(() => undefined as never) + }) + expect(toolchainHints).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-native-preflight.ts b/src/main/orcad/orcad-native-preflight.ts new file mode 100644 index 000000000000..0f3c2cf89702 --- /dev/null +++ b/src/main/orcad/orcad-native-preflight.ts @@ -0,0 +1,72 @@ +/** + * The native precondition orcad runs before its runtime loads anything native. + * + * Split from `node-pty-precondition.ts` so the decision (what to do about a verdict) is + * testable apart from the detection (what the verdict is). + */ +import process from 'node:process' +import { setRuntimeTerminalUnavailableCause } from '../runtime/native-terminal-availability' +import { terminalUnavailableMessage } from '../../shared/runtime-types' +import { + checkNodePtyPrecondition, + formatNodePtyPreconditionReport, + probeLocalBuildToolchainHints, + type NodePtyPreconditionVerdict +} from './node-pty-precondition' + +/** + * EX_CONFIG. Why not 1: a supervisor that restarts on 1 would restart forever against a + * host that can never load this binary. This code says "the host is not equipped", which + * is a different instruction from "it crashed". + */ +export const ORCAD_NATIVE_PRECONDITION_EXIT_CODE = 78 + +export type NativePreflightHooks = { + check?: () => NodePtyPreconditionVerdict + toolchainHints?: (platform: NodeJS.Platform) => string[] + warn?: (message: string) => void + fail?: (message: string) => void + exit?: (code: number) => never +} + +/** + * Returns true when boot may continue. + * + * A `blocked` verdict never returns: continuing would reach the very `require` the probe + * just proved fatal, and the operator would get the loader's stack trace instead of the + * sentence printed here. + */ +export function runOrcadNativePreflight(hooks: NativePreflightHooks = {}): boolean { + const check = hooks.check ?? checkNodePtyPrecondition + const warn = hooks.warn ?? ((message: string) => console.warn(message)) + const fail = hooks.fail ?? ((message: string) => console.error(message)) + const exit = hooks.exit ?? ((code: number) => process.exit(code) as never) + const verdict = check() + + if (verdict.status === 'ok') { + // Why clear rather than leave alone: a previous run in this process may have recorded + // a cause, and status.get must not keep reporting a degradation that no longer holds. + setRuntimeTerminalUnavailableCause(null) + return true + } + + const reason = verdict.reason ?? 'unknown' + setRuntimeTerminalUnavailableCause({ + reason, + ...(verdict.detail ? { detail: verdict.detail } : {}) + }) + const message = terminalUnavailableMessage(reason, verdict.detail) + + if (verdict.status === 'blocked') { + const hints = (hooks.toolchainHints ?? probeLocalBuildToolchainHints)(verdict.abi.platform) + fail(`orcad: ${formatNodePtyPreconditionReport(verdict, message, hints)}`) + exit(ORCAD_NATIVE_PRECONDITION_EXIT_CODE) + return false + } + + // `degraded` and `unverifiable` both boot. The first is a proven spawn-time fault the + // host can still serve around; the second established nothing, and refusing to boot on + // an inconclusive probe would take down hosts that work. + warn(`orcad: ${formatNodePtyPreconditionReport(verdict, message)}`) + return true +} diff --git a/src/main/runtime/native-terminal-availability.ts b/src/main/runtime/native-terminal-availability.ts new file mode 100644 index 000000000000..85eb3df57f07 --- /dev/null +++ b/src/main/runtime/native-terminal-availability.ts @@ -0,0 +1,51 @@ +import { + type RuntimeTerminalUnavailableReason, + terminalUnavailableMessage, + TERMINAL_PTY_DEGRADATION_CAPABILITY, + TERMINAL_UNAVAILABLE_ERROR_CODE, + type RuntimeDegradation +} from '../../shared/runtime-types' + +/** + * A note left by whoever proved this host cannot load or spawn PTYs, read back when + * `status.get` assembles `degradations[]`. + * + * Why a module-level note rather than a constructor argument: only the host entry point + * can run the out-of-process load probe, and it must run before anything requires + * `node-pty` — long before `OrcaRuntimeService` exists. This is the same shape + * `runtime-browser-commands-factory` uses for `browser_unavailable`, for the same reason. + * + * Silence means "nothing proved it broken", never "proved working". Hosts that never + * run a precondition therefore report no degradation, which is the honest answer. + */ +export type RuntimeTerminalUnavailableCause = { + reason: RuntimeTerminalUnavailableReason + detail?: string +} + +let unavailableCause: RuntimeTerminalUnavailableCause | null = null + +export function setRuntimeTerminalUnavailableCause( + cause: RuntimeTerminalUnavailableCause | null +): void { + unavailableCause = cause +} + +export function runtimeTerminalUnavailableCause(): RuntimeTerminalUnavailableCause | null { + return unavailableCause +} + +/** The degradation entry for the recorded cause, or null when nothing is degraded. */ +export function runtimeTerminalDegradation(): RuntimeDegradation | null { + const cause = unavailableCause + if (!cause) { + return null + } + return { + code: TERMINAL_UNAVAILABLE_ERROR_CODE, + capability: TERMINAL_PTY_DEGRADATION_CAPABILITY, + message: terminalUnavailableMessage(cause.reason, cause.detail), + reason: cause.reason, + ...(cause.detail ? { detail: cause.detail } : {}) + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index fd0645f52747..ab5d3a805530 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5,6 +5,7 @@ import { setRuntimeBrowserCommandsFactory, setRuntimeBrowserUnavailableCause } from './runtime-browser-commands-factory' +import { setRuntimeTerminalUnavailableCause } from './native-terminal-availability' import { setRuntimeDesktopSurface } from './runtime-desktop-surface' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type * as GitUsernameModule from '../git/git-username' @@ -691,6 +692,7 @@ function resetRuntimeTestMocks(): void { // browser RPCs reject rather than silently succeeding. setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) setRuntimeBrowserUnavailableCause(null) + setRuntimeTerminalUnavailableCause(null) // Why: the runtime's notification, window lookup and tab-create-reply channel are // injected now, so the electron mock alone is inert. Back the surface with the same // mocks so every existing expectation still holds. @@ -2592,6 +2594,43 @@ describe('OrcaRuntimeService', () => { ) }) + it('reports a host that cannot load node-pty, instead of that host never answering', () => { + // The alternative to reporting it is the process dying inside the dynamic loader, + // which reaches a client as a dropped connection with no cause attached. + setRuntimeTerminalUnavailableCause({ + reason: 'libc_floor', + detail: 'the binary requires GLIBC_2.34' + }) + + const degradations = createRuntime().getStatus().degradations ?? [] + + expect(degradations).toContainEqual({ + code: 'terminal_unavailable', + capability: 'terminal.pty.v1', + reason: 'libc_floor', + detail: 'the binary requires GLIBC_2.34', + message: + "This host's node-pty binary was built against a newer C library than the host provides, so the dynamic loader refuses it. Rebuild node-pty on this host, or deploy a build whose prebuilt binary matches this platform's libc. (the binary requires GLIBC_2.34)" + }) + }) + + it('reports browser and terminal loss together, because they fail independently', () => { + setRuntimeBrowserCommandsFactory(null) + setRuntimeBrowserUnavailableCause({ reason: 'unconfigured' }) + setRuntimeTerminalUnavailableCause({ reason: 'dependency_missing' }) + + const codes = (createRuntime().getStatus().degradations ?? []).map((entry) => entry.code) + + expect(codes).toEqual(['browser_unavailable', 'terminal_unavailable']) + }) + + it('says nothing about terminals when no precondition proved them broken', () => { + // Silence must mean "nothing proved it broken", never "proved working" — a host that + // never ran the precondition has no verdict to publish. + const degradations = createRuntime().getStatus().degradations ?? [] + expect(degradations.map((entry) => entry.code)).not.toContain('terminal_unavailable') + }) + it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => { const runtime = createRuntime() const closeTab = vi.fn().mockResolvedValue(undefined) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 98f138a35676..04c54fa429d0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -424,6 +424,7 @@ import type { LinearTeamStatesResult, LinearStatusSetResult } from '../../shared/linear/agent-access' +import { runtimeTerminalDegradation } from './native-terminal-availability' import { BROWSER_UNAVAILABLE_ERROR_CODE, browserUnavailableMessage, @@ -6174,6 +6175,12 @@ export class OrcaRuntimeService { } ] : [] + // Why appended rather than merged into the ternary: PTY loss and browser loss are + // independent, and a host can be degraded on both at once. + const terminalDegradation = runtimeTerminalDegradation() + if (terminalDegradation) { + degradations.push(terminalDegradation) + } return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, diff --git a/src/main/ssh/build-toolchain-diagnosis.ts b/src/main/ssh/build-toolchain-diagnosis.ts new file mode 100644 index 000000000000..446601518c95 --- /dev/null +++ b/src/main/ssh/build-toolchain-diagnosis.ts @@ -0,0 +1,166 @@ +/** + * Diagnose a host's C/C++ build toolchain from a POSIX-sh probe. + * + * Why this is separate from `ssh-relay-build-toolchain.ts`: everything here is pure — + * it builds a shell command, parses its output, and formats messages. The relay reaches + * it over SSH; `orcad` reaches it over a local shell. Keeping it transport-free is what + * lets the Node-only bundle reuse it without pulling `ssh2` in behind it. + */ +// Why: node-pty@1.1.0 ships no Linux prebuild, so the remote `npm install` falls +// back to `node-gyp rebuild` and needs a C/C++ toolchain. A missing toolchain is +// the dominant first-connect failure on Linux relays (#1693); node-gyp surfaces +// it as an opaque `not found: make`. We probe for the tools so we can replace +// that with an actionable "install build-essential" message. +const PROBED_TOOLS = [ + 'make', + 'gcc', + 'g++', + 'cc', + 'c++', + 'clang', + 'clang++', + 'python3', + 'python' +] as const + +// Package managers mapped to the one-liner that installs a C/C++ toolchain on +// the matching distro family. Ordered by detection priority. +const PACKAGE_MANAGER_HINTS: readonly { bin: string; install: string }[] = [ + { bin: 'apt-get', install: 'sudo apt-get install -y build-essential python3' }, + { bin: 'dnf', install: 'sudo dnf install -y make gcc gcc-c++ python3' }, + { bin: 'yum', install: 'sudo yum install -y make gcc gcc-c++ python3' }, + { bin: 'pacman', install: 'sudo pacman -S --needed base-devel python' }, + { bin: 'apk', install: 'sudo apk add build-base python3' }, + { bin: 'zypper', install: 'sudo zypper install -y gcc gcc-c++ make python3' } +] + +export type BuildToolchainStatus = { + present: string[] + packageManager: string | null + // node-gyp needs make, Python, and a C++ compiler. The caller only uses this + // verdict after npm/node-gyp output already points at a native-build failure, + // so custom Python paths do not make unrelated npm failures look toolchainy. + toolchainMissing: boolean +} + +function hasCxxCompiler(present: ReadonlySet): boolean { + return present.has('g++') || present.has('c++') || present.has('clang++') +} + +function hasPython(present: ReadonlySet): boolean { + return present.has('python3') || present.has('python') +} + +// POSIX-sh probe: echo a `HAVE ` line per resolvable build tool and a +// single `PKG ` line for the host's package manager. Runs under +// `/bin/sh -c` (see wrapRemoteCommandForPosixShell), so it stays portable. +export function buildToolchainProbeCommand(): string { + const toolLoop = `for t in ${PROBED_TOOLS.join( + ' ' + )}; do if command -v "$t" >/dev/null 2>&1; then echo "HAVE $t"; fi; done` + const pkgList = PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ') + const pkgLoop = `for p in ${pkgList}; do if command -v "$p" >/dev/null 2>&1; then echo "PKG $p"; break; fi; done` + return `${toolLoop}; ${pkgLoop}` +} + +export function parseBuildToolchainProbe(output: string): BuildToolchainStatus { + const present = new Set() + let packageManager: string | null = null + for (const line of output.split('\n')) { + const haveMatch = line.trim().match(/^HAVE (\S+)$/) + if (haveMatch) { + present.add(haveMatch[1]) + continue + } + const pkgMatch = line.trim().match(/^PKG (\S+)$/) + if (pkgMatch && !packageManager) { + packageManager = pkgMatch[1] + } + } + return { + present: PROBED_TOOLS.filter((tool) => present.has(tool)), + packageManager, + toolchainMissing: !present.has('make') || !hasCxxCompiler(present) || !hasPython(present) + } +} + +export function shouldProbeBuildToolchainAfterNativeDepsFailure(message: string): boolean { + const lower = message.toLowerCase() + if (!lower.includes('gyp') && !lower.includes('node-gyp')) { + return false + } + return ( + /\bnot found:\s*(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b/i.test( + message + ) || + /\b(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b.*\bnot found\b/i.test( + message + ) || + lower.includes('could not find any python installation') || + lower.includes('no xcode or clt version detected') + ) +} + +function missingToolNames(status: BuildToolchainStatus): string[] { + const present = new Set(status.present) + const missing: string[] = [] + if (!present.has('make')) { + missing.push('make') + } + if (!hasCxxCompiler(present)) { + missing.push('a C++ compiler (g++ or clang++)') + } + if (!hasPython(present)) { + missing.push('python3') + } + return missing +} + +/** Install hint for the host's package manager, or the cross-distro list when it is unknown. */ +export function toolchainInstallHintLines(status: BuildToolchainStatus): string[] { + const tailored = status.packageManager + ? PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === status.packageManager)?.install + : null + if (tailored) { + return [` ${tailored}`] + } + return [ + ' Debian/Ubuntu: sudo apt-get install -y build-essential python3', + ' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3', + ' Arch: sudo pacman -S --needed base-devel python', + ' Alpine: sudo apk add build-base python3' + ] +} + +/** One-line summary for the deploy log when node-pty is skipped rather than compiled. */ +export function formatSkippedNodePtyWarning(status: BuildToolchainStatus): string { + // Why: with no package manager detected the hint list is the cross-distro menu, whose first line + // is Debian's — quoting it alone would name the wrong distro, so stay neutral instead. + const hintLines = toolchainInstallHintLines(status) + const hint = + hintLines.length === 1 + ? hintLines[0].trim() + : 'install a C/C++ toolchain (make, a C++ compiler, python3)' + return ( + `missing build tools (${missingToolNames(status).join(', ')}); skipping node-pty so the ` + + `connection still serves files and git. Remote terminals need: ${hint}` + ) +} + +export function formatMissingToolchainError( + status: BuildToolchainStatus, + underlyingError: string +): string { + const lines = [ + `The remote host is missing the C/C++ build tools (${missingToolNames(status).join(', ')}) ` + + `needed to compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` + + `prebuilt binary for Linux, so they must be compiled on the remote host.`, + '', + 'Install the build tools on the remote host, then reconnect:', + ...toolchainInstallHintLines(status), + '', + `Underlying install error: ${underlyingError}` + ] + return lines.join('\n') +} + diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts index 608fca5c6eb1..bcb64d3bdf91 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.ts @@ -1,164 +1,24 @@ import { execCommand } from './ssh-relay-deploy-helpers' import type { SshConnection } from './ssh-connection' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' - -// Why: node-pty@1.1.0 ships no Linux prebuild, so the remote `npm install` falls -// back to `node-gyp rebuild` and needs a C/C++ toolchain. A missing toolchain is -// the dominant first-connect failure on Linux relays (#1693); node-gyp surfaces -// it as an opaque `not found: make`. We probe for the tools so we can replace -// that with an actionable "install build-essential" message. -const PROBED_TOOLS = [ - 'make', - 'gcc', - 'g++', - 'cc', - 'c++', - 'clang', - 'clang++', - 'python3', - 'python' -] as const - -// Package managers mapped to the one-liner that installs a C/C++ toolchain on -// the matching distro family. Ordered by detection priority. -const PACKAGE_MANAGER_HINTS: readonly { bin: string; install: string }[] = [ - { bin: 'apt-get', install: 'sudo apt-get install -y build-essential python3' }, - { bin: 'dnf', install: 'sudo dnf install -y make gcc gcc-c++ python3' }, - { bin: 'yum', install: 'sudo yum install -y make gcc gcc-c++ python3' }, - { bin: 'pacman', install: 'sudo pacman -S --needed base-devel python' }, - { bin: 'apk', install: 'sudo apk add build-base python3' }, - { bin: 'zypper', install: 'sudo zypper install -y gcc gcc-c++ make python3' } -] - -export type BuildToolchainStatus = { - present: string[] - packageManager: string | null - // node-gyp needs make, Python, and a C++ compiler. The caller only uses this - // verdict after npm/node-gyp output already points at a native-build failure, - // so custom Python paths do not make unrelated npm failures look toolchainy. - toolchainMissing: boolean -} - -function hasCxxCompiler(present: ReadonlySet): boolean { - return present.has('g++') || present.has('c++') || present.has('clang++') -} - -function hasPython(present: ReadonlySet): boolean { - return present.has('python3') || present.has('python') -} - -// POSIX-sh probe: echo a `HAVE ` line per resolvable build tool and a -// single `PKG ` line for the host's package manager. Runs under -// `/bin/sh -c` (see wrapRemoteCommandForPosixShell), so it stays portable. -export function buildToolchainProbeCommand(): string { - const toolLoop = `for t in ${PROBED_TOOLS.join( - ' ' - )}; do if command -v "$t" >/dev/null 2>&1; then echo "HAVE $t"; fi; done` - const pkgList = PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ') - const pkgLoop = `for p in ${pkgList}; do if command -v "$p" >/dev/null 2>&1; then echo "PKG $p"; break; fi; done` - return `${toolLoop}; ${pkgLoop}` -} - -export function parseBuildToolchainProbe(output: string): BuildToolchainStatus { - const present = new Set() - let packageManager: string | null = null - for (const line of output.split('\n')) { - const haveMatch = line.trim().match(/^HAVE (\S+)$/) - if (haveMatch) { - present.add(haveMatch[1]) - continue - } - const pkgMatch = line.trim().match(/^PKG (\S+)$/) - if (pkgMatch && !packageManager) { - packageManager = pkgMatch[1] - } - } - return { - present: PROBED_TOOLS.filter((tool) => present.has(tool)), - packageManager, - toolchainMissing: !present.has('make') || !hasCxxCompiler(present) || !hasPython(present) - } -} - -export function shouldProbeBuildToolchainAfterNativeDepsFailure(message: string): boolean { - const lower = message.toLowerCase() - if (!lower.includes('gyp') && !lower.includes('node-gyp')) { - return false - } - return ( - /\bnot found:\s*(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b/i.test( - message - ) || - /\b(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b.*\bnot found\b/i.test( - message - ) || - lower.includes('could not find any python installation') || - lower.includes('no xcode or clt version detected') - ) -} - -function missingToolNames(status: BuildToolchainStatus): string[] { - const present = new Set(status.present) - const missing: string[] = [] - if (!present.has('make')) { - missing.push('make') - } - if (!hasCxxCompiler(present)) { - missing.push('a C++ compiler (g++ or clang++)') - } - if (!hasPython(present)) { - missing.push('python3') - } - return missing -} - -/** Install hint for the host's package manager, or the cross-distro list when it is unknown. */ -export function toolchainInstallHintLines(status: BuildToolchainStatus): string[] { - const tailored = status.packageManager - ? PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === status.packageManager)?.install - : null - if (tailored) { - return [` ${tailored}`] - } - return [ - ' Debian/Ubuntu: sudo apt-get install -y build-essential python3', - ' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3', - ' Arch: sudo pacman -S --needed base-devel python', - ' Alpine: sudo apk add build-base python3' - ] -} - -/** One-line summary for the deploy log when node-pty is skipped rather than compiled. */ -export function formatSkippedNodePtyWarning(status: BuildToolchainStatus): string { - // Why: with no package manager detected the hint list is the cross-distro menu, whose first line - // is Debian's — quoting it alone would name the wrong distro, so stay neutral instead. - const hintLines = toolchainInstallHintLines(status) - const hint = - hintLines.length === 1 - ? hintLines[0].trim() - : 'install a C/C++ toolchain (make, a C++ compiler, python3)' - return ( - `missing build tools (${missingToolNames(status).join(', ')}); skipping node-pty so the ` + - `connection still serves files and git. Remote terminals need: ${hint}` - ) -} - -export function formatMissingToolchainError( - status: BuildToolchainStatus, - underlyingError: string -): string { - const lines = [ - `The remote host is missing the C/C++ build tools (${missingToolNames(status).join(', ')}) ` + - `needed to compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` + - `prebuilt binary for Linux, so they must be compiled on the remote host.`, - '', - 'Install the build tools on the remote host, then reconnect:', - ...toolchainInstallHintLines(status), - '', - `Underlying install error: ${underlyingError}` - ] - return lines.join('\n') -} +import { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + type BuildToolchainStatus +} from './build-toolchain-diagnosis' + +// Why re-exported rather than moved outright: `ssh-relay-deploy.ts` and the relay tests +// import the whole diagnosis surface from here, and the split exists for bundle reasons, +// not to redraw the relay's own API. +export { + buildToolchainProbeCommand, + parseBuildToolchainProbe, + shouldProbeBuildToolchainAfterNativeDepsFailure, + toolchainInstallHintLines, + formatSkippedNodePtyWarning, + formatMissingToolchainError +} from './build-toolchain-diagnosis' +export type { BuildToolchainStatus } from './build-toolchain-diagnosis' // Best-effort: returns null on Windows hosts (node-pty ships win32 prebuilds, so // a missing toolchain isn't the failure there) or if the probe itself errors — diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 423c1fe61919..6a0ad601c227 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -110,15 +110,70 @@ export function browserUnavailableMessage( return detail ? `${base} (${detail})` : base } +export const TERMINAL_UNAVAILABLE_ERROR_CODE = 'terminal_unavailable' as const + +/** + * Label for the thing a `terminal_unavailable` degradation degrades. Deliberately NOT a + * member of `RUNTIME_CAPABILITIES`: capabilities are things a host advertises it HAS, and + * adding one here would make every host start claiming a capability name it never had. + */ +export const TERMINAL_PTY_DEGRADATION_CAPABILITY = 'terminal.pty.v1' as const + +/** + * Why this host cannot spawn PTYs. Same opacity contract as + * `RuntimeBrowserUnavailableReason`: render `message`, never switch exhaustively. + * + * The libc/ABI members exist because they are the failure the dynamic loader owns — + * `node-pty` is present and resolvable, and loading it kills the process before any + * `try` can see it (docs/reference/linux-glibc-compatibility.md, #9902). A host that + * proved this out of process reports it here instead of dying. + */ +export type RuntimeTerminalUnavailableReason = + | 'dependency_missing' + | 'libc_floor' + | 'abi_mismatch' + | 'load_failed' + | 'load_crashed' + | 'spawn_helper_missing' + | 'unknown' + +const TERMINAL_UNAVAILABLE_MESSAGES: Record = { + dependency_missing: + 'Terminals are unavailable on this host: node-pty has no native binary for this platform. Install or rebuild it, or deploy a build that ships a prebuilt binary for this platform.', + libc_floor: + "This host's node-pty binary was built against a newer C library than the host provides, so the dynamic loader refuses it. Rebuild node-pty on this host, or deploy a build whose prebuilt binary matches this platform's libc.", + abi_mismatch: + "This host's node-pty binary was built for a different Node ABI than the running Node, so it cannot be loaded. Rebuild node-pty against this Node version.", + load_failed: 'Terminals are unavailable on this host: node-pty failed to load.', + load_crashed: + 'Terminals are unavailable on this host: loading node-pty terminated the probe process, which means the binary is incompatible with this host rather than merely missing.', + spawn_helper_missing: + "node-pty loaded, but its spawn-helper executable is missing or not executable, so every terminal spawn would fail. Reinstall node-pty on this host.", + unknown: 'Terminals are unavailable on this host, and the cause could not be determined.' +} + +export function terminalUnavailableMessage( + reason: RuntimeTerminalUnavailableReason, + detail?: string +): string { + const base = TERMINAL_UNAVAILABLE_MESSAGES[reason] + return detail ? `${base} (${detail})` : base +} + export type RuntimeDegradation = { - code: typeof BROWSER_UNAVAILABLE_ERROR_CODE - capability: 'browser.headless.v1' + /** + * Open vocabulary. New codes ship without a protocol bump, so a client must render + * `message` and must not switch exhaustively on this — the same contract the `reason` + * fields carry. + */ + code: typeof BROWSER_UNAVAILABLE_ERROR_CODE | typeof TERMINAL_UNAVAILABLE_ERROR_CODE + capability: 'browser.headless.v1' | typeof TERMINAL_PTY_DEGRADATION_CAPABILITY message: string /** * Machine-readable cause. Optional for mixed-version peers: absence means the host * predates structured causes, NOT that the cause is 'unconfigured'. */ - reason?: RuntimeBrowserUnavailableReason + reason?: RuntimeBrowserUnavailableReason | RuntimeTerminalUnavailableReason /** Underlying error text when the host has one. Diagnostic only; never load-bearing. */ detail?: string } From 07a1a32d7bc83226a9cd536269121cb593d30b66 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:20:59 -0700 Subject: [PATCH 4/5] test(orcad): pin the node-pty precondition to ground truth, not a prepared host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's test shard runs `vitest` directly, so `ensure-native-runtime --runtime=node` never prepares node-pty for the Node ABI — `degraded` is the correct verdict there, and asserting 'ok' encoded an environment the shard does not have. Asserting whatever it returned would be vacuous, so the expectation is now derived from an independent require() of node-pty. Verified it still bites: forcing the precondition to always report 'ok' fails the suite. --- src/main/orcad/node-pty-precondition.test.ts | 22 ++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/orcad/node-pty-precondition.test.ts b/src/main/orcad/node-pty-precondition.test.ts index 3854a5bd3b69..f5b567ae9ecc 100644 --- a/src/main/orcad/node-pty-precondition.test.ts +++ b/src/main/orcad/node-pty-precondition.test.ts @@ -112,7 +112,7 @@ describe('classifyNodePtyProbeResult', () => { it('reads past node\u2019s echoed source line when it can only use stderr', () => { const failure = probe({ stderr: - "[eval]:1\nprocess.dlopen({exports:{}},f);\n ^\n\nError: something specific went wrong\n" + '[eval]:1\nprocess.dlopen({exports:{}},f);\n ^\n\nError: something specific went wrong\n' }) expect(failure?.detail).toBe('Error: something specific went wrong') }) @@ -179,9 +179,21 @@ describe('checkNodePtyPrecondition', () => { expect(verdict).toMatchObject({ status: 'blocked', reason: 'dependency_missing' }) }) - it('passes against the node-pty this repo actually builds', () => { + it('agrees with whether node-pty actually loads on this host', () => { + // Why compared against ground truth rather than asserted as 'ok': CI's test shard + // runs `vitest` directly, so `ensure-native-runtime --runtime=node` never prepares + // node-pty for the Node ABI and `degraded` is the CORRECT verdict there. Asserting + // 'ok' encoded a prepared environment the shard does not have. Asserting "whatever + // it said" would be vacuous, so this pins the verdict to an independent check. + let loads = true + try { + createRequire(import.meta.url)('node-pty') + } catch { + loads = false + } + const verdict = checkNodePtyPrecondition({ prebuildsDir: null }) - expect(verdict.status).toBe('ok') + expect(verdict.status).toBe(loads ? 'ok' : 'degraded') expect(verdict.slot).toBe( process.platform === 'linux' ? `linux-${process.arch}-${verdict.abi.libc}` @@ -210,7 +222,9 @@ describe('checkNodePtyPrecondition', () => { const dir = stageNodePty() const abi = detectNativeHostAbi() const slot = - abi.libc === 'none' ? `${abi.platform}-${abi.arch}` : `${abi.platform}-${abi.arch}-${abi.libc}` + abi.libc === 'none' + ? `${abi.platform}-${abi.arch}` + : `${abi.platform}-${abi.arch}-${abi.libc}` const prebuildsDir = mkdtempSync(join(tmpdir(), 'orcad-prebuilds-')) temporaryDirs.push(prebuildsDir) mkdirSync(join(prebuildsDir, slot), { recursive: true }) From 38a4818b8f0237ca406aff980fef387c5ba1716d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:02:06 -0700 Subject: [PATCH 5/5] feat(orcad): run the terminal daemon, and the ops contract around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orcad declared `canRecoverPersistentLocalPtys: () => false` because it did not run the terminal daemon, so every restart, update and rollback SIGKILLed every running terminal — on the host whose selling point is that work survives the client going away. That is the one property `ssh-execution-boundary.md` recommends the peer model for. Item 4 — the daemon: - Port the launch path off electron: `daemon-init.ts`, `daemon-host-relocation.ts` and `observability/logs-directory.ts` now read the `AppEnvironment` port. Relocation additionally asks whether the app root is an asar archive rather than whether the build is packaged, so a Node host answering `isPackaged() === true` no longer walks into an Electron-only NSIS-escape path (same precedent as `parcel-watcher-entry-path.ts`). - `build-orcad.mjs` emits `daemon-entry.js` beside `orcad.js`, scans the forked children's metafiles for electron/node:sqlite, and load-checks the child under plain Node. - orcad spawns and adopts the daemon; shutdown disconnects and never kills it. `canRecoverPersistentLocalPtys` now reads the live provider and is false under degraded routing, where fresh terminals would die with the process. Item 3 — the ops contract (docs/reference/orcad-operations.md): - Bind policy: `--bind`, default loopback, pinned so neither `orca serve`'s wide default nor the connected-device widen can override it, and so a paired client cannot rebind the listener from outside. - Instance lock on the data root before profile load, scoped to the runtime role so it never refuses a restart that a live daemon makes worthwhile. - Supervision: exit codes a supervisor can act on (78 = do not retry), second-signal escalation, a shutdown deadline, and crash-loop containment on daemon respawn. - Health in the readiness payload: build hash, Node ABI, and a PTY self-test that spans both processes — the daemon spawns a real PTY in its own process and the verdict crosses its socket. Both bundle load-checks now assert on exit codes: these bundles are minified onto one line, so Node's uncaught-exception report echoes every string literal in the bundle and the previous message match passed against a bundle that never loaded. --- .gitignore | 1 + config/scripts/build-orcad.mjs | 125 +++++-- docs/reference/orcad-operations.md | 190 ++++++++++ .../daemon/daemon-entry-path-layouts.test.ts | 115 +++++++ src/main/daemon/daemon-entry.ts | 5 +- .../daemon/daemon-host-relocation.test.ts | 65 +++- src/main/daemon/daemon-host-relocation.ts | 32 +- .../daemon-init-child-readiness.test.ts | 1 - .../daemon-init-child-startup-failure.test.ts | 1 - .../daemon/daemon-init-dependency-mocks.ts | 15 +- .../daemon-init-endpoint-adoption.test.ts | 1 - src/main/daemon/daemon-init-fresh-import.ts | 13 + ...mon-init-live-session-preservation.test.ts | 1 - src/main/daemon/daemon-init-mock-types.ts | 1 + ...mon-init-packaged-bundle-staleness.test.ts | 1 - .../daemon-init-provider-installation.test.ts | 23 +- .../daemon-init-replacement-reporting.test.ts | 1 - .../daemon-init-restart-sequence.test.ts | 1 - .../daemon-init-wedged-daemon-grace.test.ts | 1 - src/main/daemon/daemon-init.ts | 95 ++++- .../daemon/daemon-respawn-throttle.test.ts | 95 +++++ src/main/daemon/daemon-respawn-throttle.ts | 84 +++++ src/main/daemon/daemon-spawner.ts | 24 ++ src/main/observability/logs-directory.ts | 34 +- src/main/orcad/main.ts | 15 +- src/main/orcad/orcad-bind-address.test.ts | 45 +++ src/main/orcad/orcad-bind-address.ts | 66 ++++ .../orcad/orcad-daemon-supervision.test.ts | 85 +++++ src/main/orcad/orcad-daemon-supervision.ts | 67 ++++ src/main/orcad/orcad-entry.ts | 107 +++++- src/main/orcad/orcad-health.test.ts | 152 ++++++++ src/main/orcad/orcad-health.ts | 155 +++++++++ src/main/orcad/orcad-instance-lock.test.ts | 151 ++++++++ src/main/orcad/orcad-instance-lock.ts | 324 ++++++++++++++++++ src/main/orcad/orcad-launch-contract.test.ts | 43 +++ .../runtime-rpc-websocket-bind-host.test.ts | 103 ++++++ src/main/runtime/runtime-rpc.ts | 32 +- src/main/server/serve-readiness.test.ts | 64 ++++ src/main/server/serve-readiness.ts | 24 +- 39 files changed, 2230 insertions(+), 128 deletions(-) create mode 100644 docs/reference/orcad-operations.md create mode 100644 src/main/daemon/daemon-entry-path-layouts.test.ts create mode 100644 src/main/daemon/daemon-respawn-throttle.test.ts create mode 100644 src/main/daemon/daemon-respawn-throttle.ts create mode 100644 src/main/orcad/orcad-bind-address.test.ts create mode 100644 src/main/orcad/orcad-bind-address.ts create mode 100644 src/main/orcad/orcad-daemon-supervision.test.ts create mode 100644 src/main/orcad/orcad-daemon-supervision.ts create mode 100644 src/main/orcad/orcad-health.test.ts create mode 100644 src/main/orcad/orcad-health.ts create mode 100644 src/main/orcad/orcad-instance-lock.test.ts create mode 100644 src/main/orcad/orcad-instance-lock.ts create mode 100644 src/main/orcad/orcad-launch-contract.test.ts diff --git a/.gitignore b/.gitignore index 8770f9b184b7..8b310ba432ad 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,7 @@ docs/** !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md +!docs/reference/orcad-operations.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/windows-process-enumeration.md !docs/reference/wsl-runner-verification.md diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index 05948acd529f..9a6bacd5eddc 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -22,6 +22,11 @@ const ENTRY = join(ROOT, 'src/main/orcad/main.ts') // looks for it in the app root. A deployment has no desktop out/main to fall back to. const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts') const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') +// Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process, +// and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every +// orcad restart would SIGKILL every running terminal. +const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts') +const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}` const OUT_FILE = join(OUT_DIR, 'orcad.js') const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME) @@ -64,20 +69,31 @@ if (process.platform !== 'win32') { chmodSync(AGENT_BROWSER_OUTPUT, 0o755) } -await build({ - entryPoints: [WATCHER_ENTRY], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: WATCHER_OUT_FILE, - external: EXTERNAL, - plugins: [externalNativeAddons], - minify: true, - sourcemap: false, - define: { 'process.env.NODE_ENV': '"production"' }, - logLevel: 'error' -}) +/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source + * directory under `outdir`, and both children must land flat beside orcad.js — that is where + * their runtime resolvers look for them. */ +function buildForkedChild(entryPoint, outfile) { + return build({ + entryPoints: [entryPoint], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile, + external: EXTERNAL, + plugins: [externalNativeAddons], + metafile: true, + minify: true, + sourcemap: false, + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'error' + }) +} + +const childResults = await Promise.all([ + buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE), + buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE) +]) const result = await build({ entryPoints: [ENTRY], @@ -95,29 +111,36 @@ const result = await build({ logLevel: 'error' }) -const output = Object.values(result.metafile.outputs).find((o) => o.entryPoint) +const output = Object.values(result.metafile.outputs).find( + (o) => o.entryPoint === 'src/main/orcad/main.ts' +) // Why check `original` and not just `path`: when electron is bundleable, esbuild // rewrites `path` to the resolved file under node_modules and the naive check passes // while the package is very much in the bundle. -const electronImporters = new Set() -for (const [file, info] of Object.entries(result.metafile.inputs)) { - for (const imported of info.imports ?? []) { - const specifier = imported.original ?? imported.path - if (specifier === 'electron' || specifier.startsWith('electron/')) { - electronImporters.add(file) - } - } -} -const sqliteImporters = new Set() -for (const [file, info] of Object.entries(result.metafile.inputs)) { - for (const imported of info.imports ?? []) { - const specifier = imported.original ?? imported.path - if (specifier === 'node:sqlite') { - sqliteImporters.add(file) +// Why both metafiles: the forked children ship in the same deployment and run under the +// same plain Node. A daemon-entry that reached electron would fail at fork time, on the +// path whose whole point is that terminals survive. +function collectImporters(metafiles, matches) { + const importers = new Set() + for (const metafile of metafiles) { + for (const [file, info] of Object.entries(metafile.inputs)) { + for (const imported of info.imports ?? []) { + if (matches(imported.original ?? imported.path)) { + importers.add(file) + } + } } } + return importers } +const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)] +const electronImporters = collectImporters( + metafiles, + (specifier) => specifier === 'electron' || specifier.startsWith('electron/') +) +const sqliteImporters = collectImporters(metafiles, (specifier) => specifier === 'node:sqlite') + const graphErrors = [] if (electronImporters.size > 0) { graphErrors.push( @@ -147,23 +170,53 @@ if (graphErrors.length > 0) { // dynamic require, a missing native, a top-level throw. The plain-node-entry-guard // smoke-loads its entries for exactly this reason, and orcad cannot join that guard // because it is an esbuild artifact rather than a rollup input. + // Why an exit code and not a message match: these bundles are minified onto one line, so + // Node's uncaught-exception report echoes that whole line — which contains every string + // literal in the bundle. A crash therefore "matches" any expected message, and a textual + // assertion passes against a bundle that never loaded. const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], { encoding: 'utf8', timeout: 60_000 }) const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}` - if ( - smoke.error || - smoke.signal || - !/Unknown argument: --orcad-smoke-load-check/.test(smokeOutput) - ) { + if (smoke.error || smoke.signal || smoke.status !== 0) { console.error( `[build-orcad] the bundle did not load under plain Node.\n` + - `Expected argv rejection, got signal=${smoke.signal ?? 'none'} ` + + `Expected a clean load-check exit, got status=${smoke.status ?? 'none'} ` + + `signal=${smoke.signal ?? 'none'} ` + `error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}` ) process.exitCode = 1 } + // Why require + parseArgs and not a real daemon: requiring the bundle evaluates every + // top-level import, and calling its exported argv parser proves the entry's own code is + // there rather than a graph that merely resolved. Booting one would need a socket, a + // token and a PTY — `smoke:orcad-terminal` does that end to end, through orcad. + // The verdict is carried by the exit code for the same minification reason as above. + const daemonSmoke = spawnSync( + process.execPath, + [ + '-e', + `const mod = require(${JSON.stringify(DAEMON_OUT_FILE)})\n` + + `if (typeof mod.parseArgs !== 'function') { process.exit(3) }\n` + + `try { mod.parseArgs([]); process.exit(4) } catch { process.exit(0) }` + ], + { + encoding: 'utf8', + timeout: 60_000, + env: { ...process.env, ORCA_DAEMON_ENTRY_LOAD_CHECK: '1' } + } + ) + const daemonSmokeOutput = `${daemonSmoke.stdout ?? ''}${daemonSmoke.stderr ?? ''}` + if (daemonSmoke.error || daemonSmoke.signal || daemonSmoke.status !== 0) { + console.error( + `[build-orcad] the daemon child did not load under plain Node.\n` + + `Expected a clean load check, got status=${daemonSmoke.status ?? 'none'} ` + + `signal=${daemonSmoke.signal ?? 'none'} ` + + `error=${daemonSmoke.error?.message ?? 'none'}\n${daemonSmokeOutput.slice(0, 2000)}` + ) + process.exitCode = 1 + } const watcherFailure = await smokeLoadWatcherChild() if (watcherFailure) { console.error( diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md new file mode 100644 index 000000000000..3ef3fdea37a4 --- /dev/null +++ b/docs/reference/orcad-operations.md @@ -0,0 +1,190 @@ +# Running orcad + +`orcad` is the Orca runtime served from plain Node. This is the contract between it and +whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its +readiness payload actually proves. + +Design background: `docs/design/shipping-orcad.html` §00c and §04. + +## Two long-lived processes, not one + +A deployment is **orcad** plus **the terminal daemon**. + +| | orcad | terminal daemon | +| --- | --- | --- | +| Started by | the supervisor | orcad, detached | +| Owns | RPC, git, worktrees, persistence | every local PTY | +| Lifetime | one supervised run | **outlives orcad** | +| Endpoint | `ws://:` | `/daemon/daemon-v.sock` | + +The daemon outliving orcad is the property the whole peer model is recommended for +(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime +restart, so a restart, an update or a rollback does not destroy running work. Everything +below exists to keep that true. + +**Consequence for supervision:** orcad's shutdown path calls `disconnectDaemon()`, never +`shutdownDaemon()`. A supervisor that reaps orcad's whole process group — systemd's +`KillMode=control-group` — kills the daemon too and turns every restart back into data loss. +Use `KillMode=mixed` (the default) or `process`, and never `--send-sigkill` on the group. + +## Bind policy + +`--bind `, **default `127.0.0.1`**. + +Only literal IPs are accepted; hostnames are refused because DNS would decide which +interface got bound. `localhost` maps to `127.0.0.1`. `0.0.0.0` / `::` are the explicit +opt-ins to network reach, and the startup log says so on every launch. + +The bind is **pinned**, not defaulted. Two things widen the desktop's listener on their own — +`orca serve`'s wide default, and a startup where some device has connected before — and an +unattended host's exposure must be exactly what the operator asked for on every launch. A +mobile pairing offer, which normally rebinds to all interfaces, is refused while the bind is +pinned to loopback and reports `network_exposure_failed` rather than advertising an endpoint +nothing can reach. + +Under the shipping design a client reaches a remote orcad over an SSH local port-forward, so +loopback is the correct default and the pairing credential travels over SSH. + +## Data root and the instance lock + +The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`. + +Before the profile index or the store is touched, orcad takes `/orcad.lock`. +It refuses to start when: + +| Code | Meaning | +| --- | --- | +| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) | +| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened | +| `orcad_instance_lock_held` | another live orcad owns this root | +| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity | +| `orcad_data_root_unusable` | the root cannot be created, stat'd or written | + +A root that is merely too permissive and that we own is tightened to `0700` rather than +refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal +is a private root, and refusing when we could just fix it helps nobody. We refuse when the +permissions are not ours to fix. Windows is exempt from the owner and mode checks: ACLs are +not expressible as a POSIX mode, and `statSync().mode` there reports a synthesized one. + +A dead holder's record is reclaimed (PID plus process start time, so a recycled PID does not +read as alive). A record belonging to a different identity is never reclaimed. + +**The lock scopes one role — who is the runtime.** It deliberately says nothing about the +daemon, which lives under `/daemon` and fences its own endpoint with its own PID +record. A lock that asked "is any process using this root" would refuse exactly the restarts +a live daemon makes worthwhile. + +## Supervision + +### Who supervises orcad + +An external supervisor (systemd, launchd, a process manager). orcad conforms to it: + +- **Readiness.** One JSON line on stdout (`--json`), `type: "orca_server_ready"`, published + after the listener is bound and the daemon verdict is in. There is no separate readiness + socket; the line is the signal. Set the supervisor's start timeout generously — the daemon + launch has its own retries and can take tens of seconds on a cold host. +- **Shutdown.** `SIGTERM` or `SIGINT` starts a graceful stop. A **second** signal exits + immediately with code 1 rather than being swallowed — a supervisor's second signal means + its first deadline elapsed, and waiting silently is what turns a stop into a `SIGKILL`, + the one teardown that skips the daemon handoff. orcad also imposes its own 15s deadline + and exits 1, so the failure stays attributable instead of arriving as an unlogged kill. +- **Exit codes.** + + | Code | Meaning | Supervisor should | + | --- | --- | --- | + | 0 | clean shutdown | restart per policy | + | 1 | startup or shutdown failure | restart with backoff | + | 78 | configuration fault (bind address, data root, instance lock) | **not** restart | + + 78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data + root owned by someone else is a restart-spin, not a recovery. +- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract + to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes + its own NDJSON lifecycle log to `/logs/daemon.log` (suppressed by + `ORCA_DIAGNOSTICS_DISABLED=1`). Rotation of that file is not implemented — see + [What is not covered](#what-is-not-covered). + +### orcad supervising the daemon + +- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID + record, token and socket under `/daemon`. +- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not + replaced, unless it is unhealthy, foreign, or built from a superseded bundle *and* owns no + live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers + to live work. +- **Restart.** The adapter respawns the daemon on death, transparently to callers. +- **Crash-loop containment.** At most **5 launches per 60s rolling window** per orcad run; + past that, launches are refused with `daemon_crash_loop` and terminals fail with that + message instead of the process forking forever. The window slides, so a repaired host + recovers without restarting orcad. An operator-initiated daemon restart clears it — that + is the deliberate "try again". +- **No macOS login-session watch.** That watch retires the daemon when the spawning GUI login + session dies. An orcad daemon must survive its SSH session ending. +- **Shutdown.** orcad never stops the daemon. A daemon that was never adopted retires itself + after its adoption window; an adopted one stays resident (see Decommissioning). + +### Decommissioning + +The daemon outliving orcad is deliberate, so stopping orcad does **not** leave the host with +zero Orca processes. A daemon that has been adopted stays resident after its runtime +disconnects — that is what makes the next start a reattach rather than a cold restore. To +retire a host completely, stop orcad and then stop the daemon named by +`health.terminalDaemon.pid`, or delete the data root and let the endpoint go stale. + +## Health + +The readiness payload carries a `health` object: + +``` +buildHash sha256 (16 hex) of the running orcad bundle — build identity that a version + string cannot give, so a rollback that did not replace the file is visible +buildVersion ORCA_VERSION +nodeVersion / nodeAbi process.versions.node / .modules — the ABI native addons must match +platform / arch / pid +terminalDaemon: + state live | degraded | absent + ownsFreshSessions whether NEW terminals are daemon-owned, i.e. survive an orcad restart + pid the live daemon's pid, from its own PID record + buildVersion the build the LIVE daemon was forked from (may legitimately predate + this orcad after an update — reporting orcad's version for both would + hide exactly that) + entryPath / protocolVersion + selfTest { ok, coverage, verdict, durationMs } +``` + +### What the self-test proves + +`selfTest` runs `checkDaemonHealth` against the daemon's socket. It is green only when the +daemon **opened its socket, completed the protocol handshake, and ran `ptySpawnHealth` — a +real short-lived PTY spawned inside the daemon's own process**. It therefore spans both +processes: orcad drives it, the daemon performs it, the verdict crosses the socket. + +- `coverage: 'pty-spawn'` — the full round trip above. +- `coverage: 'handshake'` — **win32 only**, where `checkPtySpawnHealth` returns without + spawning anything. A green verdict there covers the handshake and nothing more. It is + reported separately rather than folded into `ok` so nobody reads it as a PTY round trip. + +`state` is `live` only when the self-test passed **and** `ownsFreshSessions` is true. A +daemon that answers but has fallen back to local spawning for new terminals is `degraded`, +because those terminals die with orcad. A daemon that answered and then failed its spawn +probe is also `degraded`, not `absent`: it still holds live sessions, and calling those +exited would be the verdict `ssh-execution-boundary.md` forbids guessing. + +## What is not covered + +Named here so nothing reads as implemented that is not: + +- **A continuous health endpoint.** `health` is published once, in the readiness payload. A + supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same + `collectOrcadHealth()`; that surface does not exist yet. +- **libc slot.** §04 asks for it in the health payload. It belongs to the native strategy + (plan item 5), which owns libc detection; there is no honest value to publish until then. +- **`degradations[]`.** Plan item 2's contract, not this one. +- **Credential administration** (list / revoke / rotate devices, expiring pending offers, + structured security logging) — §04, not delivered here. +- **Pinned-port fail-closed.** A pinned `--port` still falls back to an OS-assigned port on + conflict. +- **Reconciling `webClientUrl` with reachability** under the loopback default. +- **State-schema rollback rules.** +- **Daemon log rotation.** `/logs/daemon.log` grows unbounded. diff --git a/src/main/daemon/daemon-entry-path-layouts.test.ts b/src/main/daemon/daemon-entry-path-layouts.test.ts new file mode 100644 index 000000000000..b960cbf9d8fd --- /dev/null +++ b/src/main/daemon/daemon-entry-path-layouts.test.ts @@ -0,0 +1,115 @@ +/** + * Which daemon-entry.js the launcher forks, per deployment layout. + * + * The layout that matters here is orcad's: a packaged host with NO asar, whose bundle root + * holds `orcad.js` and `daemon-entry.js` side by side (config/scripts/build-orcad.mjs emits + * exactly that). Resolving against `out/main` there would fork a path that does not exist, + * and the failure would surface as "terminals do not persist" rather than as a missing file. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { FAKE_APP_PATH, FAKE_DAEMON_ENTRY_PATH } from './daemon-init-test-harness' + +const { + getAppPathMock, + isPackagedMock, + probeSocketExistsMock, + forkMock, + checkDaemonHealthMock, + spawnerInstances, + importFresh, + installDefaultNetConnectStub, + moduleFactories +} = await vi.hoisted(async () => + (await import('./daemon-init-test-harness')).createDaemonInitMocks() +) + +vi.mock('fs', () => moduleFactories.fs()) +vi.mock('child_process', async (importOriginal) => + moduleFactories.childProcess(await importOriginal>()) +) +vi.mock('net', () => moduleFactories.net()) +vi.mock('./daemon-health', () => moduleFactories.daemonHealth()) +vi.mock('./daemon-pid-identity', () => moduleFactories.daemonPidIdentity()) +vi.mock('./daemon-tcc-attribution', () => moduleFactories.daemonTccAttribution()) +vi.mock('./daemon-bundle-staleness', () => moduleFactories.daemonBundleStaleness()) +vi.mock('./daemon-stale-kill', () => moduleFactories.daemonStaleKill()) +vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartTime()) +vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse()) +vi.mock('./client', () => moduleFactories.client()) +vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent()) +vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner()) +vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter()) +vi.mock('../ipc/pty', () => moduleFactories.ipcPty()) + +const ASAR_APP_PATH = join('/packaged', 'resources', 'app.asar') +const ASAR_UNPACKED_ENTRY = join( + '/packaged', + 'resources', + 'app.asar.unpacked', + 'out', + 'main', + 'daemon-entry.js' +) +const ORCAD_ROOT = join('/opt', 'orcad') +const ORCAD_ADJACENT_ENTRY = join(ORCAD_ROOT, 'daemon-entry.js') + +/** Drive one launch under the given layout and return the entry path that was forked. */ +async function forkedDaemonEntryPath(layout: { + appPath: string + isPackaged: boolean + existingEntry?: string +}): Promise { + // Why after importFresh: it resets these mocks to their defaults on every fresh import. + const mod = await importFresh() + getAppPathMock.mockReturnValue(layout.appPath) + isPackagedMock.mockReturnValue(layout.isPackaged) + probeSocketExistsMock.mockImplementation((p?: string) => p === layout.existingEntry) + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise + forkMock.mockImplementationOnce(() => { + throw new Error('stop after entry resolution') + }) + await expect(launcher('/fake/socket', '/fake/token')).rejects.toThrow( + 'stop after entry resolution' + ) + return forkMock.mock.calls.at(-1)?.[0] as string +} + +describe('daemon entry path per deployment layout', () => { + beforeEach(() => { + installDefaultNetConnectStub() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('resolves the dev checkout entry under out/main', async () => { + expect(await forkedDaemonEntryPath({ appPath: FAKE_APP_PATH, isPackaged: false })).toBe( + FAKE_DAEMON_ENTRY_PATH + ) + }) + + it('redirects a packaged Electron asar root to app.asar.unpacked', async () => { + expect(await forkedDaemonEntryPath({ appPath: ASAR_APP_PATH, isPackaged: true })).toBe( + ASAR_UNPACKED_ENTRY + ) + }) + + it('forks the entry beside orcad.js on a packaged host with no asar', async () => { + // orcad answers isPackaged() true; the question the resolver must ask is whether the app + // root is an asar archive, not whether the build is packaged. + expect( + await forkedDaemonEntryPath({ + appPath: ORCAD_ROOT, + isPackaged: true, + existingEntry: ORCAD_ADJACENT_ENTRY + }) + ).toBe(ORCAD_ADJACENT_ENTRY) + }) +}) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index addfa50b16a0..72537f64cbf7 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -327,8 +327,9 @@ async function main(): Promise { warmWindowsConptyOnce() } -// Only auto-run when executed directly (not imported for testing) -const isDirectExecution = !process.env.VITEST +// Only auto-run when executed directly (not imported for testing, or for the build guard's +// load check — see config/scripts/build-orcad.mjs). +const isDirectExecution = !process.env.VITEST && !process.env.ORCA_DAEMON_ENTRY_LOAD_CHECK if (isDirectExecution) { main().catch((err) => { console.error('[daemon] Fatal:', err) diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index 1d3ab98db906..78e4787c0f61 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -9,20 +9,30 @@ import { } from 'node:fs' import os from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' -// Mutable Electron app stub, hoisted so the vi.mock factory closes over it. -const { electronApp } = vi.hoisted(() => ({ - electronApp: { - isPackaged: true, - userDataPath: '', - version: '9.9.9', - getPath: (): string => electronApp.userDataPath, - getVersion: (): string => electronApp.version - } -})) +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' + +// Mutable host stub. Relocation now reads the AppEnvironment port rather than electron's +// `app`, so orcad's daemon launch path can resolve without Electron in the graph. +const hostApp = { + isPackaged: true, + userDataPath: '', + appPath: '', + version: '9.9.9' +} -vi.mock('electron', () => ({ app: electronApp })) +function installHostApp(): void { + setAppEnvironment({ + getPath: () => hostApp.userDataPath, + getAppPath: () => hostApp.appPath, + getVersion: () => hostApp.version, + isPackaged: () => hostApp.isPackaged, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + } as AppEnvironment) +} import { buildDaemonHostManifest, @@ -88,9 +98,11 @@ beforeEach(() => { mkdirSync(localAppDataDir, { recursive: true }) process.env.LOCALAPPDATA = localAppDataDir buildInstallFixture(installDir) - electronApp.isPackaged = true - electronApp.userDataPath = userDataDir - electronApp.version = '9.9.9' + hostApp.isPackaged = true + hostApp.userDataPath = userDataDir + hostApp.appPath = join(installDir, 'resources', 'app.asar') + hostApp.version = '9.9.9' + installHostApp() setProcessProp('platform', 'win32') setProcessProp('execPath', join(installDir, 'Orca.exe')) setProcessProp('resourcesPath', join(installDir, 'resources')) @@ -214,6 +226,18 @@ describe('materializeRelocatedDaemonHost', () => { expect(materializeRelocatedDaemonHost()).toBeNull() expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) }) + + it('does nothing for a packaged host with no asar root (orcad on win32)', () => { + // orcad answers isPackaged() true — it is a shipped build — but it is plain Node: no + // asar, no resourcesPath, and no NSIS updater to escape. Relocation staging a copy of + // an Electron tree that is not there is the isPackaged-honesty defect, and it would + // silently produce a null host on a path whose failures are meant to be visible. + hostApp.appPath = join(installDir, 'resources', 'app') + installHostApp() + expect(materializeRelocatedDaemonHost()).toBeNull() + expect(getRelocatedDaemonHost()).toBeNull() + expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) + }) }) describe('getRelocatedDaemonHost', () => { @@ -249,6 +273,17 @@ describe('pruneOldDaemonHosts', () => { expect(existsSync(join(root, '2.0.0'))).toBe(true) expect(existsSync(join(root, '1.0.0'))).toBe(false) }) + + it('reclaims nothing for a packaged host with no asar root (orcad on win32)', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + hostApp.appPath = join(installDir, 'resources', 'app') + installHostApp() + pruneOldDaemonHosts(new Set()) + // A Node host owns no daemon-host tree, so deleting under it would be reaching into a + // directory layout it never created. + expect(existsSync(join(root, '1.0.0'))).toBe(true) + }) }) describe('collectPinnedDaemonVersions', () => { diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 4242b292de14..79e8bcf0ad77 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -10,7 +10,7 @@ import { writeFileSync } from 'node:fs' import { dirname, join, win32 as winPath } from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { parseDaemonPidFile } from './daemon-pid-file-parse' import { startTimeMatches } from './daemon-process-start-time' @@ -85,9 +85,27 @@ function resolveEntrySourcePath(resourcesPath: string): string { return join(unpackedRoot, 'out', 'main', 'daemon-entry.js') } +/** + * Whether this process is a packaged ELECTRON app on win32 — the only shape relocation + * addresses, because what it escapes is the NSIS updater's kill zone. + * + * Why asar and not isPackaged alone: orcad answers isPackaged() true (it is a shipped build, + * not a dev checkout) while having no asar, no resourcesPath and no NSIS installer. Asking + * whether the app root is an asar archive is the same honesty fix the watcher path uses, and + * it keeps a Node host from staging a copy of an Electron tree it does not have. + */ +function isPackagedElectronWin32(): boolean { + const environment = getAppEnvironment() + return ( + process.platform === 'win32' && + environment.isPackaged() && + environment.getAppPath().includes('app.asar') + ) +} + // Relocation inputs from the live packaged process, or null when it doesn't apply (non-win32, dev, or missing resourcesPath). function collectDaemonHostSources(): DaemonHostSources | null { - if (process.platform !== 'win32' || !app.isPackaged) { + if (!isPackagedElectronWin32()) { return null } const resourcesPath = process.resourcesPath @@ -206,7 +224,7 @@ function hostRootDir(): string { const base = typeof localAppData === 'string' && localAppData.length > 0 ? join(localAppData, LOCAL_HOST_ROOT_NAME) - : app.getPath('userData') + : getAppEnvironment().getPath('userData') return join(base, HOST_SUBDIR) } @@ -219,7 +237,7 @@ export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { if (!sources) { return null } - const version = app.getVersion() + const version = getAppEnvironment().getVersion() const dest = join(hostRootDir(), version) const marker = readMarker(dest) if (!marker || marker.version !== version) { @@ -246,7 +264,7 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { if (!sources) { return null } - const version = app.getVersion() + const version = getAppEnvironment().getVersion() const root = hostRootDir() const dest = join(root, version) const staging = join(root, `${version}.staging-${randomBytes(6).toString('hex')}`) @@ -319,10 +337,10 @@ export function collectPinnedDaemonVersions(runtimeDir: string): Set { * Best-effort — never throws; a locked/staging dir is retried on a future launch. */ export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { - if (process.platform !== 'win32' || !app.isPackaged) { + if (!isPackagedElectronWin32()) { return } - const version = app.getVersion() + const version = getAppEnvironment().getVersion() const root = hostRootDir() let entries try { diff --git a/src/main/daemon/daemon-init-child-readiness.test.ts b/src/main/daemon/daemon-init-child-readiness.test.ts index 132727fff5f8..60d3c9c596d5 100644 --- a/src/main/daemon/daemon-init-child-readiness.test.ts +++ b/src/main/daemon/daemon-init-child-readiness.test.ts @@ -16,7 +16,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-child-startup-failure.test.ts b/src/main/daemon/daemon-init-child-startup-failure.test.ts index 182307d075be..27d23a400d2b 100644 --- a/src/main/daemon/daemon-init-child-startup-failure.test.ts +++ b/src/main/daemon/daemon-init-child-startup-failure.test.ts @@ -14,7 +14,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index 40e7ffa1ae6e..d920a13866ed 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -17,10 +17,7 @@ export type { MockAdapter, MockSpawner } from './daemon-init-mock-types' /** The module objects each test file's own hoisted `vi.mock` factories return. */ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { const { - getPathMock, - getAppPathMock, forkMock, - isPackagedMock, probeSocketExistsMock, writeFileSyncMock, readFileSyncMock, @@ -62,6 +59,7 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { readonly launcher: unknown readonly ensureRunning: Mock readonly resetHandle: Mock + readonly resetRespawnWindow: Mock readonly shutdown: Mock readonly getHandle: Mock private socketCounter: number @@ -99,6 +97,7 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { } }) this.resetHandle = vi.fn() + this.resetRespawnWindow = vi.fn() this.shutdown = vi.fn(async () => {}) this.getHandle = vi.fn(() => this.handle) spawnerInstances.push(this as unknown as MockSpawner) @@ -159,16 +158,6 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { } return { - electron: () => ({ - app: { - get isPackaged() { - return isPackagedMock() - }, - getPath: getPathMock, - getAppPath: getAppPathMock, - getVersion: () => '1.2.3' - } - }), fs: () => ({ mkdirSync: vi.fn<(...args: unknown[]) => void>(), existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'), diff --git a/src/main/daemon/daemon-init-endpoint-adoption.test.ts b/src/main/daemon/daemon-init-endpoint-adoption.test.ts index 2566ba0996e1..5503188d4305 100644 --- a/src/main/daemon/daemon-init-endpoint-adoption.test.ts +++ b/src/main/daemon/daemon-init-endpoint-adoption.test.ts @@ -25,7 +25,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-fresh-import.ts b/src/main/daemon/daemon-init-fresh-import.ts index 5515e5d213e0..1d4f35d655cc 100644 --- a/src/main/daemon/daemon-init-fresh-import.ts +++ b/src/main/daemon/daemon-init-fresh-import.ts @@ -1,9 +1,11 @@ import { vi } from 'vitest' +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import type { DaemonInitMockState } from './daemon-init-test-harness' /** Resets every mock plus the module registry, then re-imports daemon-init so its module-level spawner/adapter/restartInFlight start fresh. */ export async function importFreshDaemonInit(state: DaemonInitMockState) { const { + getPathMock, getAppPathMock, isPackagedMock, probeSocketExistsMock, @@ -108,6 +110,17 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { launchedStartedAtMs.current = 1_000_000 getProcessStartedAtMsMock.mockReset() getProcessStartedAtMsMock.mockReturnValue(1_000_000) + // Why the real port rather than a module mock: daemon-init reads AppEnvironment, whose + // installed instance is anchored to a realm symbol precisely so it survives resetModules. + setAppEnvironment({ + getPath: getPathMock, + getAppPath: getAppPathMock, + getVersion: () => '1.2.3', + isPackaged: isPackagedMock, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + } as unknown as AppEnvironment) // Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer. return import('./daemon-init') } diff --git a/src/main/daemon/daemon-init-live-session-preservation.test.ts b/src/main/daemon/daemon-init-live-session-preservation.test.ts index 06c926386b92..2a3eece99372 100644 --- a/src/main/daemon/daemon-init-live-session-preservation.test.ts +++ b/src/main/daemon/daemon-init-live-session-preservation.test.ts @@ -23,7 +23,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index c71c19d7e4f0..8c8b805740f9 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -4,6 +4,7 @@ import type { Mock } from 'vitest' export type MockSpawner = { ensureRunning: Mock resetHandle: Mock + resetRespawnWindow: Mock shutdown: Mock getHandle: Mock launcher: unknown diff --git a/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts b/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts index f3ee88e7b705..6683e627e6d4 100644 --- a/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts +++ b/src/main/daemon/daemon-init-packaged-bundle-staleness.test.ts @@ -24,7 +24,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index e8038894d69a..ceb7e9dfa696 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -28,7 +28,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) @@ -255,6 +254,28 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(adapterInstances[0].listProcesses).toHaveBeenCalled() }) + it('answers daemonOwnsFreshPersistentPtys honestly for each installed provider', async () => { + // What orcad publishes as `canRecoverPersistentLocalPtys` and as the readiness health + // verdict. Answering true under degraded routing would advertise recovery for terminals + // that die with the runtime process. + const mod = await importFresh() + expect(mod.daemonOwnsFreshPersistentPtys()).toBe(false) + + await mod.initDaemonPtyProvider() + expect(mod.daemonOwnsFreshPersistentPtys()).toBe(true) + + const degraded = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/degraded-socket', + tokenPath: '/fake/degraded-token', + mode: 'degraded-new-pty-fallback' + })) + await degraded.initDaemonPtyProvider() + const { DegradedDaemonPtyProvider } = await import('./degraded-daemon-pty-provider') + expect(degraded.getDaemonProvider()).toBeInstanceOf(DegradedDaemonPtyProvider) + expect(degraded.daemonOwnsFreshPersistentPtys()).toBe(false) + }) + it('rechecks the preserved daemon endpoint before recovering fresh-spawn routing', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ diff --git a/src/main/daemon/daemon-init-replacement-reporting.test.ts b/src/main/daemon/daemon-init-replacement-reporting.test.ts index 5cc3f33a02a2..92bed823fb33 100644 --- a/src/main/daemon/daemon-init-replacement-reporting.test.ts +++ b/src/main/daemon/daemon-init-replacement-reporting.test.ts @@ -26,7 +26,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-restart-sequence.test.ts b/src/main/daemon/daemon-init-restart-sequence.test.ts index 5f1468342ac2..71417db986c6 100644 --- a/src/main/daemon/daemon-init-restart-sequence.test.ts +++ b/src/main/daemon/daemon-init-restart-sequence.test.ts @@ -24,7 +24,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts b/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts index 7ad1b96abb7a..18debd1342af 100644 --- a/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts +++ b/src/main/daemon/daemon-init-wedged-daemon-grace.test.ts @@ -18,7 +18,6 @@ const { (await import('./daemon-init-test-harness')).createDaemonInitMocks() ) -vi.mock('electron', () => moduleFactories.electron()) vi.mock('fs', () => moduleFactories.fs()) vi.mock('child_process', async (importOriginal) => moduleFactories.childProcess(await importOriginal>()) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index d6c4240b19b2..77edbed6027d 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -2,7 +2,7 @@ restart, teardown); the "swap the provider atomically" invariant keeps restart + singletons co-located. */ import { join } from 'node:path' import { randomUUID } from 'node:crypto' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import { mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs' import { fork, type ChildProcess } from 'node:child_process' import { connect } from 'node:net' @@ -36,7 +36,7 @@ import { import { getDaemonLaunchIdentity } from './daemon-pid-identity' import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness' import { killStaleDaemon } from './daemon-stale-kill' -import { parseDaemonPidFile } from './daemon-pid-file-parse' +import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' import { collectPinnedDaemonVersions, materializeRelocatedDaemonHost, @@ -102,21 +102,25 @@ let adapter: DaemonProvider | null = null let restartInFlight: Promise | null = null function getRuntimeDir(): string { - const dir = join(app.getPath('userData'), 'daemon') + const dir = join(getAppEnvironment().getPath('userData'), 'daemon') mkdirSync(dir, { recursive: true }) return dir } function getHistoryDir(): string { - const dir = join(app.getPath('userData'), 'terminal-history') + const dir = join(getAppEnvironment().getPath('userData'), 'terminal-history') mkdirSync(dir, { recursive: true }) return dir } function getDaemonEntryPath(): string { - const appPath = app.getAppPath() - // Why: packaged app.getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable. - const basePath = app.isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const appPath = getAppEnvironment().getAppPath() + // Why: packaged getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable. + // Why asar and not isPackaged: orcad is a packaged non-Electron host whose bundle root holds + // orcad.js and daemon-entry.js side by side with no asar to redirect (see parcel-watcher-entry-path.ts). + const basePath = appPath.includes('app.asar') + ? appPath.replace('app.asar', 'app.asar.unpacked') + : appPath const directEntryPath = join(basePath, 'daemon-entry.js') if (existsSync(directEntryPath)) { return directEntryPath @@ -124,6 +128,12 @@ function getDaemonEntryPath(): string { return join(basePath, 'out', 'main', 'daemon-entry.js') } +// macOS TCC attribution pins the daemon to a packaged app bundle; there is none on a Node host. +function resolvePackagedDarwinAppVersion(): string | null { + const environment = getAppEnvironment() + return process.platform === 'darwin' && environment.isPackaged() ? environment.getVersion() : null +} + // Why: pass a log-file arg so field failures are diagnosable, but honor the ORCA_DIAGNOSTICS_DISABLED privacy switch. function daemonLogArgs(): string[] { const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase() @@ -519,12 +529,12 @@ function createOutOfProcessLauncher( entryPath ) const stalePackagedBundle = - app.isPackaged && + getAppEnvironment().isPackaged() && (await isDaemonStaleForCurrentBundle( runtimeDir, socketPath, tokenPath, - app.getVersion() + getAppEnvironment().getVersion() )) if (identity === 'mismatch' || stalePackagedBundle) { // Why: replacing a healthy daemon kills its child PTYs; defer code freshness until no live sessions would be lost. @@ -662,7 +672,7 @@ function createOutOfProcessLauncher( trackDaemonReplaced(pendingReplacement.reason, pendingReplacement.liveSessionCount) } - const userDataPath = app.getPath('userData') + const userDataPath = getAppEnvironment().getPath('userData') // Why: on win32 packaged, stage a daemon-host copy in userData so its image escapes the NSIS updater's kill zone; lazy so it's off first-paint. Fail-open: null → in-dir host. const relocatedHost = materializeRelocatedDaemonHost() // Fork the relocated entry when available; otherwise the install-dir entry. @@ -681,7 +691,7 @@ function createOutOfProcessLauncher( '--entry-path', entryPath, '--app-version', - app.getVersion(), + getAppEnvironment().getVersion(), '--spawner-exec-path', process.execPath, ...(macosLoginSessionWatch ? ['--login-session-watch'] : []), @@ -699,7 +709,7 @@ function createOutOfProcessLauncher( env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', - // Why: the detached plain-Node daemon can't call app.getPath(), but shell rcfiles must live outside swept tmp. + // Why: the detached plain-Node daemon has no AppEnvironment, but shell rcfiles must live outside swept tmp. ORCA_USER_DATA_PATH: userDataPath } } @@ -962,7 +972,7 @@ export async function initDaemonPtyProvider( pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir, - packagedAppVersion: process.platform === 'darwin' && app.isPackaged ? app.getVersion() : null, + packagedAppVersion: resolvePackagedDarwinAppVersion(), historyPath: getHistoryDir(), // Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying. respawn: async (reason: DaemonRespawnReason) => { @@ -1066,6 +1076,60 @@ async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise< } // Why: a narrow getter (not a raw export) keeps the "swap on restart" invariant in one place (replaceDaemonProvider). +/** + * Whether the installed provider is a daemon that will own FRESH terminals too. + * + * Why not `getDaemonProvider() !== null`: DegradedDaemonPtyProvider routes the daemon's + * EXISTING sessions to the daemon but spawns new ones on the in-process local provider, so + * those die with this process. A host that answered "I can recover persistent local PTYs" + * from that state would be advertising recovery for terminals that cannot be recovered. + */ +export function daemonOwnsFreshPersistentPtys(): boolean { + return adapter !== null && !(adapter instanceof DegradedDaemonPtyProvider) +} + +/** Endpoint coordinates of the daemon this process installed, for out-of-band health probes. */ +export type DaemonEndpointFacts = { + runtimeDir: string + socketPath: string + tokenPath: string + pidPath: string + protocolVersion: number +} + +export function getDaemonEndpointFacts(): DaemonEndpointFacts | null { + if (!adapter) { + return null + } + const runtimeDir = getRuntimeDir() + return { + runtimeDir, + socketPath: getDaemonSocketPath(runtimeDir), + tokenPath: getDaemonTokenPath(runtimeDir), + pidPath: getDaemonPidPath(runtimeDir), + protocolVersion: PROTOCOL_VERSION + } +} + +/** + * What the live daemon's own PID record says about the build it was forked from. + * + * Why the record and not this process's version: the daemon deliberately outlives the + * runtime, so after an update the two can legitimately disagree — and a health surface that + * reported orcad's version for both would hide exactly that. + */ +export function readDaemonPidRecord(): ParsedDaemonPid | null { + const facts = getDaemonEndpointFacts() + if (!facts) { + return null + } + try { + return parseDaemonPidFile(readFileSync(facts.pidPath, 'utf8')) + } catch { + return null + } +} + export function getDaemonProvider(): DaemonProvider | null { return adapter } @@ -1154,6 +1218,9 @@ async function runRestartDaemon(): Promise { } const runtimeDir = getRuntimeDir() + // An operator asking for a restart is the deliberate "try again" that clears crash-loop + // containment; without this a wedged host could never be recovered from the UI. + currentSpawner.resetRespawnWindow() const currentOnly = getCurrentDaemonAdapter(currentAdapter) const legacyAdapters = getLegacyDaemonAdapters(currentAdapter) @@ -1198,7 +1265,7 @@ async function runRestartDaemon(): Promise { pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir, - packagedAppVersion: process.platform === 'darwin' && app.isPackaged ? app.getVersion() : null, + packagedAppVersion: resolvePackagedDarwinAppVersion(), historyPath: getHistoryDir(), respawn: async (reason: DaemonRespawnReason) => { // Why: attribute rather than emit — the launcher below is the one that completes the diff --git a/src/main/daemon/daemon-respawn-throttle.test.ts b/src/main/daemon/daemon-respawn-throttle.test.ts new file mode 100644 index 000000000000..70806f999e84 --- /dev/null +++ b/src/main/daemon/daemon-respawn-throttle.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest' +import { DaemonSpawner, type DaemonProcessHandle } from './daemon-spawner' +import { DaemonCrashLoopError, DaemonRespawnThrottle } from './daemon-respawn-throttle' + +describe('DaemonRespawnThrottle', () => { + it('admits up to the cap, then refuses with the time left in the window', () => { + let now = 0 + const throttle = new DaemonRespawnThrottle({ + maxAttempts: 3, + windowMs: 1_000, + now: () => now + }) + expect(throttle.admit().allowed).toBe(true) + now = 200 + expect(throttle.admit().allowed).toBe(true) + now = 400 + expect(throttle.admit().allowed).toBe(true) + now = 500 + const refused = throttle.admit() + expect(refused).toEqual({ + allowed: false, + reason: 'crash_loop', + attemptsInWindow: 3, + retryAfterMs: 500 + }) + }) + + it('admits again once the window slides past the oldest attempt', () => { + let now = 0 + const throttle = new DaemonRespawnThrottle({ maxAttempts: 2, windowMs: 1_000, now: () => now }) + throttle.admit() + throttle.admit() + expect(throttle.admit().allowed).toBe(false) + now = 1_500 + expect(throttle.admit().allowed).toBe(true) + }) + + it('clears the window on an explicit reset (the operator-restart escape hatch)', () => { + const throttle = new DaemonRespawnThrottle({ maxAttempts: 1, windowMs: 60_000 }) + expect(throttle.admit().allowed).toBe(true) + expect(throttle.admit().allowed).toBe(false) + throttle.reset() + expect(throttle.admit().allowed).toBe(true) + }) +}) + +describe('DaemonSpawner crash-loop containment', () => { + const handle: DaemonProcessHandle = { shutdown: async () => {} } + + it('stops forking once the daemon has died repeatedly inside the window', async () => { + const launcher = vi.fn(async () => handle) + const spawner = new DaemonSpawner({ + runtimeDir: '/tmp/orcad-throttle-test', + launcher, + respawnThrottle: new DaemonRespawnThrottle({ maxAttempts: 3, windowMs: 60_000 }) + }) + for (let i = 0; i < 3; i += 1) { + await spawner.ensureRunning() + // What a dead daemon looks like to the adapter's respawn path. + spawner.resetHandle() + } + expect(launcher).toHaveBeenCalledTimes(3) + await expect(spawner.ensureRunning()).rejects.toBeInstanceOf(DaemonCrashLoopError) + // The refusal must actually prevent the fork, not just annotate it. + expect(launcher).toHaveBeenCalledTimes(3) + }) + + it('does not count a cached handle as a new attempt', async () => { + const launcher = vi.fn(async () => handle) + const spawner = new DaemonSpawner({ + runtimeDir: '/tmp/orcad-throttle-test', + launcher, + respawnThrottle: new DaemonRespawnThrottle({ maxAttempts: 2, windowMs: 60_000 }) + }) + await spawner.ensureRunning() + await spawner.ensureRunning() + await spawner.ensureRunning() + expect(launcher).toHaveBeenCalledTimes(1) + }) + + it('lets an operator restart clear containment', async () => { + const launcher = vi.fn(async () => handle) + const spawner = new DaemonSpawner({ + runtimeDir: '/tmp/orcad-throttle-test', + launcher, + respawnThrottle: new DaemonRespawnThrottle({ maxAttempts: 1, windowMs: 60_000 }) + }) + await spawner.ensureRunning() + spawner.resetHandle() + await expect(spawner.ensureRunning()).rejects.toBeInstanceOf(DaemonCrashLoopError) + spawner.resetRespawnWindow() + await expect(spawner.ensureRunning()).resolves.toBeDefined() + expect(launcher).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/daemon/daemon-respawn-throttle.ts b/src/main/daemon/daemon-respawn-throttle.ts new file mode 100644 index 000000000000..9988a6b9facc --- /dev/null +++ b/src/main/daemon/daemon-respawn-throttle.ts @@ -0,0 +1,84 @@ +/** + * Crash-loop containment for the terminal daemon. + * + * The respawn path is driven by `ensureConnected()`: every reconnect attempt against a dead + * socket forks a replacement. A daemon that dies during startup — a broken node-pty, an + * unwritable runtime dir, a wrong libc — therefore forks forever, as fast as the caller + * retries. On the desktop that burns CPU; under orcad, where a supervisor is watching a + * process that never reports failure, it is the "restart-spin while the deploy reports + * success" shape the ops contract has to rule out. + * + * Sliding window, not a permanent trip: the failure is usually environmental, and an + * environment can be repaired without restarting the runtime. Once the window drains, the + * next attempt is admitted and a repaired host recovers on its own. + */ +export type DaemonRespawnAdmission = + | { allowed: true } + | { allowed: false; reason: 'crash_loop'; attemptsInWindow: number; retryAfterMs: number } + +export type DaemonRespawnThrottleOptions = { + /** Attempts allowed inside `windowMs` before the next one is refused. */ + maxAttempts?: number + windowMs?: number + now?: () => number +} + +// Generous on purpose: a user manually restarting the daemon a few times, or a laptop +// waking to a stale socket, must never trip this. Five failures inside a minute is a +// daemon that cannot start, not a daemon having a bad moment. +export const DEFAULT_DAEMON_RESPAWN_MAX_ATTEMPTS = 5 +export const DEFAULT_DAEMON_RESPAWN_WINDOW_MS = 60_000 + +export class DaemonRespawnThrottle { + private readonly maxAttempts: number + private readonly windowMs: number + private readonly now: () => number + private attempts: number[] = [] + + constructor(options: DaemonRespawnThrottleOptions = {}) { + this.maxAttempts = options.maxAttempts ?? DEFAULT_DAEMON_RESPAWN_MAX_ATTEMPTS + this.windowMs = options.windowMs ?? DEFAULT_DAEMON_RESPAWN_WINDOW_MS + this.now = options.now ?? Date.now + } + + /** Record and admit one respawn attempt, or refuse it as a crash loop. */ + admit(): DaemonRespawnAdmission { + const now = this.now() + this.attempts = this.attempts.filter((at) => now - at < this.windowMs) + if (this.attempts.length >= this.maxAttempts) { + const oldest = this.attempts[0] as number + return { + allowed: false, + reason: 'crash_loop', + attemptsInWindow: this.attempts.length, + retryAfterMs: Math.max(0, this.windowMs - (now - oldest)) + } + } + this.attempts.push(now) + return { allowed: true } + } + + /** + * Forget the recorded attempts. + * + * Why not automatic on a successful fork: a crash loop IS a sequence of successful forks + * followed by immediate deaths, so "the fork returned" is not evidence of recovery. Only + * a caller that knows the daemon stayed up — or a deliberate operator restart — may clear + * the window. + */ + reset(): void { + this.attempts = [] + } +} + +export class DaemonCrashLoopError extends Error { + readonly code = 'daemon_crash_loop' + constructor(admission: Extract) { + super( + `The terminal daemon has failed ${admission.attemptsInWindow} times in a row; refusing to ` + + `respawn it for another ${Math.ceil(admission.retryAfterMs / 1000)}s. Terminals will not ` + + 'start until the underlying failure is fixed (check the daemon log).' + ) + this.name = 'DaemonCrashLoopError' + } +} diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 6a6d377cc2f6..a0376ef0fc0b 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -10,6 +10,7 @@ import { } from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' +import { DaemonCrashLoopError, DaemonRespawnThrottle } from './daemon-respawn-throttle' export type DaemonConnectionInfo = { socketPath: string @@ -44,6 +45,8 @@ export type DaemonLauncher = ( export type DaemonSpawnerOptions = { runtimeDir: string launcher: DaemonLauncher + /** Test seam; production uses the default window. */ + respawnThrottle?: DaemonRespawnThrottle } export class DaemonSpawner { @@ -53,10 +56,12 @@ export class DaemonSpawner { private socketPath: string private tokenPath: string private pidPath: string + private respawnThrottle: DaemonRespawnThrottle constructor(opts: DaemonSpawnerOptions) { this.runtimeDir = opts.runtimeDir this.launcher = opts.launcher + this.respawnThrottle = opts.respawnThrottle ?? new DaemonRespawnThrottle() this.socketPath = getDaemonSocketPath(this.runtimeDir) this.tokenPath = getDaemonTokenPath(this.runtimeDir) this.pidPath = getDaemonPidPath(this.runtimeDir) @@ -67,6 +72,14 @@ export class DaemonSpawner { return { socketPath: this.socketPath, tokenPath: this.tokenPath } } + // Why here and not in the respawn callback: every launch — first, post-death, and + // post-restart — funnels through this method, so this is the only place a crash loop + // cannot route around. + const admission = this.respawnThrottle.admit() + if (!admission.allowed) { + throw new DaemonCrashLoopError(admission) + } + // Why: a detached daemon may clean up after its parent exits. A unique // launch identity keeps it from deleting a replacement daemon's PID file. this.handle = await this.launcher(this.socketPath, this.tokenPath, this.pidPath, randomUUID()) @@ -85,6 +98,17 @@ export class DaemonSpawner { this.handle = null } + /** + * Forget the crash-loop window. + * + * Why an explicit call and not "a fork succeeded": a crash loop is a run of successful + * forks whose daemons then die, so the fork returning proves nothing. An operator asking + * for a restart does mean "try again", and that is the only thing that clears it. + */ + resetRespawnWindow(): void { + this.respawnThrottle.reset() + } + async shutdown(): Promise { if (!this.handle) { return diff --git a/src/main/observability/logs-directory.ts b/src/main/observability/logs-directory.ts index 94a1b7baae82..20ad104af740 100644 --- a/src/main/observability/logs-directory.ts +++ b/src/main/observability/logs-directory.ts @@ -1,27 +1,29 @@ // Single source of truth for the app's logs directory and the files inside it. // macOS convention is `~/Library/Application Support/Orca/logs/`; Windows and -// Linux resolve the same intent via Electron's `userData` dir. Falls back to a -// homedir-derived path when Electron's `app` is unavailable (unit tests). +// Linux resolve the same intent via the host's `userData` dir. Falls back to a +// homedir-derived path when no AppEnvironment is installed (unit tests). -import { app } from 'electron' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { homedir, platform } from 'node:os' import { join } from 'node:path' +// Why the port and not electron's `app`: the daemon launch path reads this for --log-file, +// and that path has to resolve under plain Node (orcad) as well as the desktop. +// Why 'userData' + 'logs' rather than getPath('logs'): electron's 'logs' is ~/Library/Logs +// on macOS, which is NOT where this app has ever written. Changing it would strand +// existing log bundles. function getUserDataDir(): string { - try { - return app.getPath('userData') - } catch { - // Tests — Electron's `app` may not be initialized. Use an OS-conventional - // fallback so callers can resolve the path without the Electron runtime. - const home = homedir() - if (platform() === 'darwin') { - return join(home, 'Library', 'Application Support', 'Orca') - } - if (platform() === 'win32') { - return join(process.env.APPDATA ?? home, 'Orca') - } - return join(home, '.config', 'Orca') + if (hasAppEnvironment()) { + return getAppEnvironment().getPath('userData') } + const home = homedir() + if (platform() === 'darwin') { + return join(home, 'Library', 'Application Support', 'Orca') + } + if (platform() === 'win32') { + return join(process.env.APPDATA ?? home, 'Orca') + } + return join(home, '.config', 'Orca') } export function getLogsDirectory(): string { diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index bc59ddb19ab3..73a973c7b242 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -1,8 +1,16 @@ /** Executable entry for `orcad`. See `./orcad-entry.ts`. */ import process from 'node:process' -import { main } from './orcad-entry' +import { main, resolveOrcadExitCode } from './orcad-entry' import { runOrcadNativePreflight } from './orcad-native-preflight' +// Why exit before the preflight: reaching this line means the whole module graph resolved +// under plain Node, which is all the build guard needs to prove. Probing natives or +// starting a server to prove it would bind a port and take a data-root lock on a build +// machine. +if (process.argv.includes('--orcad-smoke-load-check')) { + process.exit(0) +} + // Why here and not inside startOrcad: this must run before anything requires node-pty, // and `orcad-entry` reaches it through `await import('../ipc/pty')`. Static imports are // evaluated before this statement, so the guarantee is that no module in the graph @@ -12,5 +20,8 @@ runOrcadNativePreflight() main().catch((error: unknown) => { console.error('orcad: failed to start:', error) - process.exit(1) + // Why a resolved code and not a bare 1: a data-root or bind-address refusal is a + // configuration fault that restarting cannot fix, and a supervisor needs to tell the two + // apart to avoid restart-spinning on it. + process.exit(resolveOrcadExitCode(error)) }) diff --git a/src/main/orcad/orcad-bind-address.test.ts b/src/main/orcad/orcad-bind-address.test.ts new file mode 100644 index 000000000000..2ddbcc563433 --- /dev/null +++ b/src/main/orcad/orcad-bind-address.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + bindHostIsNetworkExposed, + describeOrcadBindExposure, + ORCAD_LOOPBACK_BIND_HOST, + OrcadBindAddressError, + resolveOrcadBindHost +} from './orcad-bind-address' + +describe('resolveOrcadBindHost', () => { + it('defaults to loopback when the operator asked for nothing', () => { + expect(resolveOrcadBindHost()).toBe(ORCAD_LOOPBACK_BIND_HOST) + expect(ORCAD_LOOPBACK_BIND_HOST).toBe('127.0.0.1') + }) + + it('accepts literal IPv4 and IPv6 addresses, including explicit wide binds', () => { + expect(resolveOrcadBindHost('0.0.0.0')).toBe('0.0.0.0') + expect(resolveOrcadBindHost('10.1.2.3')).toBe('10.1.2.3') + expect(resolveOrcadBindHost('::1')).toBe('::1') + expect(resolveOrcadBindHost('localhost')).toBe('127.0.0.1') + expect(resolveOrcadBindHost(' 127.0.0.1 ')).toBe('127.0.0.1') + }) + + it('refuses hostnames, because DNS would decide which interface got bound', () => { + expect(() => resolveOrcadBindHost('internal.example')).toThrow(OrcadBindAddressError) + expect(() => resolveOrcadBindHost('')).toThrow(OrcadBindAddressError) + expect(() => resolveOrcadBindHost('0.0.0.0:80')).toThrow(OrcadBindAddressError) + }) +}) + +describe('bindHostIsNetworkExposed', () => { + it('separates local-only addresses from network-reachable ones', () => { + expect(bindHostIsNetworkExposed('127.0.0.1')).toBe(false) + expect(bindHostIsNetworkExposed('127.5.5.5')).toBe(false) + expect(bindHostIsNetworkExposed('::1')).toBe(false) + expect(bindHostIsNetworkExposed('0.0.0.0')).toBe(true) + expect(bindHostIsNetworkExposed('::')).toBe(true) + expect(bindHostIsNetworkExposed('10.1.2.3')).toBe(true) + }) + + it('says out loud when a deployment is reachable from the network', () => { + expect(describeOrcadBindExposure('0.0.0.0')).toContain('reachable from the network') + expect(describeOrcadBindExposure('127.0.0.1')).toContain('local only') + }) +}) diff --git a/src/main/orcad/orcad-bind-address.ts b/src/main/orcad/orcad-bind-address.ts new file mode 100644 index 000000000000..d5fc4548dd1d --- /dev/null +++ b/src/main/orcad/orcad-bind-address.ts @@ -0,0 +1,66 @@ +/** + * The address `orcad` binds its RPC listener to. + * + * Why loopback by default: the desktop stays on loopback until the user pairs, and the + * shipping design reaches a remote orcad over an SSH local port-forward — so the wide + * bind orcad had was both a departure from the desktop's posture and unnecessary for the + * deploy model. Exposure is now something an operator asks for by name. + */ +import { isIP } from 'node:net' + +export const ORCAD_LOOPBACK_BIND_HOST = '127.0.0.1' +const ALL_INTERFACES_V4 = '0.0.0.0' +const ALL_INTERFACES_V6 = '::' + +export class OrcadBindAddressError extends Error { + readonly code = 'orcad_invalid_bind_address' +} + +/** + * Resolve `--bind`. Literal IPs only. + * + * Why not hostnames: `listen()` resolves a name through DNS, so the interface actually + * bound is decided by resolver configuration this process cannot see. An operator who + * writes `--bind internal.example` would have no way to know whether the service came up + * on a private interface or a public one. + */ +export function resolveOrcadBindHost(raw?: string): string { + if (raw === undefined) { + return ORCAD_LOOPBACK_BIND_HOST + } + const value = raw.trim() + if (value === '') { + throw new OrcadBindAddressError('--bind expects an address') + } + if (value === 'localhost') { + return ORCAD_LOOPBACK_BIND_HOST + } + if (isIP(value) === 0) { + throw new OrcadBindAddressError( + `--bind expects a literal IP address (got '${value}'). Hostnames are refused because ` + + 'DNS decides which interface would be bound. Use 127.0.0.1 for loopback, or ' + + '0.0.0.0 to expose every interface.' + ) + } + return value +} + +/** True when this address reaches beyond the local machine. */ +export function bindHostIsNetworkExposed(host: string): boolean { + if (host === ALL_INTERFACES_V4 || host === ALL_INTERFACES_V6) { + return true + } + if (isIP(host) === 4) { + return !host.startsWith('127.') + } + return host !== '::1' +} + +/** One line for the startup log, so an exposed deployment is never a silent default. */ +export function describeOrcadBindExposure(host: string): string { + return bindHostIsNetworkExposed(host) + ? `orcad is bound to ${host} and is reachable from the network. Anything that can reach ` + + 'this port can attempt pairing.' + : `orcad is bound to ${host} (local only). Reach it from another machine with an SSH ` + + 'local port-forward, or re-launch with --bind to expose it.' +} diff --git a/src/main/orcad/orcad-daemon-supervision.test.ts b/src/main/orcad/orcad-daemon-supervision.test.ts new file mode 100644 index 000000000000..19616c9d5efa --- /dev/null +++ b/src/main/orcad/orcad-daemon-supervision.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + initDaemonPtyProviderMock, + disconnectDaemonMock, + shutdownDaemonMock, + daemonOwnsFreshPersistentPtysMock, + readDaemonPidRecordMock +} = vi.hoisted(() => ({ + initDaemonPtyProviderMock: vi.fn<(signal?: unknown, options?: unknown) => Promise>(), + disconnectDaemonMock: vi.fn<() => Promise>(), + shutdownDaemonMock: vi.fn<() => Promise>(), + daemonOwnsFreshPersistentPtysMock: vi.fn<() => boolean>(), + readDaemonPidRecordMock: vi.fn<() => { pid: number } | null>() +})) + +vi.mock('../daemon/daemon-init', () => ({ + initDaemonPtyProvider: initDaemonPtyProviderMock, + disconnectDaemon: disconnectDaemonMock, + // Exported here purely so the test can prove it is never reached — killing the daemon on + // orcad shutdown is what would make an orcad restart destructive again. + shutdownDaemon: shutdownDaemonMock, + daemonOwnsFreshPersistentPtys: daemonOwnsFreshPersistentPtysMock, + readDaemonPidRecord: readDaemonPidRecordMock +})) + +const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') + +beforeEach(() => { + initDaemonPtyProviderMock.mockResolvedValue() + disconnectDaemonMock.mockResolvedValue() + shutdownDaemonMock.mockResolvedValue() + daemonOwnsFreshPersistentPtysMock.mockReturnValue(true) + readDaemonPidRecordMock.mockReturnValue({ pid: 4242 }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('startOrcadDaemon', () => { + it('reports live with the daemon pid once the provider is installed', async () => { + await expect(startOrcadDaemon()).resolves.toEqual({ state: 'live', pid: 4242 }) + }) + + it('does not arm the macOS login-session death watch', async () => { + await startOrcadDaemon() + // That watch retires the daemon when the spawning GUI login session dies. An orcad + // daemon must survive its SSH session ending — arming it would kill every terminal the + // moment the operator logged out, which is the opposite of the property being bought. + expect(initDaemonPtyProviderMock).toHaveBeenCalledWith(undefined, { + macosLoginSessionWatch: false + }) + }) + + it('reports degraded when fresh terminals would fall back to the local provider', async () => { + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + const result = await startOrcadDaemon() + expect(result.state).toBe('degraded') + }) + + it('fails open when the daemon cannot start at all', async () => { + initDaemonPtyProviderMock.mockRejectedValue(new Error('node-pty is missing')) + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + // Fail-open, like the desktop: git, worktrees and non-persistent terminals must still + // serve. What must not happen is a thrown startup or a claim of persistence. + await expect(startOrcadDaemon()).resolves.toEqual({ + state: 'unavailable', + reason: 'node-pty is missing' + }) + }) +}) + +describe('stopOrcadDaemon', () => { + it('disconnects and never shuts the daemon down', async () => { + await stopOrcadDaemon() + expect(disconnectDaemonMock).toHaveBeenCalledTimes(1) + // The whole point of item 4: an orcad restart is non-destructive only if the daemon + // outlives it. shutdownDaemon() kills the daemon and every PTY under it. + expect(shutdownDaemonMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/orcad-daemon-supervision.ts b/src/main/orcad/orcad-daemon-supervision.ts new file mode 100644 index 000000000000..d35b03048d32 --- /dev/null +++ b/src/main/orcad/orcad-daemon-supervision.ts @@ -0,0 +1,67 @@ +/** + * orcad's half of the supervision contract: it spawns and supervises the terminal daemon. + * + * The whole reason the peer model is recommended over an SSH target is that daemon-backed + * PTYs stay `live` across a runtime restart (docs/reference/ssh-execution-boundary.md). + * Without a daemon here, every orcad restart, update and rollback is a SIGKILL for every + * running terminal — on the host whose selling point is that work survives the client going + * away. + * + * The constraint that makes a restart non-destructive lives in `stopOrcadDaemon` below: the + * daemon is DETACHED and must outlive this process. Anything that tears it down on the way + * out silently converts a restart back into data loss. + */ +import { + disconnectDaemon, + daemonOwnsFreshPersistentPtys, + initDaemonPtyProvider, + readDaemonPidRecord +} from '../daemon/daemon-init' + +export type OrcadDaemonStartup = + | { state: 'live'; pid: number | null } + | { state: 'degraded'; reason: string } + | { state: 'unavailable'; reason: string } + +/** + * Bring the daemon up and install it as the local PTY provider. + * + * Fail-open, like the desktop: a host that cannot start a daemon must still serve git, + * worktrees and non-persistent terminals. What it must NOT do is keep claiming persistence + * — `daemonOwnsFreshPersistentPtys()` is what the runtime reads for that, and it answers + * false here without any extra bookkeeping. + */ +export async function startOrcadDaemon(): Promise { + try { + // Why no login-session watch: that retires the daemon when the spawning macOS GUI login + // session dies. An orcad daemon must survive its SSH session ending — that is the point. + await initDaemonPtyProvider(undefined, { macosLoginSessionWatch: false }) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[orcad] The terminal daemon did not start: ${reason}\n` + + '[orcad] Terminals will run in-process and WILL NOT survive an orcad restart.' + ) + return { state: 'unavailable', reason } + } + if (!daemonOwnsFreshPersistentPtys()) { + const reason = 'daemon adopted in degraded mode; fresh terminals run on the local provider' + console.warn( + `[orcad] ${reason}. Existing daemon sessions keep working, but new terminals will not ` + + 'survive an orcad restart until the daemon is restarted.' + ) + return { state: 'degraded', reason } + } + return { state: 'live', pid: readDaemonPidRecord()?.pid ?? null } +} + +/** + * Release the daemon without killing it. + * + * Why `disconnectDaemon` and never `shutdownDaemon`: shutdown kills the daemon process and + * every PTY under it. Calling it here would make orcad's own restart destructive, which is + * the exact property this whole item exists to buy. + */ +export async function stopOrcadDaemon(): Promise { + await disconnectDaemon() +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 105264dd8e71..9d7371b7dc92 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -18,6 +18,16 @@ import type { ServeReadiness } from '../server/serve-readiness' import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' +import { + describeOrcadBindExposure, + OrcadBindAddressError, + resolveOrcadBindHost +} from './orcad-bind-address' +import { + acquireOrcadInstanceLock, + OrcadInstanceLockError, + type OrcadInstanceLock +} from './orcad-instance-lock' let runOrcadQuitHandlers = (): void => {} @@ -80,6 +90,8 @@ export type OrcadOptions = { json?: boolean noPairing?: boolean pairingAddress?: string + /** Literal IP to bind. Defaults to loopback; see orcad-bind-address.ts. */ + bind?: string } export type OrcadHandle = { @@ -95,24 +107,30 @@ export type OrcadHandle = { export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() const userDataPath = resolveUserDataPath() + // Why before anything else touches the root: the profile index, the store and the daemon + // runtime dir all live under it, and two orcads sharing them corrupt state silently. This + // is also the last point at which refusing costs nothing. + const instanceLock = acquireOrcadInstanceLock(userDataPath) const browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) setRuntimeBrowserCommandsFactory(browserProvider?.factory ?? null, { headless: browserProvider !== null, ...(browserProvider ? { isAvailable: () => browserProvider.isAvailable() } : {}) }) try { - return await startOrcadRuntime(options, browserProvider) + return await startOrcadRuntime(options, browserProvider, instanceLock) } catch (error) { await browserProvider?.stop() setRuntimeBrowserCommandsFactory(null) runOrcadQuitHandlers() + instanceLock.release() throw error } } async function startOrcadRuntime( options: OrcadOptions, - browserProvider: OrcadBrowserProvider | null + browserProvider: OrcadBrowserProvider | null, + instanceLock: OrcadInstanceLock ): Promise { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') @@ -125,6 +143,9 @@ async function startOrcadRuntime( const { ensureActiveOrcaProfile, initOrcaProfilePaths } = await import('../orca-profiles/profile-index-store') const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store') + const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision') + const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init') + const { collectOrcadHealth } = await import('./orcad-health') const runtimeUserDataPath = getAppEnvironment().getPath('userData') initOrcaProfilePaths() @@ -137,6 +158,11 @@ async function startOrcadRuntime( // which is safe but silently discards accept records on every launch. initSshHostKeyStoreFile(profile.dataFile) + // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon + // adapter as THE local provider, and the registry's contract is that it lands before + // registerPtyHandlers so the IPC layer routes through the daemon from the first call. + await startOrcadDaemon() + const runtime = new OrcaRuntimeService(store, undefined, { // Why lazy: a daemon swap replaces the provider after construction, so an eager // reference would freeze the pre-daemon one. @@ -144,10 +170,11 @@ async function startOrcadRuntime( // Why: destructive worktree removal refuses to run without a provider to stop // processes through — correctly, since it cannot otherwise verify the tree is idle. getSshProvider: (connectionId) => getSshPtyProvider(connectionId), - // Why false: this host does not run the terminal daemon, so persistent local PTYs - // cannot be recovered. The constructor defaults this to true, which would claim a - // capability orcad does not have. - canRecoverPersistentLocalPtys: () => false, + // Why the daemon predicate and not a constant: orcad now spawns the terminal daemon, so + // its PTYs DO survive an orcad restart — but only while a daemon that owns fresh + // sessions is installed. A failed or degraded launch has to answer false, and this reads + // that live rather than snapshotting it at construction. + canRecoverPersistentLocalPtys: () => daemonOwnsFreshPersistentPtys(), // Why 'blocked': `'openable'` means a desktop window can be opened here, which is // what powers serve→desktop promotion. A Node host can never do that, and the // constructor's default would advertise it. @@ -168,14 +195,20 @@ async function startOrcadRuntime( await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() + const bindHost = resolveOrcadBindHost(options.bind) const rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, enableWebSocket: true, - exposeNetworkByDefault: true, + // Why pinned and not `exposeNetworkByDefault`: an unattended host's exposure must be + // exactly what the operator asked for, on every launch. The default path widens itself + // once a device has connected, so a loopback deployment would silently go wide one + // restart after its first client paired. + pinnedBindHost: bindHost, ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) }) await rpc.start() + console.error(`[orcad] ${describeOrcadBindExposure(bindHost)}`) const boundEndpoint = rpc.getWebSocketEndpoint() const advertised = boundEndpoint @@ -210,7 +243,11 @@ async function startOrcadRuntime( scope: 'runtime', qr: null } - : offer + : offer, + // Why in the readiness payload: this is the one message a supervisor and a deploy + // transaction both read, and a green orcad with a dead daemon is exactly the + // looks-healthy-but-useless state they must not activate. + health: await collectOrcadHealth(getAppEnvironment().getVersion()) } await new ServeReadinessPublisher().publish(readiness, { @@ -223,15 +260,20 @@ async function startOrcadRuntime( try { await rpc.stop() } finally { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. See + // orcad-daemon-supervision.ts. + await stopOrcadDaemon() await browserProvider?.stop() setRuntimeBrowserCommandsFactory(null) runOrcadQuitHandlers() + instanceLock.release() } } } } -function parseArgs(argv: string[]): OrcadOptions { +export function parseArgs(argv: string[]): OrcadOptions { const options: OrcadOptions = {} for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] @@ -247,6 +289,13 @@ function parseArgs(argv: string[]): OrcadOptions { options.json = true } else if (arg === '--no-pairing') { options.noPairing = true + } else if (arg === '--bind') { + const value = argv[i + 1] + if (value === undefined) { + throw new Error('--bind expects a value') + } + options.bind = value + i += 1 } else if (arg === '--pairing-address') { const value = argv[i + 1] if (!value) { @@ -261,22 +310,56 @@ function parseArgs(argv: string[]): OrcadOptions { return options } +/** + * Exit codes a supervisor can act on. Closed set — see docs/reference/orcad-operations.md. + * + * `ORCAD_EXIT_CONFIGURATION` is the load-bearing one: a data root owned by someone else, or + * held by another orcad, is not fixed by restarting. Restarting on it is the crash-loop the + * supervision contract has to prevent, so systemd's `RestartPreventExitStatus` needs a code + * that means "do not retry" and nothing else does. + */ +export const ORCAD_EXIT_OK = 0 +export const ORCAD_EXIT_FAILED = 1 +export const ORCAD_EXIT_CONFIGURATION = 78 + +/** Bounded so a wedged transport cannot hold a supervisor's stop past its own deadline. */ +export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 + +export function resolveOrcadExitCode(error: unknown): number { + return error instanceof OrcadInstanceLockError || error instanceof OrcadBindAddressError + ? ORCAD_EXIT_CONFIGURATION + : ORCAD_EXIT_FAILED +} + export async function main(argv: string[] = process.argv.slice(2)): Promise { const handle = await startOrcad(parseArgs(argv)) let stopping = false const shutdown = (signal: NodeJS.Signals): void => { if (stopping) { - return + // Why escalate rather than ignore: a supervisor's second signal means the first + // deadline elapsed. Continuing to wait silently is what makes a stop hang until + // SIGKILL, which is the one teardown that skips the daemon handoff entirely. + console.error(`orcad: second ${signal} during shutdown — exiting immediately`) + process.exit(ORCAD_EXIT_FAILED) } stopping = true + // Why a self-imposed deadline as well: the supervisor's SIGKILL leaves no exit code and + // no log line. Exiting ourselves keeps the failure attributable. + const deadline = setTimeout(() => { + console.error( + `orcad: shutdown after ${signal} exceeded ${ORCAD_SHUTDOWN_DEADLINE_MS}ms — exiting` + ) + process.exit(ORCAD_EXIT_FAILED) + }, ORCAD_SHUTDOWN_DEADLINE_MS) + deadline.unref() handle .stop() - .then(() => process.exit(0)) + .then(() => process.exit(ORCAD_EXIT_OK)) // Why not rethrow: we are already tearing down on a signal, and an exit code is // the only thing a supervisor can act on. .catch((error) => { console.error(`orcad: shutdown after ${signal} failed:`, error) - process.exit(1) + process.exit(ORCAD_EXIT_FAILED) }) } process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts new file mode 100644 index 000000000000..7a1a2a269ffb --- /dev/null +++ b/src/main/orcad/orcad-health.test.ts @@ -0,0 +1,152 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedDaemonPid } from '../daemon/daemon-pid-file-parse' +import type { DaemonHealth } from '../daemon/daemon-health' + +const { + checkDaemonHealthMock, + getDaemonEndpointFactsMock, + readDaemonPidRecordMock, + daemonOwnsFreshPersistentPtysMock +} = vi.hoisted(() => ({ + checkDaemonHealthMock: vi.fn<() => Promise>(), + getDaemonEndpointFactsMock: vi.fn<() => unknown>(), + readDaemonPidRecordMock: vi.fn<() => ParsedDaemonPid | null>(), + daemonOwnsFreshPersistentPtysMock: vi.fn<() => boolean>() +})) + +vi.mock('../daemon/daemon-health', () => ({ checkDaemonHealth: checkDaemonHealthMock })) +vi.mock('../daemon/daemon-init', () => ({ + getDaemonEndpointFacts: getDaemonEndpointFactsMock, + readDaemonPidRecord: readDaemonPidRecordMock, + daemonOwnsFreshPersistentPtys: daemonOwnsFreshPersistentPtysMock +})) + +const { collectOrcadHealth, collectTerminalDaemonHealth, computeOrcadBuildHash } = + await import('./orcad-health') + +const LIVE_FACTS = { + runtimeDir: '/data/daemon', + socketPath: '/data/daemon/daemon-v36.sock', + tokenPath: '/data/daemon/daemon-v36.token', + pidPath: '/data/daemon/daemon-v36.pid', + protocolVersion: 36 +} + +const PID_RECORD: ParsedDaemonPid = { + pid: 4242, + startedAtMs: 1_000, + entryPath: '/opt/orcad/daemon-entry.js', + appVersion: '1.2.2', + launchNonce: 'n', + linuxStartTicks: null, + bootId: null, + spawnerExecPath: null +} + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + +beforeEach(() => { + getDaemonEndpointFactsMock.mockReturnValue(LIVE_FACTS) + readDaemonPidRecordMock.mockReturnValue(PID_RECORD) + daemonOwnsFreshPersistentPtysMock.mockReturnValue(true) + checkDaemonHealthMock.mockResolvedValue('healthy') +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + vi.clearAllMocks() +}) + +describe('collectTerminalDaemonHealth', () => { + it('reports live only when the daemon answered its own PTY spawn probe', async () => { + const health = await collectTerminalDaemonHealth() + expect(health.state).toBe('live') + expect(health.selfTest).toMatchObject({ ok: true, verdict: 'healthy', coverage: 'pty-spawn' }) + expect(health.pid).toBe(4242) + // The build the LIVE daemon came from, which can legitimately predate this orcad. + expect(health.buildVersion).toBe('1.2.2') + expect(health.entryPath).toBe('/opt/orcad/daemon-entry.js') + expect(health.protocolVersion).toBe(36) + // Why assert the coordinates: a self-test that probed some other endpoint would prove + // nothing about the daemon this process installed. + expect(checkDaemonHealthMock).toHaveBeenCalledWith(LIVE_FACTS.socketPath, LIVE_FACTS.tokenPath) + }) + + it('is not green when the daemon is up but cannot spawn a PTY', async () => { + checkDaemonHealthMock.mockResolvedValue('pty-spawn-unhealthy') + const health = await collectTerminalDaemonHealth() + expect(health.selfTest.ok).toBe(false) + expect(health.selfTest.verdict).toBe('pty-spawn-unhealthy') + // Degraded, not absent: it still owns live sessions, and calling those exited would be + // the verdict the execution-boundary vocabulary forbids guessing. + expect(health.state).toBe('degraded') + }) + + it('is not green when the daemon stopped answering entirely', async () => { + checkDaemonHealthMock.mockResolvedValue('unreachable') + const health = await collectTerminalDaemonHealth() + expect(health.selfTest.ok).toBe(false) + expect(health.state).toBe('degraded') + }) + + it('is not green when fresh terminals fall back to the local provider', async () => { + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + const health = await collectTerminalDaemonHealth() + // The socket answers and the probe passes, but new terminals would die with this + // process — reporting live here is precisely the looks-healthy-but-useless shape. + expect(health.selfTest.ok).toBe(true) + expect(health.ownsFreshSessions).toBe(false) + expect(health.state).toBe('degraded') + }) + + it('reports absent, and probes nothing, when no daemon was ever installed', async () => { + getDaemonEndpointFactsMock.mockReturnValue(null) + daemonOwnsFreshPersistentPtysMock.mockReturnValue(false) + const health = await collectTerminalDaemonHealth() + expect(health.state).toBe('absent') + expect(health.selfTest).toMatchObject({ ok: false, verdict: 'no-daemon' }) + expect(health.pid).toBeNull() + expect(checkDaemonHealthMock).not.toHaveBeenCalled() + }) + + it('declares handshake-only coverage on win32, where the spawn probe is a no-op', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const health = await collectTerminalDaemonHealth() + // `checkPtySpawnHealth` returns immediately on win32 without spawning anything, so a + // green verdict there must not be reported as a PTY round trip. + expect(health.selfTest.coverage).toBe('handshake') + }) +}) + +describe('collectOrcadHealth', () => { + it('carries build identity and the Node ABI native addons must match', async () => { + const health = await collectOrcadHealth('1.2.3') + expect(health.buildVersion).toBe('1.2.3') + expect(health.nodeVersion).toBe(process.versions.node) + expect(health.nodeAbi).toBe(process.versions.modules) + expect(health.platform).toBe(process.platform) + expect(health.terminalDaemon.state).toBe('live') + }) +}) + +describe('computeOrcadBuildHash', () => { + it('changes when the bundle bytes change, even at the same version string', () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-build-hash-')) + const entry = join(dir, 'orcad.js') + writeFileSync(entry, 'build-a') + const first = computeOrcadBuildHash(entry) + writeFileSync(entry, 'build-b') + // A rollback that did not actually replace the file is what this has to catch, and a + // version string cannot. + expect(computeOrcadBuildHash(entry)).not.toBe(first) + }) + + it('answers unknown rather than throwing when the entry cannot be read', () => { + expect(computeOrcadBuildHash(join(tmpdir(), 'definitely-absent-orcad.js'))).toBe('unknown') + // A process with no argv[1] (an embedded host) still has to publish a readiness payload. + expect(computeOrcadBuildHash('')).toBe('unknown') + }) +}) diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts new file mode 100644 index 000000000000..9f271c55d84d --- /dev/null +++ b/src/main/orcad/orcad-health.ts @@ -0,0 +1,155 @@ +/** + * orcad's health surface: the facts a supervisor needs to decide whether this deployment + * is actually serving, as opposed to merely listening. + * + * The load-bearing one is the terminal-daemon verdict. orcad answers RPC from its own + * process, so "the port is open" stays true while the daemon that owns every terminal is + * dead — a green host that cannot run a single command. The self-test below therefore has + * to cross the process boundary: orcad drives it, the daemon performs it, and the verdict + * travels back over the daemon's socket. + */ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { checkDaemonHealth, type DaemonHealth } from '../daemon/daemon-health' +import { + daemonOwnsFreshPersistentPtys, + getDaemonEndpointFacts, + readDaemonPidRecord +} from '../daemon/daemon-init' + +/** + * How much a green self-test actually proves. + * + * `pty-spawn` — the daemon spawned a real PTY inside its own process and it worked. + * `handshake` — the daemon answered its protocol handshake, but its spawn probe is a no-op + * on this platform (win32: `checkPtySpawnHealth` returns without spawning). Reported + * separately rather than folded into `ok`, because claiming a PTY round trip we did not + * perform is the failure mode this surface exists to prevent. + */ +export type PtySelfTestCoverage = 'pty-spawn' | 'handshake' + +export type PtySelfTest = { + ok: boolean + coverage: PtySelfTestCoverage + /** The daemon's own verdict word, so a failure is diagnosable without re-probing. */ + verdict: DaemonHealth | 'no-daemon' + durationMs: number +} + +export type TerminalDaemonHealth = { + /** `live` requires the daemon to have answered; absence is never inferred from silence. */ + state: 'live' | 'degraded' | 'absent' + /** True only when FRESH terminals are daemon-owned, i.e. survive an orcad restart. */ + ownsFreshSessions: boolean + pid: number | null + /** The build the LIVE daemon was forked from, which may predate this orcad after an update. */ + buildVersion: string | null + entryPath: string | null + protocolVersion: number | null + selfTest: PtySelfTest +} + +export type OrcadHealth = { + /** Content hash of the running orcad bundle — the deployed build's identity. */ + buildHash: string + buildVersion: string + nodeVersion: string + /** `process.versions.modules`: the ABI every native addon on this host must match. */ + nodeAbi: string + platform: NodeJS.Platform + arch: string + pid: number + terminalDaemon: TerminalDaemonHealth +} + +/** + * Identity of the exact bytes running. + * + * Why hash the entry and not read a version string: `ORCA_VERSION` is whatever the deploy + * exported, so two different builds can carry one version. A rollback that did not actually + * replace the file is precisely what this has to catch. + */ +export function computeOrcadBuildHash(entryPath = process.argv[1]): string { + if (!entryPath) { + return 'unknown' + } + try { + return createHash('sha256').update(readFileSync(entryPath)).digest('hex').slice(0, 16) + } catch { + return 'unknown' + } +} + +/** + * Probe the daemon across the process boundary. + * + * `checkDaemonHealth` is the cross-process test: it opens the daemon's socket, completes the + * protocol handshake, and asks the daemon to run `ptySpawnHealth` — a real short-lived PTY + * spawned inside the daemon. Only a daemon that is alive AND can create terminals answers + * `healthy`; a wedged one times out to `unreachable`, and one whose node-pty or login session + * is broken answers `pty-spawn-unhealthy`. + */ +export async function runTerminalDaemonSelfTest( + now: () => number = () => Date.now() +): Promise { + const startedAt = now() + // Why: `checkPtySpawnHealth` returns immediately on win32 without spawning anything, so a + // green verdict there covers the handshake only. Say so instead of overclaiming. + const coverage: PtySelfTestCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' + const facts = getDaemonEndpointFacts() + if (!facts) { + return { ok: false, coverage, verdict: 'no-daemon', durationMs: now() - startedAt } + } + const verdict = await checkDaemonHealth(facts.socketPath, facts.tokenPath) + return { ok: verdict === 'healthy', coverage, verdict, durationMs: now() - startedAt } +} + +export async function collectTerminalDaemonHealth(): Promise { + const facts = getDaemonEndpointFacts() + const selfTest = await runTerminalDaemonSelfTest() + if (!facts) { + return { + state: 'absent', + ownsFreshSessions: false, + pid: null, + buildVersion: null, + entryPath: null, + protocolVersion: null, + selfTest + } + } + const record = readDaemonPidRecord() + const ownsFreshSessions = daemonOwnsFreshPersistentPtys() + return { + // Why `degraded` and not `absent` on a failed self-test: a daemon that answered its + // handshake but failed the spawn probe is still holding live sessions. Reporting it gone + // would invite a caller to treat those terminals as exited, which is the one verdict the + // execution-boundary vocabulary forbids guessing. + state: + selfTest.ok && ownsFreshSessions + ? 'live' + : selfTest.verdict === 'no-daemon' + ? 'absent' + : 'degraded', + ownsFreshSessions, + pid: record?.pid ?? null, + buildVersion: record?.appVersion ?? null, + entryPath: record?.entryPath ?? null, + protocolVersion: facts.protocolVersion, + selfTest + } +} + +export async function collectOrcadHealth(buildVersion: string): Promise { + return { + buildHash: computeOrcadBuildHash(), + buildVersion, + nodeVersion: process.versions.node, + nodeAbi: process.versions.modules ?? 'unknown', + platform: process.platform, + arch: process.arch, + pid: process.pid, + terminalDaemon: await collectTerminalDaemonHealth() + } +} diff --git a/src/main/orcad/orcad-instance-lock.test.ts b/src/main/orcad/orcad-instance-lock.test.ts new file mode 100644 index 000000000000..45f7543c5157 --- /dev/null +++ b/src/main/orcad/orcad-instance-lock.test.ts @@ -0,0 +1,151 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + acquireOrcadInstanceLock, + ORCAD_LOCK_FILE_NAME, + OrcadInstanceLockError, + type OrcadInstanceLockHooks +} from './orcad-instance-lock' + +const roots: string[] = [] + +function makeRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'orcad-lock-')) + roots.push(root) + return root +} + +/** Deterministic identity/liveness so the assertions do not depend on this machine's pids. */ +function hooks(overrides: OrcadInstanceLockHooks = {}): OrcadInstanceLockHooks { + return { + identity: () => 'uid-1000', + version: () => '1.0.0-test', + startedAtMs: () => 1_000, + startTimeMatches: () => true, + processIsAlive: () => false, + ...overrides + } +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('acquireOrcadInstanceLock', () => { + it('publishes a record naming the holder and removes it on release', () => { + const root = makeRoot() + const lock = acquireOrcadInstanceLock(root, hooks()) + const record = JSON.parse(readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')) + expect(record.pid).toBe(process.pid) + expect(record.identity).toBe('uid-1000') + expect(record.version).toBe('1.0.0-test') + lock.release() + expect(() => readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')).toThrow() + }) + + it('refuses a second instance while the holder is alive', () => { + const root = makeRoot() + acquireOrcadInstanceLock(root, hooks()) + expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => true }))).toThrow( + OrcadInstanceLockError + ) + expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => true }))).toThrow( + expect.objectContaining({ code: 'orcad_instance_lock_held' }) + ) + }) + + it('reclaims the record of a holder that is gone', () => { + const root = makeRoot() + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-1000', startedAtMs: 1, nonce: 'stale' }) + ) + const lock = acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => false })) + expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) + }) + + it('treats a live pid whose start time does not match as a recycled pid, not a holder', () => { + const root = makeRoot() + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-1000', startedAtMs: 1, nonce: 'stale' }) + ) + const lock = acquireOrcadInstanceLock( + root, + hooks({ processIsAlive: () => true, startTimeMatches: () => false }) + ) + expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) + }) + + it('never reclaims a lock held by a different identity, even a dead one', () => { + const root = makeRoot() + writeFileSync( + join(root, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ pid: 424242, identity: 'uid-2000', startedAtMs: 1, nonce: 'other' }) + ) + expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => false }))).toThrow( + expect.objectContaining({ code: 'orcad_instance_lock_foreign_identity' }) + ) + }) + + it('does not delete a record that a later instance already replaced', () => { + const root = makeRoot() + const lock = acquireOrcadInstanceLock(root, hooks()) + // A successor reclaimed the root while this process was wedged. + writeFileSync( + lock.path, + JSON.stringify({ pid: 777, identity: 'uid-1000', startedAtMs: 2, nonce: 'successor' }) + ) + lock.release() + expect(JSON.parse(readFileSync(lock.path, 'utf8')).nonce).toBe('successor') + }) + + it.runIf(process.platform !== 'win32')( + 'tightens a group/world-accessible data root rather than refusing when it can', + () => { + const root = makeRoot() + chmodSync(root, 0o755) + acquireOrcadInstanceLock(root, hooks()) + expect(statSync(root).mode & 0o777).toBe(0o700) + } + ) + + it.runIf(process.platform !== 'win32')('refuses a data root owned by another uid', () => { + const root = makeRoot() + // /tmp itself is root-owned and sticky on every supported platform, so it stands in for + // "a data root this process does not own" without needing privileges to create one. + expect(() => acquireOrcadInstanceLock('/tmp', hooks())).toThrow( + expect.objectContaining({ code: 'orcad_data_root_wrong_owner' }) + ) + // And the private root this test made is still acceptable, so the refusal is about + // ownership rather than a blanket rejection. + expect(acquireOrcadInstanceLock(root, hooks()).record.identity).toBe('uid-1000') + }) + + it('leaves the terminal daemon alone: the lock covers only the runtime role', () => { + const root = makeRoot() + // The daemon lives here and deliberately outlives the runtime. Releasing the runtime's + // lock must not touch it, or a restart would stop being non-destructive. + const daemonDir = join(root, 'daemon') + mkdirSync(daemonDir, { recursive: true }) + writeFileSync(join(daemonDir, 'daemon-v36.pid'), JSON.stringify({ pid: 99, startedAtMs: 1 })) + const lock = acquireOrcadInstanceLock(root, hooks()) + lock.release() + expect(JSON.parse(readFileSync(join(daemonDir, 'daemon-v36.pid'), 'utf8')).pid).toBe(99) + // And a fresh instance takes the root back while that daemon record still stands. + const next = acquireOrcadInstanceLock(root, hooks()) + expect(next.record.pid).toBe(process.pid) + }) +}) diff --git a/src/main/orcad/orcad-instance-lock.ts b/src/main/orcad/orcad-instance-lock.ts new file mode 100644 index 000000000000..052140bcd2ea --- /dev/null +++ b/src/main/orcad/orcad-instance-lock.ts @@ -0,0 +1,324 @@ +/** + * Single-instance ownership of orcad's data root, taken BEFORE the profile is loaded. + * + * Two orcads on one data root corrupt it quietly: both load the same profile, both write + * the same store file, and the loser's writes disappear on the next flush. The refusal is + * therefore a startup gate, not a warning. + * + * What this lock does NOT cover, and must not: the terminal daemon. The daemon is a second + * long-lived process living under `/daemon`, it deliberately outlives the orcad that + * spawned it, and it fences its own endpoint with a PID record of its own. A lock that + * asked "is any process using this root" would refuse every restart that a live daemon + * makes worthwhile. This lock scopes exactly one role — who is the runtime — so releasing + * it says nothing about the daemon, which is what makes a non-destructive restart possible. + */ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { userInfo } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { getProcessStartedAtMs, startTimeMatches } from '../daemon/daemon-process-start-time' + +export const ORCAD_LOCK_FILE_NAME = 'orcad.lock' + +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' + +export class OrcadInstanceLockError extends Error { + constructor( + readonly code: OrcadInstanceLockCode, + message: string + ) { + super(message) + this.name = 'OrcadInstanceLockError' + } +} + +export type OrcadLockRecord = { + pid: number + /** Null where the platform cannot read it; PID alone is then the (weaker) fence. */ + startedAtMs: number | null + /** POSIX uid, or the Windows username. Compared as an opaque string. */ + identity: string + version: string + acquiredAt: string + /** Distinguishes our record from a replacement written after we lost the race. */ + nonce: string +} + +export type OrcadInstanceLock = { + readonly path: string + readonly record: OrcadLockRecord + release(): void +} + +export type OrcadInstanceLockHooks = { + identity?: () => string + version?: () => string + now?: () => Date + /** Whether a PID is running. EPERM counts as alive: it proves the process exists. */ + processIsAlive?: (pid: number) => boolean + startedAtMs?: (pid: number) => number | null + startTimeMatches?: (pid: number, expected: number | null) => boolean +} + +function defaultIdentity(): string { + // Why uid and not the name on POSIX: two accounts can share a login name across a + // container boundary while the uid is what the filesystem actually enforces. + return process.platform === 'win32' + ? (userInfo().username ?? 'unknown') + : String(process.getuid?.() ?? 'unknown') +} + +function defaultProcessIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return isErrorCode(error, 'EPERM') + } +} + +function isErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +function parseLockRecord(content: string): OrcadLockRecord | null { + try { + const parsed: unknown = JSON.parse(content) + if (!parsed || typeof parsed !== 'object') { + return null + } + const record = parsed as Partial + if (typeof record.pid !== 'number' || typeof record.identity !== 'string') { + return null + } + return { + pid: record.pid, + startedAtMs: typeof record.startedAtMs === 'number' ? record.startedAtMs : null, + identity: record.identity, + version: typeof record.version === 'string' ? record.version : 'unknown', + acquiredAt: typeof record.acquiredAt === 'string' ? record.acquiredAt : '', + nonce: typeof record.nonce === 'string' ? record.nonce : '' + } + } catch { + return null + } +} + +/** + * 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): void { + // Windows ACLs are not expressible as a POSIX mode, and `statSync().mode` there reports a + // synthesized one. Checking it would refuse correct deployments and pass wrong ones. + if (process.platform === 'win32') { + 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. + * + * A dead holder's record is reclaimed; a live one, or one belonging to a different identity, + * is never touched. + */ +export function acquireOrcadInstanceLock( + dataRoot: string, + hooks: OrcadInstanceLockHooks = {} +): OrcadInstanceLock { + const identity = (hooks.identity ?? defaultIdentity)() + const isAlive = hooks.processIsAlive ?? defaultProcessIsAlive + const readStartedAt = hooks.startedAtMs ?? getProcessStartedAtMs + const matchesStartTime = hooks.startTimeMatches ?? startTimeMatches + + try { + mkdirSync(dataRoot, { recursive: true, mode: 0o700 }) + } catch (error) { + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot create the orcad data root ${dataRoot}: ${(error as Error).message}` + ) + } + assertDataRootIsPrivate(dataRoot) + + const lockPath = join(dataRoot, ORCAD_LOCK_FILE_NAME) + const record: OrcadLockRecord = { + pid: process.pid, + startedAtMs: readStartedAt(process.pid), + identity, + version: (hooks.version ?? (() => process.env.ORCA_VERSION ?? 'unknown'))(), + acquiredAt: (hooks.now ?? (() => new Date()))().toISOString(), + nonce: randomUUID() + } + const serialized = JSON.stringify(record) + + const publish = (): boolean => { + try { + writeFileSync(lockPath, serialized, { flag: 'wx', mode: 0o600 }) + return true + } catch (error) { + if (isErrorCode(error, 'EEXIST')) { + return false + } + throw new OrcadInstanceLockError( + 'orcad_data_root_unusable', + `Cannot write the orcad instance lock ${lockPath}: ${(error as Error).message}` + ) + } + } + + if (publish()) { + return makeLock(lockPath, record) + } + + const existing = parseLockRecord(safeRead(lockPath) ?? '') + if (existing && existing.identity !== identity) { + throw new OrcadInstanceLockError( + 'orcad_instance_lock_foreign_identity', + `The orcad data root ${dataRoot} is locked by identity ${existing.identity} (pid ` + + `${existing.pid}); this process runs as ${identity}. Two identities sharing one data ` + + 'root corrupts it. Give each its own ORCA_USER_DATA.' + ) + } + if (existing && 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.' + ) + } + if (!existing) { + console.warn( + `[orcad] The instance lock at ${lockPath} is unreadable; reclaiming it. If another orcad ` + + 'is running on this data root, stop it now.' + ) + } + + // Why rename-and-then-publish rather than unlink-and-write: rename claims one exact + // directory entry, so a replacement written between our read and our write stays at the + // canonical path and wins — we never delete a record we did not inspect. + const claimPath = `${lockPath}.stale-${process.pid}-${randomUUID()}` + try { + renameSync(lockPath, claimPath) + } catch { + throw new OrcadInstanceLockError( + 'orcad_instance_lock_held', + `Could not reclaim the stale orcad instance lock at ${lockPath}; another process is ` + + 'holding it. Retry, or stop the other orcad.' + ) + } + if (!publish()) { + // Someone else claimed it first. Their record is authoritative; ours is not. + try { + unlinkSync(claimPath) + } catch { + // A uniquely named claim is inert. + } + throw new OrcadInstanceLockError( + 'orcad_instance_lock_held', + `Another orcad took the data root ${dataRoot} while this one was reclaiming a stale lock.` + ) + } + try { + unlinkSync(claimPath) + } catch { + // The canonical record is authoritative; the claim is inert. + } + return makeLock(lockPath, record) +} + +function safeRead(path: string): string | null { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +function makeLock(lockPath: string, record: OrcadLockRecord): OrcadInstanceLock { + let released = false + return { + path: lockPath, + record, + release: () => { + if (released) { + return + } + released = true + // Why re-read before unlinking: a reclaim by a later orcad (after, say, a SIGKILL that + // this process somehow survived enough to run handlers) leaves a record that is not + // ours. Deleting it would unlock a live runtime. + const current = parseLockRecord(safeRead(lockPath) ?? '') + if (!current || current.nonce !== record.nonce) { + return + } + try { + unlinkSync(lockPath) + } catch { + // Best-effort: a leftover record with a dead pid is reclaimed on the next start. + } + } + } +} diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts new file mode 100644 index 000000000000..b22dc74f0e46 --- /dev/null +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -0,0 +1,43 @@ +/** + * The two things a supervisor reads off a launch: what the arguments mean, and what an exit + * code means. Both are part of the ops contract in docs/reference/orcad-operations.md. + */ +import { describe, expect, it } from 'vitest' +import { + ORCAD_EXIT_CONFIGURATION, + ORCAD_EXIT_FAILED, + parseArgs, + resolveOrcadExitCode +} from './orcad-entry' +import { OrcadBindAddressError } from './orcad-bind-address' +import { OrcadInstanceLockError } from './orcad-instance-lock' + +describe('parseArgs', () => { + it('accepts --bind and leaves it unset when absent', () => { + expect(parseArgs(['--bind', '0.0.0.0'])).toEqual({ bind: '0.0.0.0' }) + expect(parseArgs([])).toEqual({}) + expect(parseArgs(['--port', '6768', '--bind', '10.0.0.5', '--json'])).toEqual({ + port: 6768, + bind: '10.0.0.5', + json: true + }) + }) + + 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() + }) +}) + +describe('resolveOrcadExitCode', () => { + it('separates a configuration fault from a generic failure', () => { + // A supervisor must be able to stop restarting on faults that restarting cannot fix: + // a data root owned by someone else, held by another instance, or a bad bind address. + expect( + resolveOrcadExitCode(new OrcadInstanceLockError('orcad_instance_lock_held', 'held')) + ).toBe(ORCAD_EXIT_CONFIGURATION) + expect(resolveOrcadExitCode(new OrcadBindAddressError('bad'))).toBe(ORCAD_EXIT_CONFIGURATION) + expect(resolveOrcadExitCode(new Error('port in use'))).toBe(ORCAD_EXIT_FAILED) + expect(ORCAD_EXIT_CONFIGURATION).not.toBe(ORCAD_EXIT_FAILED) + }) +}) diff --git a/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts b/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts index a74c48b896bf..8210b0f06d40 100644 --- a/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts +++ b/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts @@ -518,6 +518,109 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { } }) + it('honours a pinned bind host over exposeNetworkByDefault (orcad --bind)', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + // Why both: an unattended host passes an explicit answer, and it must outrank every + // implicit widen — otherwise "default loopback" is only true until something else wins. + exposeNetworkByDefault: true, + pinnedBindHost: '127.0.0.1' + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + expect(new URL(server.getWebSocketEndpoint()!).hostname).toBe('127.0.0.1') + } finally { + await server.stop() + } + }) + + it('stays pinned to loopback even after a device has connected once', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why this case specifically: the unpinned default widens at the NEXT startup once any + // network-reach device has connected. A loopback orcad would therefore go wide one + // restart after its first client paired — silently, and without the operator asking. + const registry = new DeviceRegistry(userDataPath) + const device = registry.getOrCreatePendingDevice('CLI', 'runtime', 'network') + registry.updateLastSeen(device.deviceId) + + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '127.0.0.1' + }) + + await server.start() + try { + expect( + server + .getDeviceRegistry() + ?.listDevices() + .some((d) => d.lastSeenAt > 0) + ).toBe(true) + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + } finally { + await server.stop() + } + }) + + it('refuses a runtime-widening pairing offer while the bind is pinned to loopback', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '127.0.0.1' + }) + + await server.start() + try { + // A paired client can reach this RPC. Without the pin's refusal it would rebind the + // listener to every interface, undoing the operator's bind policy from the outside. + const offer = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(offer.available).toBe(false) + if (!offer.available) { + expect(offer.reason).toBe('network_exposure_failed') + } + expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1') + } finally { + await server.stop() + errorSpy.mockRestore() + } + }) + + it('still widens on request when the operator pinned the wide address', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '0.0.0.0' + }) + + await server.start() + try { + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + await server.ensureNetworkExposure() + expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0') + } finally { + await server.stop() + } + }) + it('refuses to widen a pairing offer that arrives after the server has stopped', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 3759ac356c1d..79d69c6fee2a 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -61,6 +61,12 @@ const DEFAULT_WS_PORT = 6768 const WS_BIND_HOST_LOOPBACK = '127.0.0.1' const WS_BIND_HOST_ALL_INTERFACES = '0.0.0.0' +// Why brackets: `ws://::1:6768` is not a URL, and every consumer of this endpoint parses it +// with `new URL`. An IPv6 bind address would otherwise publish an unparseable endpoint. +function formatWsEndpoint(host: string, port: number): string { + return `ws://${host.includes(':') ? `[${host}]` : host}:${port}` +} + type OrcaRuntimeRpcServerOptions = { runtime: OrcaRuntimeService userDataPath: string @@ -73,6 +79,16 @@ type OrcaRuntimeRpcServerOptions = { // Why: STA-2370 — bind the WS listener to all interfaces at startup instead of loopback-until-paired. // Only `orca serve` (explicit remote opt-in) and E2E set this; the desktop app widens lazily on pairing. exposeNetworkByDefault?: boolean + /** + * Pin the WS listener to exactly this address for the process's whole life. + * + * Why a pin and not another default: the two paths below both widen on their own — + * `exposeNetworkByDefault` at startup, and a device that has connected once at every + * later startup. An unattended host (orcad) whose operator asked for loopback must + * still be on loopback after a client pairs and the service restarts, so the answer + * has to outrank both, and `ensureNetworkExposure()` has to refuse rather than widen. + */ + pinnedBindHost?: string webClientRoot?: string // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). keepaliveIntervalMs?: number @@ -488,6 +504,7 @@ export class OrcaRuntimeRpcServer { private readonly wsPort: number private readonly preferPinnedWsPort: boolean private readonly exposeNetworkByDefault: boolean + private readonly pinnedBindHost: string | null private readonly webClientRoot: string | undefined // Why: STA-2370 — the host the WS listener is currently bound to, so pairing can widen loopback→all-interfaces once. private wsBoundHost: string | null = null @@ -546,6 +563,7 @@ export class OrcaRuntimeRpcServer { wsPort = DEFAULT_WS_PORT, preferPinnedWsPort = false, exposeNetworkByDefault = false, + pinnedBindHost, webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP, @@ -560,6 +578,7 @@ export class OrcaRuntimeRpcServer { this.wsPort = wsPort this.preferPinnedWsPort = preferPinnedWsPort this.exposeNetworkByDefault = exposeNetworkByDefault + this.pinnedBindHost = pinnedBindHost ?? null this.webClientRoot = webClientRoot this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap @@ -1244,6 +1263,9 @@ export class OrcaRuntimeRpcServer { // A grant minted for "This computer only" is excluded: its client is a browser on this machine, so // counting it would republish the runtime on every interface one restart after the user declined that. private resolveInitialWebSocketBindHost(): string { + if (this.pinnedBindHost) { + return this.pinnedBindHost + } if (this.exposeNetworkByDefault) { return WS_BIND_HOST_ALL_INTERFACES } @@ -1289,7 +1311,7 @@ export class OrcaRuntimeRpcServer { this.wsBoundHost = options.host return { transport: wsTransport, - endpoint: `ws://${options.host}:${wsTransport.resolvedPort}` + endpoint: formatWsEndpoint(options.host, wsTransport.resolvedPort) } } @@ -1362,6 +1384,14 @@ export class OrcaRuntimeRpcServer { // connected when a later LAN/QR offer opts in. Rebinding terminates them (ws cannot move a listener), // so the resolved port is reused — already-issued endpoints stay valid and clients reconnect in place. async ensureNetworkExposure(): Promise { + if (this.pinnedBindHost && this.pinnedBindHost !== WS_BIND_HOST_ALL_INTERFACES) { + // Why throw and not return: callers widen so they can ADVERTISE a LAN endpoint. Returning + // quietly would let them publish one that nothing can reach; the throw lands in their + // existing network_exposure_failed branch, which reports the offer unavailable instead. + throw new Error( + `Runtime bind address is pinned to ${this.pinnedBindHost}; refusing to widen to all interfaces` + ) + } if ( !this.enableWebSocket || this.stopping || diff --git a/src/main/server/serve-readiness.test.ts b/src/main/server/serve-readiness.test.ts index 7e037eba0256..3e4b77e695fc 100644 --- a/src/main/server/serve-readiness.test.ts +++ b/src/main/server/serve-readiness.test.ts @@ -4,6 +4,7 @@ import { ServeReadinessPublisher, type ServeReadiness } from './serve-readiness' +import type { OrcadHealth } from '../orcad/orcad-health' const ready: ServeReadiness = { runtimeId: 'runtime-1', @@ -21,6 +22,25 @@ const ready: ServeReadiness = { } } +const health: OrcadHealth = { + buildHash: 'abc123def4567890', + buildVersion: '1.4.0', + nodeVersion: '20.11.0', + nodeAbi: '115', + platform: 'linux', + arch: 'x64', + pid: 1234, + terminalDaemon: { + state: 'live', + ownsFreshSessions: true, + pid: 4242, + buildVersion: '1.4.0', + entryPath: '/opt/orcad/daemon-entry.js', + protocolVersion: 36, + selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 12 } + } +} + describe('ServeReadinessPublisher', () => { it('writes one complete human-readable ready block', async () => { const write = vi.fn(async () => {}) @@ -90,6 +110,50 @@ describe('ServeReadinessPublisher', () => { ).toThrow('websocket_unavailable. Choose an unused --port.') }) + it('omits the health block entirely when a host does not report one', () => { + const payload = JSON.parse(renderServeReadiness(ready, { mode: 'json' })) + // Why absent rather than a null/empty object: a reader must be able to tell "this host + // does not publish health" from "this host published a green verdict". + expect('health' in payload).toBe(false) + expect(renderServeReadiness(ready, { mode: 'human' })).not.toContain('Terminal daemon') + }) + + it('carries build identity, Node ABI and the daemon self-test in the JSON contract', () => { + const payload = JSON.parse(renderServeReadiness({ ...ready, health }, { mode: 'json' })) + expect(payload.health.buildHash).toBe('abc123def4567890') + expect(payload.health.nodeAbi).toBe('115') + expect(payload.health.terminalDaemon.selfTest).toEqual({ + ok: true, + coverage: 'pty-spawn', + verdict: 'healthy', + durationMs: 12 + }) + }) + + it('says out loud when the daemon self-test failed', () => { + const failed: ServeReadiness = { + ...ready, + health: { + ...health, + terminalDaemon: { + ...health.terminalDaemon, + state: 'degraded', + ownsFreshSessions: false, + selfTest: { + ok: false, + coverage: 'pty-spawn', + verdict: 'pty-spawn-unhealthy', + durationMs: 3_000 + } + } + } + } + const human = renderServeReadiness(failed, { mode: 'human' }) + // An operator reading the ready block must not have to infer this from a missing line. + expect(human).toContain('PTY self-test FAILED') + expect(human).toContain('terminals survive an orcad restart: NO') + }) + it('rejects concurrent and later duplicate publications', async () => { let finishWrite: (() => void) | undefined const publisher = new ServeReadinessPublisher( diff --git a/src/main/server/serve-readiness.ts b/src/main/server/serve-readiness.ts index e4ab3db89253..b0bdf14dabab 100644 --- a/src/main/server/serve-readiness.ts +++ b/src/main/server/serve-readiness.ts @@ -1,4 +1,5 @@ import type { PairingOfferUnavailableReason } from '../runtime/runtime-rpc' +import type { OrcadHealth } from '../orcad/orcad-health' export type ServePairingUnavailableReason = PairingOfferUnavailableReason | 'disabled_by_operator' @@ -24,6 +25,14 @@ export type ServeReadiness = { advertisedEndpoint: string | null managedWslCliReconciliation: 'pending' | 'settled' | 'failed' pairing: ServePairingReadiness + /** + * Build identity, Node ABI and the cross-process terminal-daemon self-test. + * + * Optional because the Electron `--serve` host does not publish one yet; readers must + * treat its absence as "not reported", never as healthy. Additive, so an older client + * parsing this payload is unaffected. + */ + health?: OrcadHealth } export type ServeReadinessOutput = @@ -77,7 +86,8 @@ export function renderServeReadiness( boundEndpoint: readiness.boundEndpoint, advertisedEndpoint: readiness.advertisedEndpoint, managedWslCliReconciliation: readiness.managedWslCliReconciliation, - pairing: readiness.pairing + pairing: readiness.pairing, + ...(readiness.health ? { health: readiness.health } : {}) }) } return renderHumanReadiness(readiness) @@ -89,6 +99,18 @@ function renderHumanReadiness(readiness: ServeReadiness): string { `Bound endpoint: ${readiness.boundEndpoint ?? 'websocket unavailable'}`, `Advertised endpoint: ${readiness.advertisedEndpoint ?? 'unavailable'}` ] + if (readiness.health) { + const daemon = readiness.health.terminalDaemon + lines.push( + `Build: ${readiness.health.buildVersion} (${readiness.health.buildHash}), Node ` + + `${readiness.health.nodeVersion} ABI ${readiness.health.nodeAbi}` + ) + lines.push( + `Terminal daemon: ${daemon.state} — PTY self-test ${daemon.selfTest.ok ? 'passed' : 'FAILED'}` + + ` (${daemon.selfTest.coverage}: ${daemon.selfTest.verdict})` + + `; terminals survive an orcad restart: ${daemon.ownsFreshSessions ? 'yes' : 'NO'}` + ) + } if (readiness.pairing.available) { if (readiness.pairing.webClientUrl) { lines.push(`Web client URL: ${readiness.pairing.webClientUrl}`)