From bbb0903f1e087b62fb42387febc8f13c310705b3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:34:35 -0700 Subject: [PATCH] test(daemon): add cross-protocol reattach coverage Runs the current DaemonPtyAdapter against real daemons built from every still-supported previous protocol version to ensure pane reattachment works across app updates without replacing shells or creating orphans. Includes 30 historical daemon builds plus working tree control, with bundle caching per commit and a dedicated CI job. --- .github/workflows/pr.yml | 34 ++ config/reliability-gates.jsonc | 89 ++++++ .../scripts/pr-workflow-parallelism.test.mjs | 21 ++ .../cross-protocol-daemon-reattach.test.ts | 271 ++++++++++++++++ src/main/daemon/legacy-daemon-release-refs.ts | 56 ++++ .../daemon/legacy-daemon-release-runtime.ts | 293 ++++++++++++++++++ 6 files changed, 764 insertions(+) create mode 100644 src/main/daemon/cross-protocol-daemon-reattach.test.ts create mode 100644 src/main/daemon/legacy-daemon-release-refs.ts create mode 100644 src/main/daemon/legacy-daemon-release-runtime.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c2c3119456b..5ceff36ced4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -200,6 +200,36 @@ jobs: src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/shared/posix-command-path-lookup.test.ts + daemon_cross_protocol: + name: daemon cross-protocol reattach + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + # Why: the spec builds each previous release's daemon from its own tag, + # so it needs full history and tags rather than the shallow default. + fetch-depth: 0 + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + + - name: Restore legacy daemon bundle cache + uses: actions/cache@v5 + with: + path: node_modules/.cache/orca-daemon-release-entries + key: legacy-daemons-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/main/daemon/legacy-daemon-release-refs.ts') }} + restore-keys: | + legacy-daemons-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- + + - name: Test current adapter against every previous daemon + run: | + pnpm exec vitest run --bail=1 --config config/vitest.config.ts \ + src/main/daemon/cross-protocol-daemon-reattach.test.ts + test: name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} runs-on: ubuntu-latest @@ -233,6 +263,7 @@ jobs: --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ + --exclude=src/main/daemon/cross-protocol-daemon-reattach.test.ts \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} package: @@ -398,6 +429,7 @@ jobs: - typecheck - git_compatibility - shell_contracts + - daemon_cross_protocol - test - package - package_windows @@ -419,6 +451,7 @@ jobs: TYPECHECK: ${{ needs.typecheck.result }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} + DAEMON_CROSS_PROTOCOL: ${{ needs.daemon_cross_protocol.result }} TEST: ${{ needs.test.result }} PACKAGE: ${{ needs.package.result }} PACKAGE_WINDOWS: ${{ needs.package_windows.result }} @@ -429,6 +462,7 @@ jobs: "$TYPECHECK" \ "$GIT_COMPATIBILITY" \ "$SHELL_CONTRACTS" \ + "$DAEMON_CROSS_PROTOCOL" \ "$TEST" \ "$PACKAGE" \ "$PACKAGE_WINDOWS"; do diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 585640c7b8d..6584e6c0019 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -8966,6 +8966,95 @@ "The live topology uses an SSH Git worktree; folder-workspace behavior is covered by target-scoped renderer reconciliation rather than a second headed topology." ], "demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault." + }, + { + "id": "terminal-daemon.cross-protocol-reattach", + "title": "The current adapter adopts a live pane from every still-supported daemon protocol without replacing or orphaning a shell", + "maturity": "experimental", + "protection": "partial", + "owner": "daemon-terminal", + "layer": "daemon-protocol-compatibility", + "surfaces": [ + "pane mount after an app update", + "attach-only spawn against a preserved daemon", + "legacy attach-only emulation", + "daemon session and process lifecycle" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local-daemon"], + "coveredPlatforms": ["macos", "linux"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "Each still-supported protocol is replayed by building its declared historical daemon entry and booting it under plain Node on a private socket, then driving it with the working tree's DaemonPtyAdapter pinned to that protocol. The working-tree build runs as a control case. Windows is skipped because the fixture drives the PTY with a POSIX shell command and reads daemon children through ps; SSH, WSL, and relay providers are out of scope because they do not speak the local daemon protocol.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/11789", + "https://github.com/stablyai/orca/pull/12351" + ], + "invariant": "For every protocol in PREVIOUS_DAEMON_PROTOCOL_VERSIONS plus PROTOCOL_VERSION, an attach-only spawn issued by the current build against a real daemon of that protocol must return the existing session, the same shell pid, and a snapshot still carrying output written before the attach. An attach-only spawn for an unknown session id must fail with a rejection the pane mount classifies as already-gone, must leave the daemon's session list and child-process set exactly as they were, and must not disturb the unrelated live session's liveness or pid. PREVIOUS_DAEMON_PROTOCOL_VERSIONS must end at PROTOCOL_VERSION - 1 and every entry must have a declared historical ref whose own source reports that protocol.", + "oracle": "For each protocol, extract src/main and src/shared at that protocol's shipped ref, bundle daemon-entry.ts against the current node_modules, and boot it on a temporary socket with VITEST cleared. Create a real PTY session through the current adapter, drive the shell until it emits a per-protocol marker, and disconnect. Attach-only through a fresh adapter and require the same session id, isReattach true, the original shell pid, the marker inside the returned snapshot, and an unchanged session list. Then attach-only to an absent id and require a rejection matching the message the mount path keys on, the SessionNotFoundError class on emulated protocols, the orphan session record and any created shell process to clear within a bounded settle, and the surviving session to keep its pid and liveness. Separately assert the manifest and the previous-version list agree with PROTOCOL_VERSION.", + "commands": [ + "pnpm exec vitest run --bail=1 --config config/vitest.config.ts src/main/daemon/cross-protocol-daemon-reattach.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cross-protocol-daemon-reattach.test.ts" + ], + "testFiles": ["src/main/daemon/cross-protocol-daemon-reattach.test.ts"], + "assertionRefs": [ + { + "file": "src/main/daemon/cross-protocol-daemon-reattach.test.ts", + "assertions": [ + "declares a release for every still-supported previous protocol", + "requires a protocol bump to extend the previous-version list", + "adopts the live session instead of replacing it", + "rejects a missing session without leaving an orphan behind" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cross-protocol-daemon-reattach.test.ts", + "durationSeconds": 18.6, + "summary": "64 tests passed against 30 historical daemon builds plus the working-tree control, with a warm bundle cache." + }, + { + "date": "2026-08-03", + "runner": "local", + "platform": "linux", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cross-protocol-daemon-reattach.test.ts", + "durationSeconds": 9.9, + "summary": "Reproduction of the daemon_cross_protocol job in a node:24 container from a --shared clone: 64 passed cold, 4.2s warm. The same container reverted PR 12351 and failed 60 of 64, so the gate bites on the CI platform, not only on macOS." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "31 sequential daemon builds and boots, each with one real PTY session" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Local macOS and containerized linux sweeps passed. The suite is new, so no CI or soak history exists yet. Two environmental sensitivities are known. Host pty exhaustion: the orphan case needs one pty beyond the live pane, and a saturated host fails it with an explicit posix_openpt error - macos caps ptmx at 511 where linux allows 4096, so this is effectively a developer-machine condition. Ambient SHELL: every daemon build resolves the PTY shell as env.SHELL then process.env.SHELL then /bin/zsh, so an unset SHELL on a zsh-less image failed 20 of 31 protocols; bootDaemon now pins SHELL to the first existing of /bin/bash and /bin/sh instead of inheriting it." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Two mutations of daemon-pty-adapter.ts were run against the suite. Restoring the #11789 hard throw for legacy attach-only failed 60 of 64 tests (all 30 legacy protocols, both cases). Deleting only the kill-and-throw so the emulated create is silently kept failed 30 of 64 - the exact silent-replacement mode the gate exists for. The adapter was restored and the suite returned to 64 passed." + }, + "performanceBudget": { + "required": false, + "evidence": "No product code is touched. The fixture bundles each release's daemon entry with esbuild and caches it under node_modules/.cache keyed by commit, so a warm run pays only for forks and PTY spawns. macOS measured about 32 seconds cold and 19 warm; linux, which is what the CI job runs on, measured 9.9 cold and 4.2 warm." + }, + "promotionCriteria": [ + "Accumulate CI history from the dedicated daemon_cross_protocol job; linux is covered by a containerized reproduction but has never run on a real GitHub runner.", + "Confirm the cold-cache runtime stays inside the runtime budget on CI hardware.", + "Keep the pid-identity, snapshot-marker, and orphan-settle assertions green as new protocols are appended." + ], + "knownGaps": [ + "Windows is skipped entirely: the marker command is POSIX shell and the orphan-process check reads ps, so ConPTY daemons get no coverage.", + "Only one representative ref per protocol is replayed, so other daemon builds within the same protocol are not exercised.", + "The daemon is bundled against the current node_modules rather than the release's own dependency tree, so a native-dependency behavior change between releases is not reproduced.", + "The orphan case needs one pty beyond the live pane; a host at its pty ceiling fails rather than skipping, which is deliberate but makes the gate sensitive to unrelated load." + ], + "demotionRule": "Keep experimental or demote if attach-only against a preserved daemon can return a different pid than the live pane, a failed attach can leave a session record or shell process behind, the previous-protocol list can grow without a matching release ref, or the gate flakes for reasons other than host pty exhaustion." } ] } diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index d1e90fad17a..32f2124cf7a 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -17,6 +17,7 @@ const patchedNodePtyContractFiles = [ 'src/main/daemon/node-pty-fd-leak.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts' ] +const crossProtocolDaemonTest = 'src/main/daemon/cross-protocol-daemon-reattach.test.ts' const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles] const testFilePatterns = [ 'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', @@ -53,6 +54,25 @@ describe('PR workflow parallelism', () => { for (const testFile of nativeShellContractFiles) { expect(testStep.run).toContain(`--exclude=${testFile}`) } + expect(testStep.run).toContain(`--exclude=${crossProtocolDaemonTest}`) + }) + + it('runs cross-protocol daemon coverage once with release history and a bundle cache', () => { + const job = workflow.jobs.daemon_cross_protocol + const checkout = job.steps.find((step) => step.name === 'Checkout') + const install = job.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + const cache = job.steps.find((step) => step.name === 'Restore legacy daemon bundle cache') + const testStep = job.steps.find( + (step) => step.name === 'Test current adapter against every previous daemon' + ) + + expect(checkout.with['fetch-depth']).toBe(0) + expect(install.with['native-runtime']).toBe('node') + expect(cache.with.path).toBe('node_modules/.cache/orca-daemon-release-entries') + expect(testStep.run).toContain('--bail=1') + expect(testStep.run).toContain(crossProtocolDaemonTest) }) it('runs real-zsh coverage once outside the general shards', () => { @@ -177,6 +197,7 @@ describe('PR workflow parallelism', () => { 'typecheck', 'git_compatibility', 'shell_contracts', + 'daemon_cross_protocol', 'test', 'package', 'package_windows' diff --git a/src/main/daemon/cross-protocol-daemon-reattach.test.ts b/src/main/daemon/cross-protocol-daemon-reattach.test.ts new file mode 100644 index 00000000000..4e2637db5f4 --- /dev/null +++ b/src/main/daemon/cross-protocol-daemon-reattach.test.ts @@ -0,0 +1,271 @@ +/** + * Runs the CURRENT build's DaemonPtyAdapter against a REAL daemon built from + * every still-supported previous release, plus the working tree as a control. + * + * Daemons survive app updates, so this pairing — new adapter, old daemon — is + * what every pre-existing pane hits after an update, and nothing else in the + * suite exercises it: the sibling adoption tests construct the current + * DaemonServer wearing an old protocol number, which cannot reproduce a + * daemon-side behavior change. #11789 shipped a total reattach failure through + * exactly that gap. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { SessionNotFoundError } from './daemon-errors' +import { + PREVIOUS_DAEMON_PROTOCOL_VERSIONS, + PROTOCOL_VERSION, + STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION +} from './daemon-protocol-version' +import { LEGACY_DAEMON_RELEASES } from './legacy-daemon-release-refs' +import { + bootDaemon, + buildDaemonEntryFromRef, + buildDaemonEntryFromWorkingTree +} from './legacy-daemon-release-runtime' +import type { BootedDaemon } from './legacy-daemon-release-runtime' +import type { PtySpawnResult } from '../providers/pty-spawn-result' + +const CASE_TIMEOUT_MS = 120_000 +const MARKER_TIMEOUT_MS = 30_000 +const SETTLE_TIMEOUT_MS = 15_000 +const MAX_CAPTURE_CHARS = 64 * 1024 + +// Why the working tree too: it proves the assertions below can be satisfied at +// all, so a legacy failure reads as a compatibility break rather than a broken +// harness. `ref: null` selects the build under test instead of a git ref. +const REATTACH_CASES = [ + ...LEGACY_DAEMON_RELEASES.map((release) => ({ + protocolVersion: release.protocolVersion, + ref: release.ref + })), + { protocolVersion: PROTOCOL_VERSION, ref: null } +] + +function identity(value: unknown): unknown { + return value +} + +async function waitUntil( + check: () => boolean | Promise, + timeoutMs: number, + failure: () => string +): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + if (await check()) { + return + } + if (Date.now() >= deadline) { + throw new Error(failure()) + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } +} + +describe('cross-protocol daemon reattach coverage', () => { + it('declares a release for every still-supported previous protocol', () => { + expect(LEGACY_DAEMON_RELEASES.map((release) => release.protocolVersion)).toEqual([ + ...PREVIOUS_DAEMON_PROTOCOL_VERSIONS + ]) + }) + + // Why: bumping PROTOCOL_VERSION without appending the outgoing version leaves + // the build users are still running untested — the #11789 shape exactly. + it('requires a protocol bump to extend the previous-version list', () => { + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS.at(-1)).toBe(PROTOCOL_VERSION - 1) + }) +}) + +// Why POSIX-only: the fixture drives the PTY with a shell command and reads the +// daemon's child processes via ps; neither has a Windows equivalent here. +describe.skipIf(process.platform === 'win32').each(REATTACH_CASES)( + 'current build against a protocol $protocolVersion daemon', + ({ protocolVersion, ref }) => { + const marker = `orca-reattach-${protocolVersion}` + const sessionId = `cross-protocol-pane-${protocolVersion}` + const missingSessionId = `cross-protocol-missing-${protocolVersion}` + let runtimeDir = '' + let daemon: BootedDaemon | null = null + let livePane: PtySpawnResult | null = null + let childPidsWithLivePane: Set | null = null + + const createAdapter = (): DaemonPtyAdapter => { + if (!daemon) { + throw new Error(`protocol ${protocolVersion} daemon is not running`) + } + return new DaemonPtyAdapter({ + socketPath: daemon.socketPath, + tokenPath: daemon.tokenPath, + protocolVersion + }) + } + + /** The pane as it existed before the update: created by whatever app build + * shipped with this daemon, then left running across the restart. It also + * has to exist before anything else, or the daemon retires as unadopted. */ + const createLivePane = async (): Promise => { + const creator = createAdapter() + try { + const created = await creator.spawn({ sessionId, cols: 80, rows: 24, cwd: runtimeDir }) + let output = '' + const stopListening = creator.onData((payload) => { + if (payload.id === sessionId) { + output = `${output}${payload.data}`.slice(-MAX_CAPTURE_CHARS) + } + }) + try { + await waitUntil( + () => { + // Re-sent because a shell still sourcing its rc files can drop + // the first line; extra copies only add ignored output. + creator.write(sessionId, `printf 'ok-%s\\n' ${marker}\r`) + return output.includes(`ok-${marker}`) + }, + MARKER_TIMEOUT_MS, + () => `protocol ${protocolVersion} shell never produced ok-${marker}: ${output}` + ) + } finally { + stopListening() + } + return created + } finally { + creator.dispose() + } + } + + const attachOnlyMissingSession = async (adapter: DaemonPtyAdapter): Promise => { + const failure: unknown = await adapter + .spawn({ + sessionId: missingSessionId, + cols: 120, + rows: 40, + cwd: runtimeDir, + attachOnly: true, + command: 'must-not-run' + }) + .then((result) => { + throw new Error(`attach-only created session ${result.id} instead of failing`) + }, identity) + // Why the message and not just the class: a native v31 rejection crosses + // the wire as a plain DaemonProtocolError, and the mount path classifies + // it by message (isPtyAlreadyGoneError in ipc/pty.ts) to retire the owner + // and respawn. #11789 failed here — terminal_pane_owner_unknown matched + // nothing, so the pane surfaced a raw code instead. + if (!(failure instanceof Error)) { + throw new Error(`attach-only rejected with a non-error value: ${String(failure)}`) + } + expect(failure.message).toMatch(/Session not found/i) + if (protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION) { + // The emulation raises it in-process, so the class survives too. + expect(failure).toBeInstanceOf(SessionNotFoundError) + } + } + + /** Pre-31 daemons have no attach-only, so the adapter emulates it by creating + * and then killing — and the kill returns before the daemon retires the + * record, hence a bounded settle rather than a single sample. */ + const expectNoResidue = async (adapter: DaemonPtyAdapter): Promise => { + let sessionIds: string[] = [] + await waitUntil( + async () => { + sessionIds = (await adapter.listSessions()).map((session) => session.sessionId) + return !sessionIds.includes(missingSessionId) + }, + SETTLE_TIMEOUT_MS, + () => `protocol ${protocolVersion} kept an orphan session: ${sessionIds.join(', ')}` + ) + expect(sessionIds).toEqual([sessionId]) + + let childPids: Set | null = null + await waitUntil( + () => { + childPids = daemon?.childPids() ?? null + return childPids === null || childPids.size === (childPidsWithLivePane?.size ?? 0) + }, + SETTLE_TIMEOUT_MS, + () => { + const pids: number[] = [...(childPids ?? [])] + return `protocol ${protocolVersion} left an orphan process: ${pids.join(', ')}` + } + ) + expect(childPids).toEqual(childPidsWithLivePane) + } + + beforeAll(async () => { + const build = ref ? await buildDaemonEntryFromRef(ref) : null + // A manifest entry naming the wrong ref would silently retest some other + // protocol, so trust the version read out of that ref's own source. + expect(build?.protocolVersion ?? PROTOCOL_VERSION).toBe(protocolVersion) + runtimeDir = mkdtempSync(join(tmpdir(), `cross-protocol-daemon-${protocolVersion}-`)) + daemon = await bootDaemon({ + entryPath: build ? build.entryPath : await buildDaemonEntryFromWorkingTree(), + runtimeDir + }) + livePane = await createLivePane() + childPidsWithLivePane = daemon.childPids() + }, CASE_TIMEOUT_MS) + + afterAll(async () => { + await daemon?.stop() + daemon = null + if (runtimeDir) { + rmSync(runtimeDir, { recursive: true, force: true }) + } + }) + + it( + 'adopts the live session instead of replacing it', + async () => { + const adapter = createAdapter() + try { + const attached = await adapter.spawn({ + sessionId, + cols: 120, + rows: 40, + attachOnly: true, + command: 'must-not-run' + }) + + expect(attached.id).toBe(sessionId) + expect(attached.isReattach).toBe(true) + // The load-bearing one: a replacement shell would be a different + // process, which is what "looks like success but lost everything" + // means at the OS level. + expect(attached.pid).toBe(livePane?.pid) + expect(attached.snapshot).toContain(`ok-${marker}`) + + const sessions = await adapter.listSessions() + expect(sessions.map((session) => session.sessionId)).toEqual([sessionId]) + } finally { + adapter.dispose() + } + }, + CASE_TIMEOUT_MS + ) + + it( + 'rejects a missing session without leaving an orphan behind', + async () => { + const adapter = createAdapter() + try { + await attachOnlyMissingSession(adapter) + await expectNoResidue(adapter) + + // A kill aimed at the wrong id would satisfy everything above. + const survivor = (await adapter.listSessions()).find( + (session) => session.sessionId === sessionId + ) + expect(survivor?.isAlive).toBe(true) + expect(survivor?.pid).toBe(livePane?.pid) + } finally { + adapter.dispose() + } + }, + CASE_TIMEOUT_MS + ) + } +) diff --git a/src/main/daemon/legacy-daemon-release-refs.ts b/src/main/daemon/legacy-daemon-release-refs.ts new file mode 100644 index 00000000000..26f02fd2a78 --- /dev/null +++ b/src/main/daemon/legacy-daemon-release-refs.ts @@ -0,0 +1,56 @@ +/** + * A representative build for a still-running daemon of each previous protocol. + * + * A daemon survives the app update that replaces it, so after an update every + * pre-existing pane is served by a daemon built from OLD code. Reconstructing + * that daemon by pointing the current `DaemonServer` at an old protocol number + * only tests the current implementation wearing an old label — it cannot catch a + * behavior change on the daemon side (#11789 shipped exactly that blind spot). + * These refs let the cross-protocol reattach gate run the current adapter + * against the real previous build. + * + * Each entry is the newest ref that shipped that protocol when one exists, so + * within-protocol daemon fixes are included. Protocol 27 instead pins its last + * main commit because it was superseded before release. + */ +export type LegacyDaemonRelease = { + protocolVersion: number + /** Newest release tag built at this protocol, or a commit when none shipped. */ + ref: string +} + +// Why refs and not tags-only: protocols 6, 19 and 27 were superseded before a +// stable release cut, so their newest shipped artifact is an rc tag or — for 27, +// which never left main — the commit that carried it. +export const LEGACY_DAEMON_RELEASES: readonly LegacyDaemonRelease[] = [ + { protocolVersion: 1, ref: 'v1.3.16' }, + { protocolVersion: 2, ref: 'v1.3.19' }, + { protocolVersion: 3, ref: 'v1.3.21' }, + { protocolVersion: 4, ref: 'v1.3.47' }, + { protocolVersion: 5, ref: 'v1.4.2' }, + { protocolVersion: 6, ref: 'v1.4.2-rc.8' }, + { protocolVersion: 7, ref: 'v1.4.27' }, + { protocolVersion: 8, ref: 'v1.4.29' }, + { protocolVersion: 9, ref: 'v1.4.32' }, + { protocolVersion: 10, ref: 'v1.4.35' }, + { protocolVersion: 11, ref: 'v1.4.55' }, + { protocolVersion: 12, ref: 'v1.4.65' }, + { protocolVersion: 13, ref: 'v1.4.72' }, + { protocolVersion: 14, ref: 'v1.4.78' }, + { protocolVersion: 15, ref: 'v1.4.79' }, + { protocolVersion: 16, ref: 'v1.4.80' }, + { protocolVersion: 17, ref: 'v1.4.90' }, + { protocolVersion: 18, ref: 'v1.4.134' }, + { protocolVersion: 19, ref: 'v1.4.135-rc.2' }, + { protocolVersion: 20, ref: 'v1.4.135' }, + { protocolVersion: 21, ref: 'v1.4.141' }, + { protocolVersion: 22, ref: 'v1.4.145' }, + { protocolVersion: 23, ref: 'v1.4.146' }, + { protocolVersion: 24, ref: 'v1.4.149' }, + { protocolVersion: 25, ref: 'v1.4.150' }, + { protocolVersion: 26, ref: 'v1.4.155' }, + { protocolVersion: 27, ref: '7ab601487c2e4c05b7067a5157e395300a0bdc0c' }, + { protocolVersion: 28, ref: 'v1.4.159' }, + { protocolVersion: 29, ref: 'v1.4.161' }, + { protocolVersion: 30, ref: 'v1.4.167' } +] diff --git a/src/main/daemon/legacy-daemon-release-runtime.ts b/src/main/daemon/legacy-daemon-release-runtime.ts new file mode 100644 index 00000000000..17948fc7108 --- /dev/null +++ b/src/main/daemon/legacy-daemon-release-runtime.ts @@ -0,0 +1,293 @@ +/** + * Builds and boots a daemon from an arbitrary ref of this repo under plain Node. + * + * Shares its shape with config/scripts/daemon-boot-smoke.mjs — fork the entry as + * a plain-Node child, wait for the `ready` IPC message, stop it on SIGTERM — but + * sources the entry from a git ref instead of out/main so the current build can + * be exercised against the daemon a user is actually still running. + * + * Only the daemon's own source is rebuilt; node-pty and the rest of the runtime + * resolve from the current node_modules, which is what an in-place app update + * leaves behind anyway (the daemon process keeps running, its native deps do + * not get re-resolved). Bundles are cached per commit so a warm PR CI run pays + * for the fork alone. + */ +import { execFileSync, fork } from 'node:child_process' +import type { ChildProcess } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { build } from 'esbuild' + +const CACHE_ROOT = join(process.cwd(), 'node_modules', '.cache', 'orca-daemon-release-entries') +// Why only these trees: the daemon entry's import graph never leaves them, and +// archiving all of src/ costs roughly twice as long per ref. +const DAEMON_SOURCE_TREES = ['src/main', 'src/shared'] +const DAEMON_ENTRY_SOURCE = 'src/main/daemon/daemon-entry.ts' +// Why two: PROTOCOL_VERSION lived in types.ts until it was split out at v31's lineage. +const PROTOCOL_SOURCE_PATHS = [ + 'src/main/daemon/daemon-protocol-version.ts', + 'src/main/daemon/types.ts' +] +const PROTOCOL_VERSION_PATTERN = /^export const PROTOCOL_VERSION = (\d+)/m + +const READY_TIMEOUT_MS = 30_000 +const STOP_TIMEOUT_MS = 10_000 + +// Why pin the shell: every daemon build back to v1.3.16 resolves the PTY shell as +// `env.SHELL || process.env.SHELL || '/bin/zsh'`, and SHELL is unset for a non-bash +// child on CI images that have no zsh. Left ambient, the fixture passes on a dev Mac +// and fails on the runner. +const FIXTURE_SHELL_CANDIDATES = ['/bin/bash', '/bin/sh'] +const KILL_TIMEOUT_MS = 5_000 +const MAX_STDERR_CHARS = 64 * 1024 + +export type DaemonEntryBuild = { + entryPath: string + protocolVersion: number +} + +export type BootedDaemon = { + pid: number + socketPath: string + tokenPath: string + /** PTY shells the daemon owns, or null where the platform has no ps. */ + childPids(): Set | null + stop(): Promise +} + +function git(args: string[]): string { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function resolveCommit(ref: string): string { + try { + return git(['rev-parse', '--verify', `${ref}^{commit}`]).trim() + } catch { + throw new Error( + `Cannot resolve daemon release ref "${ref}". This gate replays real previous builds, so it ` + + 'needs full history and tags — clone with fetch-depth 0 (or run `git fetch --tags`).' + ) + } +} + +function readProtocolVersionAt(ref: string): number { + for (const path of PROTOCOL_SOURCE_PATHS) { + let source: string + try { + source = git(['show', `${ref}:${path}`]) + } catch { + continue + } + const match = source.match(PROTOCOL_VERSION_PATTERN) + if (match) { + return Number(match[1]) + } + } + throw new Error(`Could not read PROTOCOL_VERSION from ref "${ref}"`) +} + +async function bundleDaemonEntry(entrySourcePath: string, outfile: string): Promise { + await build({ + entryPoints: [entrySourcePath], + outfile, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + // Why external: the bundle lives under node_modules/.cache so Node resolves + // every dependency — including the native node-pty binding — from the + // current install, exactly as a surviving daemon process still holds them. + packages: 'external', + logLevel: 'silent' + }) +} + +function extractSourceTrees(ref: string, destination: string): void { + const archivePath = join(destination, 'source.tar') + mkdirSync(destination, { recursive: true }) + git(['archive', '--format=tar', '-o', archivePath, ref, ...DAEMON_SOURCE_TREES]) + execFileSync('tar', ['-xf', archivePath, '-C', destination], { stdio: 'ignore' }) + rmSync(archivePath, { force: true }) +} + +function readCachedProtocolVersion(stampPath: string, commit: string): number | null { + try { + const stamp: unknown = JSON.parse(readFileSync(stampPath, 'utf8')) + if ( + typeof stamp !== 'object' || + stamp === null || + !('commit' in stamp) || + stamp.commit !== commit || + !('protocolVersion' in stamp) || + !Number.isSafeInteger(stamp.protocolVersion) || + Number(stamp.protocolVersion) < 1 + ) { + return null + } + return Number(stamp.protocolVersion) + } catch { + return null + } +} + +/** Builds the daemon entry as it existed at `ref`, reusing a cached bundle per commit. */ +export async function buildDaemonEntryFromRef(ref: string): Promise { + const commit = resolveCommit(ref) + const cacheDir = join(CACHE_ROOT, commit) + const entryPath = join(cacheDir, 'daemon-entry.js') + const stampPath = join(cacheDir, 'release.json') + if (existsSync(entryPath) && existsSync(stampPath)) { + const protocolVersion = readCachedProtocolVersion(stampPath, commit) + if (protocolVersion !== null) { + return { entryPath, protocolVersion } + } + } + const protocolVersion = readProtocolVersionAt(commit) + const sourceDir = join(cacheDir, 'source') + rmSync(cacheDir, { recursive: true, force: true }) + try { + extractSourceTrees(commit, sourceDir) + await bundleDaemonEntry(join(sourceDir, DAEMON_ENTRY_SOURCE), entryPath) + } finally { + rmSync(sourceDir, { recursive: true, force: true }) + } + writeFileSync(stampPath, `${JSON.stringify({ ref, commit, protocolVersion })}\n`) + return { entryPath, protocolVersion } +} + +/** Builds the daemon entry from the working tree — the build under test. */ +export async function buildDaemonEntryFromWorkingTree(): Promise { + const entryPath = join(CACHE_ROOT, 'working-tree', 'daemon-entry.js') + await bundleDaemonEntry(join(process.cwd(), DAEMON_ENTRY_SOURCE), entryPath) + return entryPath +} + +// Why ps and not a pid file: the assertion is about PTY shells the daemon owns, +// which only exist as its children. Windows has no equivalent one-liner, so the +// caller degrades to the protocol-level session listing there. +export function listChildPids(parentPid: number): Set | null { + if (process.platform === 'win32') { + return null + } + const output = execFileSync('ps', ['-o', 'pid=,ppid=', '-ax'], { encoding: 'utf8' }) + const children = new Set() + for (const line of output.split('\n')) { + const [pid, ppid] = line.trim().split(/\s+/).map(Number) + if (ppid === parentPid && Number.isFinite(pid)) { + children.add(pid) + } + } + return children +} + +function hasExited(child: ChildProcess): boolean { + return child.exitCode !== null || child.signalCode !== null +} + +function waitForExit(child: ChildProcess, timeoutMs: number): Promise { + if (hasExited(child)) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + const finish = (exited: boolean): void => { + clearTimeout(timer) + child.off('exit', onExit) + resolve(exited) + } + const onExit = (): void => finish(true) + const timer = setTimeout(() => finish(false), timeoutMs) + child.once('exit', onExit) + }) +} + +function resolveFixtureShell(): string { + const shell = FIXTURE_SHELL_CANDIDATES.find((candidate) => existsSync(candidate)) + if (!shell) { + throw new Error( + `No POSIX shell available for the daemon fixture; tried ${FIXTURE_SHELL_CANDIDATES.join(', ')}` + ) + } + return shell +} + +export async function bootDaemon(options: { + entryPath: string + runtimeDir: string +}): Promise { + const socketPath = join(options.runtimeDir, 'daemon.sock') + const tokenPath = join(options.runtimeDir, 'daemon.token') + const env: NodeJS.ProcessEnv = { + ...process.env, + ORCA_USER_DATA_PATH: options.runtimeDir, + SHELL: resolveFixtureShell() + } + // Why: daemon-entry only runs main() when VITEST is unset, so an inherited + // VITEST leaves a live-but-silent process that never binds the socket. + delete env.VITEST + const child = fork(options.entryPath, ['--socket', socketPath, '--token', tokenPath], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + // Why: Vitest's own execArgv would be reapplied to a plain-Node daemon. + execArgv: [], + env + }) + let stderr = '' + child.stderr?.on('data', (chunk: Buffer) => { + stderr = `${stderr}${chunk.toString('utf8')}`.slice(-MAX_STDERR_CHARS) + }) + + const stop = async (): Promise => { + if (hasExited(child)) { + return + } + const gracefulExit = waitForExit(child, STOP_TIMEOUT_MS) + child.kill('SIGTERM') + if (await gracefulExit) { + return + } + const forcedExit = waitForExit(child, KILL_TIMEOUT_MS) + child.kill('SIGKILL') + if (!(await forcedExit)) { + throw new Error(`daemon ${child.pid ?? 'unknown'} did not exit after SIGKILL`) + } + } + + try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`daemon did not signal ready in ${READY_TIMEOUT_MS}ms\n${stderr}`)), + READY_TIMEOUT_MS + ) + child.on('message', (message: { type?: string }) => { + if (message?.type === 'ready') { + clearTimeout(timer) + resolve() + } + }) + child.on('error', (error) => { + clearTimeout(timer) + reject(new Error(`daemon fork failed: ${error.message}\n${stderr}`)) + }) + child.on('exit', (code, signal) => { + clearTimeout(timer) + reject(new Error(`daemon exited before ready (code=${code}, signal=${signal})\n${stderr}`)) + }) + }) + } catch (error) { + await stop() + throw error + } + + const pid = child.pid + if (pid === undefined) { + await stop() + throw new Error('daemon signaled ready without a process id') + } + + return { + pid, + socketPath, + tokenPath, + childPids: () => listChildPids(pid), + stop + } +}