diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8dad06a1faf..517c77c9ddd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -176,7 +176,21 @@ jobs: # Native cache misses need the compiler, Electron needs Xvfb, and paired # Quick Open needs ripgrep. Install them in one apt transaction per shard. - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep xvfb zsh openbox x11-utils + # The Azure archive took 16 minutes for one font package; bound setup + # separately so a slow mirror cannot consume the shard's test budget. + run: &install_e2e_tools | + for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + if [ -f "$source" ]; then + sudo sed -i 's|https*://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" + fi + done + sudo tee /etc/apt/apt.conf.d/99-orca-e2e >/dev/null <<'APTCONF' + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + Acquire::Retries "1"; + APTCONF + timeout 120 sudo apt-get update + timeout 300 sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: @@ -241,7 +255,7 @@ jobs: # them as an artifact makes post-mortem debugging on CI possible without # re-running locally. - name: Upload Playwright traces - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: playwright-traces-${{ matrix.shard_name }} @@ -267,7 +281,7 @@ jobs: # unbounded inventory fallback; the paired fixture exercises that real boundary. # Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this # lane now receives those specs from pr.yml's SSH source mapping. - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies with: @@ -344,7 +358,7 @@ jobs: pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}" - name: Upload Playwright traces - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: playwright-traces-changed @@ -403,7 +417,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} - name: Install native build and headless UI tools - run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh openbox x11-utils + run: *install_e2e_tools - uses: ./.github/actions/install-node-dependencies with: @@ -459,7 +473,7 @@ jobs: fi - name: Upload watcher isolation traces - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }} @@ -544,7 +558,7 @@ jobs: ORCA_E2E_FORWARD_APP_LOGS: '1' run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1 - uses: actions/upload-artifact@v7 - if: failure() + if: failure() || cancelled() with: name: localhost-ssh-traces path: test-results/ diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 0a2840c60a1..449ad212864 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -18,8 +18,7 @@ on: - '.github/actions/install-node-dependencies/**' - '.github/actions/prepare-native-runtime/**' - '.github/workflows/node-server-tests.yml' - # The pull request qualifies one platform for an unflavoured change; this is where all six - # are re-qualified, so a platform break surfaces minutes after merge instead of next cron. + # Relevant main pushes qualify every platform after the dependency check. push: branches: [main] paths: @@ -60,15 +59,16 @@ on: permissions: contents: read -# Why a run-scoped group for template builds: a release call shares github.ref with main's push -# runs, and cancelling either would drop a release's template or a main qualification. +# Main pushes must finish detection before they can supersede relevant qualification. concurrency: - group: node-server-${{ inputs.build_template && format('template-{0}', github.run_id) || github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ !inputs.build_template }} + group: node-server-${{ (inputs.build_template || github.event_name == 'push') && format('run-{0}', github.run_id) || github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ !inputs.build_template && github.event_name != 'push' }} jobs: changes: - if: github.event_name == 'pull_request' + if: >- + github.event_name == 'push' || + (github.event_name == 'pull_request' && github.event.pull_request.draft != true) runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -84,7 +84,20 @@ jobs: - name: Detect headless-server build and test inputs id: scope shell: bash + env: + PUSH_BASE: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} run: | + if [ "$EVENT_NAME" = push ]; then + # Compare the entire push, including multi-commit pushes and removed files. + if git fetch --no-tags --depth=1 origin "$PUSH_BASE" && + git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes"; then + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification + else + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + exit 0 + fi # Compare the tested merge with its base, retaining both sides of renames. if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes"; then node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" @@ -94,6 +107,9 @@ jobs: persistence: needs: changes + concurrency: + group: node-server-persistence-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # Missing/failed detection runs the full matrix; manual runs remain unconditional. # A draft carries no platform verdict; readiness re-triggers this workflow. Spelled against # the event name so the push and schedule paths do not rest on a null property comparison. @@ -126,10 +142,10 @@ jobs: continue-on-error: true shell: bash run: node config/scripts/orcad-windows-prebuild-cache.mjs --fingerprint - - name: Restore the exact Windows server prebuild for this pull request + - name: Restore the exact Windows server prebuild id: orcad-prebuild-cache-restore if: >- - github.event_name == 'pull_request' && + (github.event_name == 'pull_request' || github.event_name == 'push') && steps.orcad-prebuild-cache-identity.outcome == 'success' && steps.orcad-prebuild-cache-identity.outputs.key != '' continue-on-error: true @@ -234,6 +250,9 @@ jobs: linux_glibc_floor: needs: [changes, persistence] + concurrency: + group: node-server-linux_glibc_floor-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # A failed smoke already blocks qualification; missing scope still selects every platform. if: >- ${{ !cancelled() && needs.persistence.result == 'success' && @@ -262,7 +281,17 @@ jobs: PYTHON: /opt/python/cp312-cp312/bin/python3 steps: - name: Install glibc 2.28 prerequisites - run: dnf install -y git procps-ng unzip which xz + run: | + missing_tool=false + for tool in git ps unzip which xz; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing_tool=true + fi + done + if [ "$missing_tool" = true ]; then + # The image's source-built Git needs no RPM; missing tools come from AlmaLinux. + dnf --disablerepo='epel*' install -y git procps-ng unzip which xz + fi - uses: actions/checkout@v6 with: ref: ${{ inputs.ref }} @@ -289,6 +318,9 @@ jobs: linux_glibc217_compat: needs: [changes, persistence] + concurrency: + group: node-server-linux_glibc217_compat-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # A failed smoke already blocks qualification; missing scope still selects every platform. if: >- ${{ !cancelled() && needs.persistence.result == 'success' && @@ -341,6 +373,9 @@ jobs: linux_musl: needs: [changes, persistence] + concurrency: + group: node-server-linux_musl-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} # A failed smoke already blocks qualification; missing scope still selects every platform. if: >- ${{ !cancelled() && needs.persistence.result == 'success' && diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index d190855f7c0..342e5ad7c3a 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -17,6 +17,7 @@ const BUILD_SCRIPTS = [ 'config/scripts/pinned-node-downloads.mjs', 'config/scripts/build-orcad.mjs', 'config/scripts/build-orcad-prebuilds.mjs', + 'config/scripts/orcad-windows-prebuild-cache.mjs', 'config/scripts/orcad-prebuild-smoke-child.cjs', 'config/scripts/build-windows-process-tree-relay-addon.mjs', 'config/scripts/run-node-server-tests.mjs', @@ -133,7 +134,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) const result = await classifyNodeServerChanges(changedFiles) console.log(result.reason) - const policy = nodeServerQualification(changedFiles, result) + const policy = nodeServerQualification(changedFiles, result, { + fullQualification: process.argv.includes('--full-qualification') + }) const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, output) diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 78b511dc1a8..6c1903f8031 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -113,6 +113,7 @@ describe('the actual Bun build and profile-test dependency graph', () => { 'src/main/worker-thread-entry-path.ts', 'config/scripts/zip-extractor-command.mjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', + 'config/scripts/orcad-windows-prebuild-cache.mjs', 'config/scripts/profile-state-worker-smoke.mjs', 'config/scripts/vitest-host-ports-setup.ts', 'tests/e2e/daemon-running-work-probe.unit.test.ts' @@ -131,16 +132,22 @@ describe('the actual Bun build and profile-test dependency graph', () => { }) }) -it('keeps all ten platform jobs and runs them when detection is skipped or fails', () => { +it('keeps every platform job and runs them when detection is skipped or fails', () => { const workflow = parse( readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8') ) expect(workflow.on).toHaveProperty('workflow_dispatch') - expect(workflow.jobs.changes.if).toBe("github.event_name == 'pull_request'") + expect(workflow.jobs.changes.if).toBe( + "github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft != true)" + ) expect(workflow.jobs.changes.steps[0].with['fetch-depth']).toBe(2) expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false) const detect = workflow.jobs.changes.steps.find((step) => step.id === 'scope') expect(detect.run).toContain('git diff --name-only --no-renames -z HEAD^1 HEAD') + expect(detect.env.PUSH_BASE).toBe('${{ github.event.before }}') + expect(detect.run).toContain('git fetch --no-tags --depth=1 origin "$PUSH_BASE"') + expect(detect.run).toContain('git diff --name-only --no-renames -z "$PUSH_BASE" HEAD') + expect(detect.run).toContain('node-server-changes" --full-qualification') expect(workflow.on.pull_request.types).toContain('ready_for_review') expect(workflow.on.schedule).toHaveLength(1) // A pull request may qualify one platform, so the merged commit must re-qualify all six. diff --git a/config/scripts/node-server-concurrency.test.mjs b/config/scripts/node-server-concurrency.test.mjs new file mode 100644 index 00000000000..92c863361d5 --- /dev/null +++ b/config/scripts/node-server-concurrency.test.mjs @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { expect, it } from 'vitest' +import { parse } from 'yaml' + +const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) + +function context(event, runId, inputs = {}) { + return { + github: { + event_name: event, + run_id: runId, + ref: 'refs/heads/main', + event: { pull_request: { number: 123 } } + }, + inputs: { build_template: false, ref: '', ...inputs }, + matrix: { os: 'windows-2022' }, + format: (template, value) => template.replace('{0}', value) + } +} + +function expression(source, ctx) { + return runInNewContext(source.slice(3, -2).trim(), ctx) +} + +function group(policy, ctx) { + return policy.group.replace(/\$\{\{([\s\S]*?)\}\}/g, (_match, source) => + String(runInNewContext(source, ctx)) + ) +} + +it('skips draft detection and rechecks the same draft once it is ready', () => { + const draft = context('pull_request', 1) + draft.github.event.pull_request.draft = true + expect(runInNewContext(workflow.jobs.changes.if, draft)).toBe(false) + draft.github.event.pull_request.draft = false + expect(runInNewContext(workflow.jobs.changes.if, draft)).toBe(true) + expect(workflow.on.pull_request.types).toContain('ready_for_review') + expect(runInNewContext(workflow.jobs.changes.if, context('push', 2))).toBe(true) + for (const event of ['schedule', 'workflow_dispatch', 'workflow_call']) { + expect(runInNewContext(workflow.jobs.changes.if, context(event, 2))).toBe(false) + } +}) + +it('lets main pushes finish detection without cancelling another push', () => { + const first = context('push', 1) + const second = context('push', 2) + expect(group(workflow.concurrency, first)).not.toBe(group(workflow.concurrency, second)) + expect(expression(workflow.concurrency['cancel-in-progress'], first)).toBe(false) +}) + +it('still replaces superseded pull requests at workflow level', () => { + const first = context('pull_request', 1) + const second = context('pull_request', 2) + expect(group(workflow.concurrency, first)).toBe(group(workflow.concurrency, second)) + expect(expression(workflow.concurrency['cancel-in-progress'], first)).toBe(true) +}) + +it.each(['persistence', 'linux_glibc_floor', 'linux_glibc217_compat', 'linux_musl'])( + '%s only supersedes eligible main qualification, isolating releases and nightly runs', + (name) => { + const job = workflow.jobs[name] + expect(job.if).toContain("needs.changes.outputs.should_run != 'false'") + const policy = job.concurrency + const first = context('push', 1) + const second = context('push', 2) + expect(group(policy, first)).toBe(group(policy, second)) + expect(expression(policy['cancel-in-progress'], first)).toBe(true) + for (const [event, inputs] of [ + ['schedule', {}], + ['workflow_dispatch', {}], + ['push', { build_template: true }], + ['push', { ref: 'refs/tags/v1' }] + ]) { + const isolated = context(event, 2, inputs) + expect(group(policy, isolated)).not.toBe(group(policy, first)) + expect(expression(policy['cancel-in-progress'], isolated)).toBe(false) + expect(group(policy, isolated)).not.toBe(group(policy, context(event, 3, inputs))) + } + if (job.strategy?.matrix) { + second.matrix.os = 'windows-11-arm' + expect(group(policy, second)).not.toBe(group(policy, first)) + } + } +) diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index b1faf6eef17..dc409a1c84d 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -7,42 +7,66 @@ export const NODE_SERVER_RUNNERS = [ 'windows-11-arm' ] -// Only surfaces whose behaviour actually differs per platform. Escalating on `config/`, -// `resources/` and `.github/` wholesale took 36.5% of the last 1100 commits through all six -// platforms where a platform-flavoured predicate takes 19%. -const PLATFORM_PREFIXES = [ +// Shared execution and storage changes need every host; explicit platform paths need their family. +const BUILD_PREFIXES = [ 'native/', 'config/patches/', '.github/actions/install-node-dependencies/', - '.github/actions/prepare-native-runtime/', + '.github/actions/prepare-native-runtime/' +] +// A remote target's OS does not identify the client platform that builds its commands. +const CROSS_HOST_PREFIXES = ['src/main/ssh/', 'src/main/providers/', 'src/relay/'] +const PLATFORM_PREFIXES = [ 'src/main/persistence/', 'src/main/sqlite/', 'src/main/orcad/', - 'src/main/providers/', 'src/main/daemon/', - 'src/main/ssh/', 'src/main/wsl/', - 'src/relay/', - 'src/shared/child-process/', - // Every native prebuild slot is compiled and smoked against the pinned runtime. - 'src/shared/node-runtime-pin.ts' + 'src/shared/child-process/' ] -export function nodeServerQualification(changedFiles, scope) { - const platformSpecific = changedFiles.some( - (file) => - // A root manifest can move a native dependency on every platform at once. +const PLATFORM_FAMILIES = [ + { pattern: /(?:^|[/.-])(?:windows|win32|wsl)(?:[/.-]|$)/i, prefix: 'windows-' }, + { pattern: /(?:^|[/.-])(?:macos|darwin|posix)(?:[/.-]|$)/i, prefix: 'macos-' }, + { pattern: /(?:^|[/.-])(?:linux|posix)(?:[/.-]|$)/i, prefix: 'ubuntu-' } +] + +export function nodeServerQualification(changedFiles, scope, { fullQualification = false } = {}) { + const selected = new Set(['ubuntu-22.04']) + let qualification = false + let full = fullQualification || changedFiles.length === 0 || scope.graphUnavailable === true + for (const file of changedFiles) { + // Build policy and native sources can change every slot, even with a platform in the name. + if ( !file.includes('/') || - PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix)) || - /(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun|prebuilds?)(?:[/.-]|$)/i.test( - file - ) - ) - // A pull request qualifies one platform unless the change is platform-flavoured; the push to - // main re-qualifies all six, so an unescalated miss surfaces minutes after merge, not a day. - const full = changedFiles.length === 0 || scope.graphUnavailable === true || platformSpecific + BUILD_PREFIXES.some((prefix) => file.startsWith(prefix)) || + CROSS_HOST_PREFIXES.some((prefix) => file.startsWith(prefix)) || + (/(?:^|[/.-])(?:remote|ssh)(?:[/.-]|$)/i.test(file) && + PLATFORM_FAMILIES.some(({ pattern }) => pattern.test(file))) || + file === 'src/shared/node-runtime-pin.ts' || + file === '.github/workflows/node-server-tests.yml' || + file.startsWith('config/scripts/node-server-') || + /(?:^|[/.-])(?:bun|prebuilds?)(?:[/.-]|$)/i.test(file) + ) { + full = true + continue + } + const families = PLATFORM_FAMILIES.filter(({ pattern }) => pattern.test(file)) + if (families.length > 0) { + for (const { prefix } of families) { + for (const runner of NODE_SERVER_RUNNERS.filter((runner) => runner.startsWith(prefix))) { + selected.add(runner) + } + qualification ||= prefix === 'ubuntu-' + } + } else if (PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix))) { + full = true + } + } return { - qualification: full, - runners: full ? NODE_SERVER_RUNNERS : ['ubuntu-22.04'] + qualification: full || qualification, + runners: full + ? NODE_SERVER_RUNNERS + : NODE_SERVER_RUNNERS.filter((runner) => selected.has(runner)) } } diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index 480d7e45912..9c3c0bd7b27 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -34,11 +34,8 @@ it.each([ 'src/main/persistence/profile-state/store.ts', 'src/main/sqlite/database.ts', 'src/main/orcad/entry.ts', - 'src/main/runtime/windows-terminal.ts', - 'src/shared/linux-glibc.ts', 'src/main/daemon/entry.ts', 'src/relay/index.ts', - 'src/main/wsl/runner.ts', 'config/scripts/build-orcad-prebuilds.mjs', 'config/scripts/orcad-prebuild-slot-contents.mjs', 'src/shared/node-runtime-pin.ts' @@ -58,3 +55,85 @@ it('fails closed to every platform when the evidence is incomplete', () => { }).qualification ).toBe(true) }) + +it.each([ + [ + 'src/main/runtime/windows-terminal.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], + [ + 'src/main/windows/windows-process-table.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], + ['src/main/wsl/runner.ts', ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], false], + [ + 'src/main/orcad/orcad-launcher.win32.test.ts', + ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + false + ], + ['src/main/daemon/darwin-process.ts', ['ubuntu-22.04', 'macos-14', 'macos-15-intel'], false], + ['src/shared/linux-glibc.ts', ['ubuntu-22.04', 'ubuntu-24.04-arm'], true], + [ + 'src/main/daemon/posix-process.ts', + ['ubuntu-22.04', 'ubuntu-24.04-arm', 'macos-14', 'macos-15-intel'], + true + ] +])('selects both architectures and a Linux smoke for %s', (file, runners, qualification) => { + expect(nodeServerQualification([file], scope)).toEqual({ runners, qualification }) +}) + +it('combines platform families without adding Linux compatibility work', () => { + expect( + nodeServerQualification( + ['src/main/windows/windows-process-table.ts', 'src/main/daemon/darwin-process.ts'], + scope + ) + ).toEqual({ + runners: ['ubuntu-22.04', 'macos-14', 'macos-15-intel', 'windows-2022', 'windows-11-arm'], + qualification: false + }) +}) + +it('keeps all hosts for shared changes alongside a platform-specific change', () => { + expect( + nodeServerQualification( + ['src/main/windows/windows-process-table.ts', 'src/main/daemon/entry.ts'], + scope + ) + ).toEqual({ runners: NODE_SERVER_RUNNERS, qualification: true }) +}) + +it.each([ + 'src/main/ssh/remote-node-runtime-store-windows.ts', + 'src/main/ssh/orcad-remote-node-runtime-windows.ts', + 'src/main/ssh/ssh-posix-command-wrapper.test.ts', + 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', + 'src/relay/windows-port-scan.ts', + 'src/main/runtime/windows-firewall-remote-scope.ts', + 'src/shared/remote-windows-path.ts' +])('qualifies every client platform for a remote execution input: %s', (file) => { + expect(nodeServerQualification([file], scope)).toEqual({ + runners: NODE_SERVER_RUNNERS, + qualification: true + }) +}) + +it.each([ + '.github/workflows/node-server-tests.yml', + 'config/scripts/node-server-qualification.mjs' +])('qualifies all hosts when the selection policy changes: %s', (file) => { + expect(nodeServerQualification([file], scope)).toEqual({ + runners: NODE_SERVER_RUNNERS, + qualification: true + }) +}) + +it('fully qualifies relevant main pushes even for an unflavoured change', () => { + expect( + nodeServerQualification(['src/main/runtime/rpc/methods/example.ts'], scope, { + fullQualification: true + }) + ).toEqual({ runners: NODE_SERVER_RUNNERS, qualification: true }) +}) diff --git a/config/scripts/orcad-daemon-protocol-crossing.test.mjs b/config/scripts/orcad-daemon-protocol-crossing.test.mjs deleted file mode 100644 index 131c1d54c57..00000000000 --- a/config/scripts/orcad-daemon-protocol-crossing.test.mjs +++ /dev/null @@ -1,100 +0,0 @@ -// D7: orcad's update and rollback planning must agree with the CI protocol-crossing facts. -import { readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { - DAEMON_PROTOCOL_SOURCE_PATH, - canAttach, - parseDaemonProtocolFacts -} from './daemon-protocol-facts.mjs' -import { CURRENT_ORCAD_DAEMON_PROTOCOL } from '../../src/main/ssh/orcad-daemon-protocol-crossing' -import { assessOrcadRollback, planOrcadUpdate } from '../../src/main/ssh/orcad-update-plan' - -const projectDir = resolve(import.meta.dirname, '../..') -const current = parseDaemonProtocolFacts( - readFileSync(join(projectDir, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8') -) -// The release before the newest protocol bump: it speaks one version lower and cannot list ours. -const older = { - protocolVersion: current.protocolVersion - 1, - previousProtocolVersions: current.previousProtocolVersions.filter( - (version) => version < current.protocolVersion - 1 - ) -} -const record = { - schemaVersion: 1, - active: '0.3.0+new', - previous: '0.2.0+old', - activatedAt: '2026-01-01T00:00:00.000Z', - snapshot: { - dirName: 'pre-0.3.0+new-1', - takenBeforeVersion: '0.3.0+new', - readableByVersion: '0.2.0+old', - takenAt: '2026-01-01T00:00:00.000Z' - } -} -const live = (daemonProtocolVersion) => ({ - liveSessions: 2, - startedSinceActivation: 0, - daemonProtocolVersion -}) - -function rollback(target, daemonProtocolVersion) { - return assessOrcadRollback({ - record, - snapshotPresent: true, - census: live(daemonProtocolVersion), - targetDaemonProtocol: target, - stateWritesSinceActivation: false - }) -} - -describe('orcad daemon protocol crossing', () => { - it('deploys exactly the protocol the working tree declares', () => { - expect({ - protocolVersion: CURRENT_ORCAD_DAEMON_PROTOCOL.protocolVersion, - previousProtocolVersions: [...CURRENT_ORCAD_DAEMON_PROTOCOL.previousProtocolVersions] - }).toEqual(current) - }) - - it('keeps terminals on rollback only when the old build lists the new protocol', () => { - expect(canAttach(older, current)).toBe(false) - expect(rollback(older, current.protocolVersion)).toMatchObject({ - safety: 'unsafe', - code: 'orcad_rollback_strands_live_terminals' - }) - // A daemon preserved from before the activation still speaks the old build's protocol. - expect(canAttach(older, older)).toBe(true) - expect(rollback(older, older.protocolVersion)).toMatchObject({ safety: 'clean' }) - const listing = { ...older, previousProtocolVersions: [...older.previousProtocolVersions] } - listing.previousProtocolVersions.push(current.protocolVersion + 1) - expect(canAttach(listing, { ...current, protocolVersion: current.protocolVersion + 1 })).toBe( - true - ) - expect(rollback(listing, current.protocolVersion + 1)).toMatchObject({ safety: 'clean' }) - }) - - it('updates over live terminals only when the candidate can attach their daemon', () => { - const plan = (daemonProtocolVersion) => - planOrcadUpdate({ - record, - candidateVersion: '0.4.0+next', - census: live(daemonProtocolVersion), - candidateDaemonProtocol: current, - force: true - }) - expect(canAttach(current, older)).toBe(true) - expect(plan(older.protocolVersion)).toMatchObject({ - action: 'proceed', - preservesLiveDaemon: true - }) - const dropped = current.protocolVersion + 1 - expect(canAttach(current, { protocolVersion: dropped, previousProtocolVersions: [] })).toBe( - false - ) - expect(plan(dropped)).toMatchObject({ - action: 'defer', - code: 'orcad_update_strands_live_terminals' - }) - }) -}) diff --git a/config/scripts/orcad-template-release-workflow.test.mjs b/config/scripts/orcad-template-release-workflow.test.mjs index 87ea8570767..eee8d451fc3 100644 --- a/config/scripts/orcad-template-release-workflow.test.mjs +++ b/config/scripts/orcad-template-release-workflow.test.mjs @@ -25,7 +25,9 @@ describe('orcad template release wiring (design D2)', () => { build_template: { type: 'boolean', default: false } }) // A release call shares github.ref with main's push runs; neither may cancel the other. - expect(nodeServer.concurrency['cancel-in-progress']).toBe('${{ !inputs.build_template }}') + expect(nodeServer.concurrency['cancel-in-progress']).toBe( + "${{ !inputs.build_template && github.event_name != 'push' }}" + ) expect(nodeServer.concurrency.group).toContain('github.run_id') for (const lane of LANES) { const steps = nodeServer.jobs[lane].steps diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index ef2371d86e5..3d42ffd8df3 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -40,7 +40,7 @@ function context(os, arch, event, ref, template = false) { describe('Windows server prebuild cache workflow', () => { it.each([ ['pull_request', 'refs/pull/1/merge', false, true, false], - ['push', 'refs/heads/main', false, false, true], + ['push', 'refs/heads/main', false, true, true], ['schedule', 'refs/heads/main', false, false, true], ['workflow_dispatch', 'refs/heads/main', false, false, true], ['workflow_call', 'refs/heads/main', false, false, false], diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b962df41edc..e60e683c738 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -441,6 +441,42 @@ the renderer, `runtime-worktree-status-projection.ts` in main, and PR 3 moves the rollup and the decay into `src/shared` and makes all three call it. +## Readiness reads the store + +`terminal wait --for tui-idle` is a reader too. Before STA-9100 hook state +reached it only through the ` ready` titles the window writes, so a +headless `orca serve` never saw it (#16095). Now an agent whose rule file says +`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) has its +fresh row read straight from the store, through the same +`selectFreshExplicitAgentStatusRow` join prompt-receipt verification uses +(`src/main/runtime/tui-idle-hook-lane.ts`): + +- the main agent's turn, not the combined row, decides: `mainAgent.state` when + published, so a subagent's Stop does not end the lead turn. `done` settles + the wait, `working` holds it, and a permission wait never settles. The tail's + blocked text goes through the existing permission arbiter with the turn as + its explicit status, so a denied prompt's dialog left in the tail no longer + blocks a turn the hook says ended; +- the row joins on any pane key or terminal handle the PTY owns; a pane neither + reaches, a stale or restored row, a session-start `done`, a row from before + the PTY respawned, and a `done` received before the pane's latest input all + leave the decision to the screen and text rules, which is also how startup + readiness works before an agent's first hook. The input is the PTY run's + `lastInputAt` (`terminal-run-facts.ts`), which both write funnels record, so + a key the user typed counts like a prompt Orca sent: the next turn's first + hook may still be in flight, and an agent restarted in the same shell has + not posted one. A shell command marker is no process boundary: Pi paints + OSC 133 zones itself; +- every other agent stays `identity-only`: Claude sends no event when an + approval is denied or Esc stops a tool, so its row can sit at `waiting` or + `working` forever, and the rules keep deciding. Codex is identity-only too: + before its `Interrupt` hook an Esc mid-turn leaves the row `working`, and an + older TUI can hand its hooks to a newer shared app server, so no version + check tells which Codex posts it. Current Codex settles fast anyway, since + `Interrupt` drives its `Codex ready` title. + +The titles stay for display; remote clients read them. + ## What does not change - The hook scripts, the OSC 9999 wire format, and the relay protocol. diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index f4443b05c43..966cbcdbb10 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -3,6 +3,43 @@ The [September 28 demand rollout](ci-demand-rollout.md) documents staged checks, unit-selection evidence, headless runtime qualification, review cancellation and daily occupancy reports. +## Headless server follow-up + +[PR #24527](https://github.com/stablyai/orca/pull/24527) adds dependency detection to +main pushes. Unrelated pushes skip qualification; relevant pushes still run all +six persistence targets and five Linux compatibility jobs. Explicit Windows or +Mac PR paths select both architectures plus a Linux smoke, while shared +execution/storage changes, SSH/provider/relay inputs, native inputs, manifests, and incomplete evidence +retain the full matrix. Main pushes use the same validated exact Windows slot +cache as PRs; nightly and release builds still compile freshly. + +Main detection runs cannot cancel each other. Only eligible qualification jobs +share main concurrency groups, so an unrelated push cannot cancel needed tests. +Release templates, explicit refs, and nightly runs remain isolated. + +Draft PRs have no server verdict, so their detector is also skipped. The existing +`ready_for_review` event performs detection and qualification once the PR is ready. +This removes the checkout and dependency installation for a result whose platform +jobs were already ineligible. + +The glibc 2.28 prerequisite step checks all five tools before installing anything. +The pinned ARM image already supplies them, including Git 2.55.0 built under +`/usr/local/bin`; installing the Git RPM does not change the Git on PATH. A +missing-tool fallback still installs the original package list and disables EPEL +for that one command. In +the baseline x64 log, EPEL metadata took 4 minutes 50 seconds to download although +every installed package came from AlmaLinux BaseOS or AppStream. The package list, +compiler image, libc floor, native smoke, and persistence tests stay unchanged. +The [DNF command reference](https://dnf.readthedocs.io/en/stable/command_ref.html) +defines `--disablerepo` as a temporary command-level filter, so later commands +retain the image's repository configuration. + +A [completed main run](https://github.com/stablyai/orca/actions/runs/36962172614) +used 42 aggregate runner-minutes across 11 test jobs. The +[latest daily demand report](https://github.com/stablyai/orca/actions/runs/36965354205) +estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are +baseline observations; post-merge savings have not yet been measured. + ## October 1 Windows and dependency cache follow-up [PR #24355](https://github.com/stablyai/orca/pull/24355) merged at `197ea3a3`. @@ -17,8 +54,8 @@ tooling is removed from ordinary PR CI. The existing dependency-native cache and the server's N-API 8 slot serve different consumers. Cache the small server slot separately, using the exact compiler image, architecture, dependency/patch/runtime inputs and compilation/validation source. -Only PR qualification restores it. Main qualification still compiles freshly and -saves after persistence/lifecycle tests and the existing x64 Node 18 handoff. +PR and main-push qualification restore it. Nightly qualification still compiles +freshly; main saves after persistence/lifecycle tests and the existing x64 Node 18 handoff. Templates and explicit-ref calls continue to compile freshly. | Hosted runner | Fresh build median | Restore median | Difference | diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md index d537c4d94de..bbfb4a84876 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -37,11 +37,21 @@ authority. | --------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fetch-no-write-fetch-head` | Fetch a private rebase ref without changing worktree-local `FETCH_HEAD` | Serialize all Orca fetch/pull operations per worktree Git directory before Git 2.29 | | `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 | +| `worktree-add-lock-reason` | Create a prepared checkout with its ownership marker already present | Before Git 2.33, add without checkout, then exclusively create the same reason marker before materializing files | | `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo | | `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca | | `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | | `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form | +Prepared creation registers and locks without checking out files while its shared +exact-base fetch runs. A cancellable in-process barrier waits for fetch settlement, +including offline failure, then resolves the current commit OID on the owning host +and materializes files once. Existing preparations queue a tip refresh on that same +barrier before a create can claim them. Finalization still resolves the latest base +and runs the post-checkout hook only when attaching the requested branch. This uses +baseline-compatible `rev-parse` and `reset --hard`; the barrier never enters Git +transport options or the remote wire. + ### Placeholders That Fail Open `GitCapabilityCache` records commands Git _rejects_. A `git log --format` @@ -75,6 +85,10 @@ container start, so their wall clock is runner contention, not Git. Build the running alongside them is charged to whichever boundary case is in flight and surfaces as a Vitest timeout rather than as a slow setup step. +The idle maintenance contract also verifies `multi-pack-index write` and packed +object reads. The command arrived in Git 2.20 and needs no newer-Git fallback; +Orca uses only index metadata writes, respecting `core.multiPackIndex=false`. + Keep the unit tests alongside that matrix. They cover concurrent probes, native/WSL/SSH/relay isolation, and error-stream shapes that a single real binary invocation cannot exercise deterministically. diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index f3830db82ed..adc18586c21 100644 --- a/docs/reference/orcad-operations.md +++ b/docs/reference/orcad-operations.md @@ -150,47 +150,6 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to exits with code 1 if teardown stalls. The bundled runtime also stops gracefully if its launcher's IPC channel closes. On POSIX, both the launcher and runtime ignore `SIGHUP`, so terminal hangups do not stop a headless host. Use `SIGTERM` or `SIGINT` to stop it. -- **Stop requests.** A file stops orcad the same way `SIGTERM` does, without a PID that may - since have been reused by another process: - - `.orcad-stop-request` beside `orcad.js` in the running slot. orcad deletes it and stops. - - An instance-bound request in the data root, named - `.orcad-managed-stop-request.`. orcad stops only when it - names this orcad's version, runtime ID, PID, start time and lock nonce, and while the - instance lock still holds that record. The file is kept as evidence. - - `orcad --complete-managed-stop ''` writes that request, waits for the - instance to exit, and prints one JSON line whose `verdict` is `live`, `unverifiable` or - `exited`. `exited` needs proof: no process with that PID, or a PID whose start time shows - it now belongs to another process. On `exited` it writes - `/orcad-stop-receipts/.json`. It exits 0 whenever it printed a - verdict, 64 for a malformed invocation, and 1 for a failure before any verdict, which is - never evidence of exit. - - A request with `retireIdleDaemon: true` asks orcad to retire the terminal daemon too. This - is best effort and never blocks or fails the stop: - - The daemon is retired only when it proves it owns no live session across every - generation. - - A busy daemon (`live`) or one whose state cannot be proven (`unverifiable`) stays up with - its terminals, and orcad reopens new-terminal admission before exiting. - - The completed-stop receipt records `retirement` as `retired`, `live` or `unverifiable`. - If orcad exits without recording an outcome, the receipt says `unverifiable`. - - `orcad --cancel-managed-stop ''` withdraws a request orcad has not acted on. - orcad and the canceller each try to create `.decision.json` exclusively, - so exactly one wins. `canceled` means orcad keeps running and the request file is removed; - `dispatched` means orcad already began stopping, and only the completion can say how it - ended. - - A build advertises all of the above with `health.stopRequests: 1` in its readiness line. - Clients stop such a build through the slot request file and older builds with `SIGTERM`, - after corroborating the PID with readiness either way. A launch clears a slot request - that the previous process never consumed. -- **Decommissioning a managed slot.** An Orca client decommissions through the same activation - journal and fence as deploy and rollback. It refuses while the terminal census reports live - or uncounted terminals, stops the instance with a managed request that also asks to retire - the daemon, and records that no version is active only after `exited` is proven. A stop - that did not finish is cancelled; if orcad already acted on it, or the host cannot answer, - the fence stays for recovery. -- **Instance lock.** `/orcad.lock` names the running orcad. A record that is - unreadable, malformed or over 64 KiB is never reclaimed: orcad exits 78 until an operator - removes it. A shutdown whose teardown failed keeps the lock until the process exits, so a - second orcad cannot start beside a writer that may still be running. - **Exit codes.** | Code | Meaning | Supervisor should | diff --git a/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts b/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts index 3cb87609c16..f5a1afda061 100644 --- a/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts +++ b/mobile/src/transport/cellular-handshake-stall-real-socket.test.ts @@ -127,10 +127,3 @@ describe.runIf(RUN_LIVE)('issue #10119 — real socket, handshake slower than th expect(labels.slice(firstEscalated).every((l) => l !== 'Connecting…')).toBe(true) }, 60_000) }) - -// Why: vitest fails a file with zero tests; keep a sentinel for default runs. -describe.runIf(!RUN_LIVE)('real-socket handshake stall (skipped)', () => { - it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => { - expect(true).toBe(true) - }) -}) diff --git a/mobile/src/transport/rpc-client-live-recovery.test.ts b/mobile/src/transport/rpc-client-live-recovery.test.ts index bef276f918d..d2eea169c9f 100644 --- a/mobile/src/transport/rpc-client-live-recovery.test.ts +++ b/mobile/src/transport/rpc-client-live-recovery.test.ts @@ -199,10 +199,3 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => { } ) }) - -// Why: vitest fails a file with zero tests; keep a sentinel for default runs. -describe.runIf(!RUN_LIVE)('live foreground recovery (skipped)', () => { - it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => { - expect(true).toBe(true) - }) -}) diff --git a/src/main/__mocks__/worktree-create-preparation.ts b/src/main/__mocks__/worktree-create-preparation.ts new file mode 100644 index 00000000000..74e48a3f8c9 --- /dev/null +++ b/src/main/__mocks__/worktree-create-preparation.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, vi, type Mock } from 'vitest' +import type * as WorktreeLogic from '../ipc/worktree-logic' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/repo-types' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' + +const mocks: { + mkdir: Mock + listWorktreeGraph: Mock + prepareCheckout: Mock + refreshTip: Mock + finalize: Mock + discard: Mock + unlock: Mock + getWorktreeOptions: Mock + computeWorkspaceRoot: Mock + computeWorkspaceRootAsync: Mock + resolveBaseRef: Mock + measureDivergence: Mock +} = vi.hoisted(() => ({ + mkdir: vi.fn(), + listWorktreeGraph: vi.fn(), + prepareCheckout: vi.fn(), + refreshTip: vi.fn(), + finalize: vi.fn(), + discard: vi.fn(), + unlock: vi.fn(), + getWorktreeOptions: vi.fn(), + computeWorkspaceRoot: vi.fn(), + computeWorkspaceRootAsync: vi.fn(), + resolveBaseRef: vi.fn(), + measureDivergence: vi.fn() +})) + +export { mocks } + +vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) +vi.mock('../git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) +vi.mock('../git/worktree-create-preparation', () => ({ + prepareWorktreeCreateCheckout: mocks.prepareCheckout, + finalizePreparedWorktree: mocks.finalize, + discardPreparedWorktree: mocks.discard, + unlockPreparedWorktree: mocks.unlock +})) +vi.mock('../git/worktree-preparation-tip-refresh', () => ({ + refreshPreparedWorktreeTip: mocks.refreshTip +})) +vi.mock('../git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) +vi.mock('../git/worktree-base-divergence', () => ({ + measureRetargetDivergence: mocks.measureDivergence +})) +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, + getWorktreeMirrorDistro: () => undefined +})) +vi.mock('../ipc/worktree-logic', async (importOriginal) => ({ + isOrphanedWorktreeError: (await importOriginal()).isOrphanedWorktreeError, + computeWorkspaceRoot: mocks.computeWorkspaceRoot, + computeWorkspaceRootAsync: mocks.computeWorkspaceRootAsync, + getWorktreePathSettings: () => ({ + workspaceDir: process.platform === 'win32' ? 'C:\\workspace' : '/workspace', + nestWorkspaces: false + }) +})) + +import { _resetWorktreeCreatePreparationsForTests } from '../worktree-create-preparation' + +// Evictions and retries are fire-and-forget, so let them settle before asserting. +export function flushBackgroundWork(ms = 0): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const EXISTING_REFS = new Set([ + 'refs/heads/main', + 'refs/remotes/origin/main', + 'refs/remotes/origin/release' +]) +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked flow reads only this repository identity. +export const repo = { id: 'repo-1', path: '/repo' } as Repo +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked persistence boundary reads only getSettings. +export const store = { getSettings: () => ({}) } as unknown as Store + +beforeEach(() => { + mocks.mkdir.mockReset().mockResolvedValue(undefined) + mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) + mocks.prepareCheckout.mockReset().mockResolvedValue(undefined) + mocks.refreshTip.mockReset().mockResolvedValue(undefined) + mocks.finalize.mockReset().mockResolvedValue({}) + mocks.discard.mockReset().mockResolvedValue(undefined) + mocks.unlock.mockReset().mockResolvedValue(undefined) + mocks.getWorktreeOptions.mockReset().mockReturnValue({}) + mocks.measureDivergence.mockReset().mockResolvedValue('within') + mocks.resolveBaseRef + .mockReset() + .mockImplementation((_repoPath: string, baseRef: string) => + resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate)) + ) + mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => { + throw new Error('synchronous workspace-root lookup must not run on the main thread') + }) + mocks.computeWorkspaceRootAsync + .mockReset() + .mockImplementation(async (repoPath: string) => + process.platform === 'win32' && /^[A-Za-z]:[\\/]/.test(repoPath) + ? 'C:\\workspace' + : '/workspace' + ) +}) + +afterEach(async () => { + await _resetWorktreeCreatePreparationsForTests() +}) diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 4d001d00422..d8b9189532e 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' let tempRoots: string[] = [] @@ -11,10 +12,6 @@ afterEach(async () => { tempRoots = [] }) -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} - describe('scanAiVaultSessions Codex worker sessions', () => { it('hides Codex worker transcripts from session history', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-workers-')) @@ -198,28 +195,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { ) const result = await scanAiVaultSessions({ - claudeProjectsDir: join(root, 'claude-projects'), - codexSessionsDir, - geminiSessionsDir: join(root, 'gemini-sessions'), - antigravityBrainDir: join(root, 'antigravity-brain'), - copilotSessionsDir: join(root, 'copilot-sessions'), - cursorProjectsDir: join(root, 'cursor-projects'), - opencodeStorageDir: join(root, 'opencode-storage'), - opencodeDbPaths: [], - grokSessionsDir: join(root, 'grok-sessions'), - devinTranscriptsDir: join(root, 'devin-transcripts'), - hermesSessionsDir: join(root, 'hermes-sessions'), - rovoSessionsDir: join(root, 'rovo-sessions'), - openclawStateDir: join(root, 'openclaw-state'), - openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), - piSessionsDir: join(root, 'pi-sessions'), - ompSessionsDir: join(root, 'omp-sessions'), - primeAgentSessionsDir: join(root, 'prime-agent-sessions'), - droidSessionsDir: join(root, 'droid-sessions'), - droidProjectsDir: join(root, 'droid-projects'), - kimiSessionsDir: join(root, 'kimi-sessions'), - museSessionsDir: join(root, 'muse-sessions'), - zcodeDbPath: join(root, 'zcode-db.sqlite'), + ...isolatedScanRoots(root), platform: 'darwin' }) diff --git a/src/main/ai-vault/session-scanner-service-search.test.ts b/src/main/ai-vault/session-scanner-service-search.test.ts index 4cd2723a198..2508556d47e 100644 --- a/src/main/ai-vault/session-scanner-service-search.test.ts +++ b/src/main/ai-vault/session-scanner-service-search.test.ts @@ -117,11 +117,11 @@ it('reports the indexer phase and a live generation over the protocol', async () const status = await vi.waitFor(async () => { const value = await searchStatus() expect(value.filesIndexed).toBeGreaterThan(0) + expect(value.phase).toBe('current') + expect(value.generation).toBeGreaterThan(0) return value }) expect(status.enabled).toBe(true) - expect(status.phase).toBe('current') - expect(status.generation).toBeGreaterThan(0) expect(existsSync(harness.databasePath)).toBe(true) }) diff --git a/src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts b/src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts new file mode 100644 index 00000000000..f3fe3c3904e --- /dev/null +++ b/src/main/crash-reporting/low-commit-oom-recovery-gate.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest' +import { withPlatform } from '../window/createMainWindow-test-harness' +import { + createLowCommitOomRecoveryGate, + LOW_COMMIT_REPEAT_OOM_WINDOW_MS +} from './low-commit-oom-recovery-gate' + +const OOM: Electron.RenderProcessGoneDetails = { reason: 'oom', exitCode: -536870904 } +const CRASHED: Electron.RenderProcessGoneDetails = { reason: 'crashed', exitCode: 5 } +// Launch 22912 (Scan-30 1790622432/1790622459): OOM at 19:06:54.6, reload OOMed again at 19:07:28.7. +const FIRST_OOM = Date.parse('2026-09-28T19:06:54.600Z') +const RELOAD_OOM = Date.parse('2026-09-28T19:07:28.700Z') + +function sample(swapFreeMB: number | undefined, ageMs = 4_000) { + return () => ({ + systemMemoryPreGoneSampleAgeMs: ageMs, + ...(swapFreeMB === undefined ? {} : { systemMemoryPreGoneSwapFreeMB: swapFreeMB }) + }) +} + +// A gone-time read that resolved no commit field, as off-Electron. +const NO_GONE_TIME_READING = () => ({}) + +function goneTime(swapFreeMB: number) { + return () => ({ systemMemorySwapFreeMB: swapFreeMB }) +} + +function observeTwice( + read: ReturnType, + second = OOM, + gapMs = RELOAD_OOM - FIRST_OOM, + platform: NodeJS.Platform = 'win32', + readGoneTime: () => Record = NO_GONE_TIME_READING +) { + return withPlatform(platform, () => { + const gate = createLowCommitOomRecoveryGate(read, readGoneTime) + const first = gate.assess(OOM, FIRST_OOM) + gate.recordRecoveredDeath(OOM, FIRST_OOM) + return [first, gate.assess(second, FIRST_OOM + gapMs)] + }) +} + +describe('createLowCommitOomRecoveryGate', () => { + it('holds the reload of a repeat OOM with 60 MB of commit left', () => { + expect(observeTwice(sample(60))).toEqual([ + null, + { availableCommitMB: 60, sincePreviousOomMs: 34_100, commitReading: 'pre-gone' } + ]) + }) + + it('always lets the first OOM of the launch auto-reload, even with 5 MB left', () => { + expect(observeTwice(sample(5))[0]).toBeNull() + }) + + it.each([ + ['commit is healthy (744 MB)', sample(744), OOM, 34_100, 'win32'], + [ + 'the previous OOM was over 5 minutes ago', + sample(60), + OOM, + LOW_COMMIT_REPEAT_OOM_WINDOW_MS + 1, + 'win32' + ], + ['the death is not an OOM', sample(60), CRASHED, 34_100, 'win32'], + ['no commit reading exists', sample(undefined), OOM, 34_100, 'win32'], + ['the reading is stale', sample(60, 31_000), OOM, 34_100, 'win32'], + ['the host is macOS', sample(60), OOM, 34_100, 'darwin'], + ['the host is Linux', sample(60), OOM, 34_100, 'linux'] + ] as const)('reloads when %s', (_label, read, second, gapMs, platform) => { + expect(observeTwice(read, second, gapMs, platform)[1]).toBeNull() + }) + + // Launch 13084 (Scan-31): 12:20:37.207 then 12:22:59.975; each OOM restarts the window. + it('measures the window from the most recent OOM', () => { + const verdicts = withPlatform('win32', () => { + const gate = createLowCommitOomRecoveryGate(sample(60, 2_000), NO_GONE_TIME_READING) + return [ + '2026-09-29T12:06:19.869Z', + '2026-09-29T12:20:37.207Z', + '2026-09-29T12:20:40.665Z', + '2026-09-29T12:22:59.975Z' + ].map((iso) => { + const verdict = gate.assess(OOM, Date.parse(iso)) + gate.recordRecoveredDeath(OOM, Date.parse(iso)) + return verdict + }) + }) + expect(verdicts.map((v) => v?.sincePreviousOomMs ?? null)).toEqual([null, null, 3_458, 139_310]) + }) + + // Launch 13084: the repeat OOM came 3.458 s after the previous one, inside one 10 s sampler tick. + describe('when no sampler tick landed since the previous OOM', () => { + const gapMs = 3_458 + + it.each([ + ['taken before the previous OOM', 5_000], + ['taken exactly at the previous OOM', gapMs], + ['stale', 31_000] + ])('reads commit at gone time instead of trusting a reading %s', (_label, ageMs) => { + expect(observeTwice(sample(744, ageMs), OOM, gapMs, 'win32', goneTime(60))[1]).toEqual({ + availableCommitMB: 60, + sincePreviousOomMs: gapMs, + commitReading: 'gone-time' + }) + }) + + it('reloads when the gone-time reading shows commit recovered (2029 MB)', () => { + expect(observeTwice(sample(60, 5_000), OOM, gapMs, 'win32', goneTime(2_029))[1]).toBeNull() + }) + + it('prefers a reading taken after the previous OOM over the gone-time one', () => { + expect(observeTwice(sample(60, 1_000), OOM, gapMs, 'win32', goneTime(2_029))[1]).toEqual({ + availableCommitMB: 60, + sincePreviousOomMs: gapMs, + commitReading: 'pre-gone' + }) + }) + + it('reads nothing on macOS or Linux', () => { + const readGoneTime = vi.fn(goneTime(60)) + for (const platform of ['darwin', 'linux'] as const) { + expect(observeTwice(sample(60, 5_000), OOM, gapMs, platform, readGoneTime)[1]).toBeNull() + } + expect(readGoneTime).not.toHaveBeenCalled() + }) + }) + + it.each([Number.NaN, Infinity, -1])( + 'does not block recovery on an invalid commit reading (%s)', + (commitMB) => { + expect(observeTwice(sample(commitMB), OOM, 34_100, 'win32', goneTime(commitMB))[1]).toBeNull() + } + ) + + it.each([Number.NaN, Infinity, -1])('ignores an invalid sample age (%s)', (ageMs) => { + expect(observeTwice(sample(60, ageMs), OOM, 34_100, 'win32', goneTime(2_029))[1]).toBeNull() + }) + + it.each([0, -1])('does not block recovery when the clock fails to advance (%s)', (gapMs) => { + expect(observeTwice(sample(60), OOM, gapMs, 'win32', goneTime(60))[1]).toBeNull() + }) + + it('does not start the repeat window for an OOM that was never recovered', () => { + const verdict = withPlatform('win32', () => { + const gate = createLowCommitOomRecoveryGate(sample(60, 2_000), NO_GONE_TIME_READING) + gate.assess(OOM, FIRST_OOM) + return gate.assess(OOM, RELOAD_OOM) + }) + expect(verdict).toBeNull() + }) +}) diff --git a/src/main/crash-reporting/low-commit-oom-recovery-gate.ts b/src/main/crash-reporting/low-commit-oom-recovery-gate.ts new file mode 100644 index 00000000000..f9362822745 --- /dev/null +++ b/src/main/crash-reporting/low-commit-oom-recovery-gate.ts @@ -0,0 +1,94 @@ +import type { CrashReportDetailValue } from '../../shared/crash-reporting' +import { preGoneSystemMemoryDetails } from './pre-gone-host-memory' +import { getSystemMemoryDetails, SYSTEM_MEMORY_KEY_PREFIX } from './system-memory-details' + +// Why: when Windows commit is exhausted by another program, a recovery reload OOMs again within seconds +// (launch 13084: 3.5 s after the reload; launch 22912: 34 s), so a repeat OOM on a starved host asks the user instead. +export const LOW_COMMIT_REPEAT_OOM_WINDOW_MS = 5 * 60_000 +export const LOW_COMMIT_AVAILABLE_MB_THRESHOLD = 512 +// Two missed 10 s sampler ticks: an older reading may predate the squeeze or its relief. +const LOW_COMMIT_MAX_SAMPLE_AGE_MS = 30_000 + +export type LowCommitOomVerdict = { + /** Pre-gone MEMORYSTATUSEX.ullAvailPageFile, i.e. commit still available. */ + availableCommitMB: number + sincePreviousOomMs: number + /** 'gone-time' when no sampler tick landed since the previous OOM and the gate read commit itself. */ + commitReading: 'pre-gone' | 'gone-time' +} + +export type LowCommitOomRecoveryGate = { + /** Read at gone time; returns a verdict only when auto-reload would run straight back into the OOM. */ + assess: (details: Electron.RenderProcessGoneDetails, now: number) => LowCommitOomVerdict | null + /** Call only once the death is actually recovered, so a skipped teardown OOM cannot start the repeat window. */ + recordRecoveredDeath: (details: Electron.RenderProcessGoneDetails, goneAt: number) => void +} + +type MemoryDetails = Record + +function usablePreGoneCommitMB(sample: MemoryDetails, sincePreviousOomMs: number): number | null { + const availableCommitMB = sample[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSwapFreeMB`] + const sampleAgeMs = sample[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSampleAgeMs`] + if ( + typeof availableCommitMB !== 'number' || + typeof sampleAgeMs !== 'number' || + !Number.isFinite(availableCommitMB) || + availableCommitMB < 0 || + !Number.isFinite(sampleAgeMs) || + sampleAgeMs < 0 || + sampleAgeMs > LOW_COMMIT_MAX_SAMPLE_AGE_MS || + // Why: a reading from before the previous OOM misses the commit that corpse released. + sampleAgeMs >= sincePreviousOomMs + ) { + return null + } + return availableCommitMB +} + +export function createLowCommitOomRecoveryGate( + readPreGoneDetails: (now: number) => MemoryDetails = preGoneSystemMemoryDetails, + readGoneTimeDetails: () => MemoryDetails = getSystemMemoryDetails +): LowCommitOomRecoveryGate { + let previousOomAt: number | null = null + return { + assess: (details, now) => { + const previous = previousOomAt + if ( + // Only win32 swapFree is available commit; elsewhere it is not a verdict. + process.platform !== 'win32' || + details.reason !== 'oom' || + previous === null || + !Number.isFinite(now) || + now <= previous || + now - previous > LOW_COMMIT_REPEAT_OOM_WINDOW_MS + ) { + return null + } + const sincePreviousOomMs = now - previous + const preGoneMB = usablePreGoneCommitMB(readPreGoneDetails(now), sincePreviousOomMs) + // Why fall back: a 10 s sampler misses most ~3.5 s repeat loops. A gone-time read sees commit the corpse + // already released, so it can only over-report and miss a prompt, never raise a false one. + const goneTimeMB = + preGoneMB === null ? readGoneTimeDetails()[`${SYSTEM_MEMORY_KEY_PREFIX}SwapFreeMB`] : null + const availableCommitMB = preGoneMB ?? goneTimeMB + if ( + typeof availableCommitMB !== 'number' || + !Number.isFinite(availableCommitMB) || + availableCommitMB < 0 || + availableCommitMB >= LOW_COMMIT_AVAILABLE_MB_THRESHOLD + ) { + return null + } + return { + availableCommitMB, + sincePreviousOomMs, + commitReading: preGoneMB === null ? 'gone-time' : 'pre-gone' + } + }, + recordRecoveredDeath: (details, goneAt) => { + if (details.reason === 'oom') { + previousOomAt = goneAt + } + } + } +} diff --git a/src/main/daemon/daemon-health-identity.ts b/src/main/daemon/daemon-health-identity.ts deleted file mode 100644 index 44a1c02978e..00000000000 --- a/src/main/daemon/daemon-health-identity.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** What a `ptySpawnHealth` reply proves about this daemon; every field is optional on the wire. */ -export function readDaemonHealthIdentity(): { - coverage: 'pty-spawn' | 'handshake' - runtimeKind: 'node' - runtimeVersion: string -} { - return { - // Why handshake on Windows: preflightPtySpawnHealth skips the spawn probe there. - coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn', - runtimeKind: 'node', - runtimeVersion: process.version - } -} diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 5b00b7c91e5..c499fadd551 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -11,7 +11,6 @@ import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' import type { SocketProbeOutcome } from './daemon-endpoint-probe' import { checkDaemonHealth, - checkDaemonHealthWithCoverage, E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV, healthCheckDaemon } from './daemon-health' @@ -106,63 +105,12 @@ describe('daemon health', () => { try { await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('healthy') await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true) - await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toMatchObject({ - verdict: 'healthy', - coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn', - runtimeKind: 'node', - runtimeVersion: process.version - }) - expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(3) + expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(2) } finally { await server.shutdown() } }) - it('treats missing coverage from a legacy Windows daemon as handshake-only', async () => { - writeFileSync(tokenPath, 'legacy-token') - const server = createServer((socket) => { - let pending = '' - socket.on('data', (chunk) => { - pending += chunk.toString() - for (;;) { - const newline = pending.indexOf('\n') - if (newline === -1) { - return - } - const message: unknown = JSON.parse(pending.slice(0, newline)) - const type = - typeof message === 'object' && message !== null && 'type' in message - ? message.type - : undefined - pending = pending.slice(newline + 1) - if (type === 'hello') { - socket.write(`${JSON.stringify({ type: 'hello', ok: true })}\n`) - } else if (type === 'ptySpawnHealth') { - socket.write( - `${JSON.stringify({ id: 'health-1', ok: true, payload: { healthy: true } })}\n` - ) - } - } - }) - }) - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(socketPath, resolve) - }) - - const platform = Object.getOwnPropertyDescriptor(process, 'platform')! - Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - try { - await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toEqual({ - verdict: 'healthy', - coverage: 'handshake' - }) - } finally { - Object.defineProperty(process, 'platform', platform) - await closeServer(server) - } - }) - it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => { const server = new DaemonServer({ socketPath, diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index cc035c26a08..d3a2c8a91a9 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -21,57 +21,15 @@ export const E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV = 'ORCA_E2E_FORCE_DAEMON_HE // also covers a live-but-wedged daemon that simply missed the RPC budget. export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy' -export type DaemonHealthCheck = { - verdict: DaemonHealth - coverage: 'pty-spawn' | 'handshake' - /** Optional runtime proof from newer daemons; absent on mixed-version peers. */ - runtimeKind?: 'node' - runtimeVersion?: string -} - -function readRuntimeIdentity( - payload: unknown -): Pick { - if (typeof payload !== 'object' || payload === null) { - return {} - } - const runtimeKind = 'runtimeKind' in payload ? payload.runtimeKind : undefined - const runtimeVersion = 'runtimeVersion' in payload ? payload.runtimeVersion : undefined - // Why drop other kinds: only a Node daemon reports here; anything else is an unknown peer. - return { - ...(runtimeKind === 'node' ? { runtimeKind } : {}), - ...(typeof runtimeVersion === 'string' && runtimeVersion.length > 0 ? { runtimeVersion } : {}) - } -} - -function readPtySpawnHealthCoverage( - payload: unknown, - fallback: DaemonHealthCheck['coverage'] -): DaemonHealthCheck['coverage'] { - if (typeof payload !== 'object' || payload === null) { - return fallback - } - const coverage = 'coverage' in payload ? payload.coverage : undefined - return coverage === 'pty-spawn' || coverage === 'handshake' ? coverage : fallback -} - -export function checkDaemonHealthWithCoverage( - socketPath: string, - tokenPath: string -): Promise { +export function checkDaemonHealth(socketPath: string, tokenPath: string): Promise { return new Promise((resolve) => { - // Older Windows daemons answered this RPC without spawning; an absent optional coverage - // field must preserve that weaker meaning during adoption. - const fallbackCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn' - const resolveVerdict = (verdict: DaemonHealth): void => - resolve({ verdict, coverage: fallbackCoverage }) if (process.env[E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV] === '1') { - resolveVerdict('unreachable') + resolve('unreachable') return } if (process.platform !== 'win32' && !existsSync(socketPath)) { - resolveVerdict('unreachable') + resolve('unreachable') return } @@ -79,13 +37,13 @@ export function checkDaemonHealthWithCoverage( try { token = readFileSync(tokenPath, 'utf8').trim() } catch { - resolveVerdict('unreachable') + resolve('unreachable') return } let settled = false let sock: Socket | null = null - const settle = (result: DaemonHealthCheck): void => { + const settle = (result: DaemonHealth): void => { if (settled) { return } @@ -100,7 +58,7 @@ export function checkDaemonHealthWithCoverage( sock?.off('connect', onConnect) sock?.off('data', onData) } - const onError = (): void => settle({ verdict: 'unreachable', coverage: fallbackCoverage }) + const onError = (): void => settle('unreachable') const onConnect = (): void => { const hello: HelloMessage = { type: 'hello', @@ -131,13 +89,13 @@ export function checkDaemonHealthWithCoverage( try { message = JSON.parse(line) as Record } catch { - settle({ verdict: 'rejected', coverage: fallbackCoverage }) + settle('rejected') return } if (message.type === 'hello') { if (!(message as HelloResponse).ok) { - settle({ verdict: 'rejected', coverage: fallbackCoverage }) + settle('rejected') return } // Why: a protocol-live daemon with a stale cwd or node-pty helper @@ -148,20 +106,12 @@ export function checkDaemonHealthWithCoverage( } if (message.id === 'health-1') { - const identity = readRuntimeIdentity(message.payload) - settle({ - verdict: message.ok === true ? 'healthy' : 'pty-spawn-unhealthy', - coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage), - ...identity - }) + settle(message.ok === true ? 'healthy' : 'pty-spawn-unhealthy') return } } } - const timer = setTimeout( - () => settle({ verdict: 'unreachable', coverage: fallbackCoverage }), - HEALTH_CHECK_TIMEOUT_MS - ) + const timer = setTimeout(() => settle('unreachable'), HEALTH_CHECK_TIMEOUT_MS) sock = connect({ path: socketPath }) sock.on('error', onError) @@ -172,13 +122,6 @@ export function checkDaemonHealthWithCoverage( }) } -export async function checkDaemonHealth( - socketPath: string, - tokenPath: string -): Promise { - return (await checkDaemonHealthWithCoverage(socketPath, tokenPath)).verdict -} - export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise { return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy' } diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts index 43a82df72bb..1500718e98f 100644 --- a/src/main/daemon/daemon-idle-shutdown.test.ts +++ b/src/main/daemon/daemon-idle-shutdown.test.ts @@ -442,35 +442,6 @@ describe('current daemon lifecycle retirement', () => { adopted.dispose() }) - it('atomically retires an idle daemon and permanently fences adapter spawns', async () => { - await startServer() - const adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) - - await expect(adapter.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' }) - await expect( - adapter.spawn({ sessionId: 'late-after-decommission', cols: 80, rows: 24 }) - ).rejects.toThrow('Terminal daemon is decommissioning') - await waitFor(() => onIdleShutdown.mock.calls.length === 1) - adapter.dispose() - }) - - it('reopens adapter admission when the daemon refuses retirement for a live session', async () => { - await startServer() - const adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) - await adapter.spawn({ sessionId: 'already-live', cols: 80, rows: 24 }) - - await expect(adapter.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1, - admissionReopened: true - }) - await expect( - adapter.spawn({ sessionId: 'allowed-after-refusal', cols: 80, rows: 24 }) - ).resolves.toMatchObject({ id: 'allowed-after-refusal' }) - expect(onIdleShutdown).not.toHaveBeenCalled() - adapter.dispose() - }) - it('does not let repeated authenticated control probes extend the startup deadline', async () => { await startServer() const healthControl = connect(socketPath) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 6f0bd4eefc5..918547d8b79 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -8,9 +8,6 @@ export { getDaemonEndpointFacts, getDaemonProvider, listLiveDaemonPtyIds, - listLiveDaemonSessions, - requestIdleDaemonRetirement, - releaseDaemonRetirementFence, readDaemonPidRecord, replaceDaemonProvider, shutdownDaemon, diff --git a/src/main/daemon/daemon-provider-census.test.ts b/src/main/daemon/daemon-provider-census.test.ts deleted file mode 100644 index 30aade980fd..00000000000 --- a/src/main/daemon/daemon-provider-census.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' - -vi.mock('../ipc/pty', () => ({ setLocalPtyProvider: vi.fn() })) - -import { DaemonPtyRouter } from './daemon-pty-router' -import { createAdapter } from './daemon-pty-router-test-fixture' -import { - disconnectDaemon, - listLiveDaemonSessions, - listLiveDaemonSessionsWithProtocol, - replaceDaemonProvider, - requestIdleDaemonRetirement -} from './daemon-provider-state' -import { PROTOCOL_VERSION } from './types' - -afterEach(async () => { - await disconnectDaemon() -}) - -it('reads a census without an installed daemon as unverifiable, never as empty', async () => { - await expect(listLiveDaemonSessions()).resolves.toBeNull() - await expect(requestIdleDaemonRetirement()).resolves.toEqual({ state: 'unverifiable' }) -}) - -it('reads a census with an unanswered generation as unverifiable', async () => { - const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - vi.mocked(legacy.listSessions).mockRejectedValue(new Error('daemon unreachable')) - replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] })) - - await expect(listLiveDaemonSessions()).resolves.toBeNull() -}) - -it('labels each live session with the protocol of the generation that owns it', async () => { - const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION - 1) - replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] })) - - await expect(listLiveDaemonSessionsWithProtocol()).resolves.toEqual([ - { sessionId: 'live-1', isAlive: true, protocolVersion: PROTOCOL_VERSION }, - { sessionId: 'live-2', isAlive: true, protocolVersion: PROTOCOL_VERSION - 1 } - ]) -}) - -it('lists every generation when each one answers', async () => { - const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION) - replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] })) - - await expect(listLiveDaemonSessions()).resolves.toEqual([ - { sessionId: 'live-1', isAlive: true }, - { sessionId: 'live-2', isAlive: true } - ]) -}) diff --git a/src/main/daemon/daemon-provider-state.ts b/src/main/daemon/daemon-provider-state.ts index f54d886206d..412dda3af83 100644 --- a/src/main/daemon/daemon-provider-state.ts +++ b/src/main/daemon/daemon-provider-state.ts @@ -11,16 +11,12 @@ import { getMacDaemonTccAttributionHealth, type MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' -import { PROTOCOL_VERSION, type DaemonSessionInfo, type SessionInfo } from './types' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' +import { PROTOCOL_VERSION } from './types' let spawner: DaemonSpawner | null = null let adapter: DaemonProvider | null = null -export function installDaemonProvider( - newSpawner: DaemonSpawner | null, - newAdapter: DaemonProvider -): void { +export function installDaemonProvider(newSpawner: DaemonSpawner, newAdapter: DaemonProvider): void { spawner = newSpawner replaceDaemonProvider(newAdapter) } @@ -39,12 +35,7 @@ export function getDaemonSpawner(): DaemonSpawner | null { * from that state would be advertising recovery for terminals that cannot be recovered. */ export function daemonOwnsFreshPersistentPtys(): boolean { - if (!adapter || adapter instanceof DegradedDaemonPtyProvider) { - return false - } - return adapter instanceof DaemonPtyRouter - ? !adapter.getAllAdapters().some((entry) => entry.recoveryOnly) - : !adapter.recoveryOnly + return adapter !== null && !(adapter instanceof DegradedDaemonPtyProvider) } /** Endpoint coordinates of the daemon this process installed, for out-of-band health probes. */ @@ -104,27 +95,6 @@ export async function getCurrentDaemonMacTccAttributionHealth(): Promise { - await adapter?.disconnectOnly() - adapter = null -} - -/** Kill the daemon and all its sessions. Use for full cleanup only. */ -export async function shutdownDaemon(): Promise { - adapter?.dispose() - adapter = null - await spawner?.shutdown() - spawner = null -} - /** Returns null unless every daemon generation supplied an authoritative inventory. */ export async function listLiveDaemonPtyIds(): Promise { if (!adapter) { @@ -145,54 +115,23 @@ export async function listLiveDaemonPtyIds(): Promise { ) } -/** Returns null unless every daemon generation supplied an authoritative session inventory. */ -export async function listLiveDaemonSessions(): Promise { - const sessions = await listLiveDaemonSessionsWithProtocol() - return sessions?.map(({ protocolVersion: _protocolVersion, ...session }) => session) ?? null +// Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference. +export function replaceDaemonProvider(newAdapter: DaemonProvider): void { + adapter = newAdapter + setLocalPtyProvider(newAdapter) } -/** Like listLiveDaemonSessions, with the protocol of the daemon generation owning each session. */ -export async function listLiveDaemonSessionsWithProtocol(): Promise { - if (!adapter) { - return null - } - const adapters = - adapter instanceof DaemonPtyRouter || adapter instanceof DegradedDaemonPtyProvider - ? adapter.getAllAdapters() - : [adapter] - const inventories = await Promise.allSettled( - adapters.map(async (daemonAdapter) => - (await daemonAdapter.listSessions()).map((session) => ({ - ...session, - protocolVersion: daemonAdapter.protocolVersion - })) - ) - ) - if (inventories.some((inventory) => inventory.status === 'rejected')) { - return null - } - return inventories.flatMap((inventory) => - inventory.status === 'fulfilled' ? inventory.value : [] - ) +// Disconnect without killing: the daemon survives app quit so sessions stay warm for reattach. +// Leave history sessions marked "unclean" so a daemon crash while Orca is closed stays recoverable. +export async function disconnectDaemon(): Promise { + await adapter?.disconnectOnly() + adapter = null } -/** Atomically fence new daemon terminals and retire only an idle, single-generation daemon. */ -export async function requestIdleDaemonRetirement(): Promise { - if (!adapter) { - return { state: 'unverifiable' } - } - if (adapter instanceof DegradedDaemonPtyProvider) { - return { state: 'unverifiable' } - } - if (adapter instanceof DaemonPtyRouter) { - return adapter.requestIdleRetirement() - } - return adapter.requestIdleRetirement() -} - -/** Reopens terminal admission when an idle-retirement attempt did not retire the daemon. */ -export function releaseDaemonRetirementFence(): void { - if (adapter && !(adapter instanceof DegradedDaemonPtyProvider)) { - adapter.releaseIdleRetirementFence() - } +/** Kill the daemon and all its sessions. Use for full cleanup only. */ +export async function shutdownDaemon(): Promise { + adapter?.dispose() + adapter = null + await spawner?.shutdown() + spawner = null } diff --git a/src/main/daemon/daemon-pty-event-subscriptions.ts b/src/main/daemon/daemon-pty-event-subscriptions.ts index f007a734b40..6d95e2de1c7 100644 --- a/src/main/daemon/daemon-pty-event-subscriptions.ts +++ b/src/main/daemon/daemon-pty-event-subscriptions.ts @@ -3,12 +3,7 @@ import { removeDaemonListener } from './daemon-listener-registry' import { emitPtyListeners } from './daemon-pty-listener-emission' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import { DaemonPtySessionInventory } from './daemon-pty-session-inventory' -import { - CLEAN_DISCONNECT_PROTOCOL_VERSION, - type ListSessionsResult, - type ShutdownIfIdleResult -} from './types' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' +import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' import type { PtyBackgroundStreamEvent } from '../providers/types' export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory { @@ -90,75 +85,6 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent this.recordAuthenticatedIdentity() } - async requestIdleRetirement(): Promise { - if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { - return { state: 'unsupported' } - } - if (this.idleRetirementState === 'retiring') { - return { state: 'retiring' } - } - if (this.idleRetirementPromise) { - return this.idleRetirementPromise - } - if ( - this.disconnectOnlyPromise || - (this.respawnAdoptionClosed && this.idleRetirementState === 'open') - ) { - return { state: 'unverifiable' } - } - this.idleRetirementAdmissionClosed = true - this.respawnAdoptionClosed = true - this.idleRetirementState = 'checking' - const request = this.finishIdleRetirementRequest().finally(() => { - if (this.idleRetirementPromise === request) { - this.idleRetirementPromise = null - } - }) - this.idleRetirementPromise = request - return request - } - - private async finishIdleRetirementRequest(): Promise { - try { - await this.client.ensureConnected() - const result = await this.client.request('shutdownIfIdle', undefined) - if (result.retiring) { - this.idleRetirementState = 'retiring' - return { state: 'retiring' } - } - let liveSessions: number | null = null - try { - const inventory = await this.client.request('listSessions', undefined) - liveSessions = inventory.sessions.filter((session) => session.isAlive).length - } catch { - liveSessions = null - } - this.reopenAfterRefusedIdleRetirement() - return { - state: 'busy', - liveSessions, - ...(!this.recoveryOnly ? { admissionReopened: true as const } : {}) - } - } catch { - // The daemon may have accepted before contact was lost; keep admission and respawn fenced. - this.idleRetirementState = 'unverifiable' - return { state: 'unverifiable' } - } - } - - /** Reopens admission an idle-retirement attempt fenced without retiring the daemon. */ - releaseIdleRetirementFence(): void { - if (this.idleRetirementState !== 'retiring' && !this.idleRetirementPromise) { - this.reopenAfterRefusedIdleRetirement() - } - } - - private reopenAfterRefusedIdleRetirement(): void { - this.idleRetirementState = 'open' - this.idleRetirementAdmissionClosed = false - this.respawnAdoptionClosed = false - } - // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. async disconnectOnly(): Promise { diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index cc8f8da849a..335c641da62 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -127,7 +127,7 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot // Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan. const { worktreeId } = parsePtySessionId(session.sessionId) - if (!this.recoveryOnly && (worktreeId === null || !validWorktreeIds.has(worktreeId))) { + if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { try { await this.client.request('kill', { sessionId: session.sessionId }) } catch { diff --git a/src/main/daemon/daemon-pty-router-test-fixture.ts b/src/main/daemon/daemon-pty-router-test-fixture.ts deleted file mode 100644 index 23d2c5f48b4..00000000000 --- a/src/main/daemon/daemon-pty-router-test-fixture.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { vi } from 'vitest' -import { settledWriteStub } from '../providers/settled-pty-write-stub' -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' -import { - AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION -} from './types' -import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' - -type AdapterMock = DaemonPtyAdapter & { - emitData: (id: string, data: string, sequenceChars?: number) => void - emitBackground: (event: PtyBackgroundStreamEvent) => void - emitExit: (id: string, code: number, incarnationId?: string) => void - emitIdentityChange: () => void - triggerWriteUnavailable: (id: string) => void -} - -export function createAdapter( - label: string, - sessions: string[] = [], - reconcileResult?: { alive: string[]; killed: string[] }, - protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION -): AdapterMock { - const writes: { id: string; data: string }[] = [] - const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = - [] - const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] - const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] - const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = - [] - const identityChangeListeners: (() => void)[] = [] - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the router calls only the adapter members this mock defines. - return { - protocolVersion, - supportsGitCredentialGuardHost: () => - protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, - supportsAgentSessionClaims: () => - protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - supportsAgentSessionCreateOperations: () => - protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - providesAgentSessionOwnerListings: () => - protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - canProvideAuthoritativeBufferSnapshot: () => - protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, - spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { - const id = opts.sessionId ?? `${label}-new` - sessions.push(id) - return { id } - }), - listProcesses: vi.fn(async () => - sessions.map((id) => ({ - id, - cwd: '', - title: label - })) - ), - listSessions: vi.fn(async () => sessions.map((sessionId) => ({ sessionId, isAlive: true }))), - requestIdleRetirement: vi.fn(async () => ({ state: 'retiring' as const })), - releaseIdleRetirementFence: vi.fn(), - hasPty: vi.fn((id: string) => sessions.includes(id)), - probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), - write: vi.fn((id: string, data: string) => { - writes.push({ id, data }) - }), - writeWithSettlement: vi.fn(settledWriteStub()), - resize: vi.fn(), - setPtyBackgrounded: vi.fn(), - getBufferSnapshot: vi.fn(async () => null), - shutdown: vi.fn(async (id: string) => { - const idx = sessions.indexOf(id) - if (idx !== -1) { - sessions.splice(idx, 1) - } - }), - attach: vi.fn(async () => {}), - sendSignal: vi.fn(async () => {}), - getCwd: vi.fn(async () => ''), - getInitialCwd: vi.fn(async () => ''), - clearBuffer: vi.fn(async () => {}), - acknowledgeDataEvent: vi.fn(), - hasChildProcesses: vi.fn(async () => false), - getForegroundProcess: vi.fn(async () => null), - inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), - confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), - serialize: vi.fn(async () => '{}'), - revive: vi.fn(async () => {}), - getDefaultShell: vi.fn(async () => '/bin/zsh'), - getProfiles: vi.fn(async () => []), - onData: vi.fn( - (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { - dataListeners.push(callback) - return () => { - const idx = dataListeners.indexOf(callback) - if (idx !== -1) { - dataListeners.splice(idx, 1) - } - } - } - ), - onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { - backgroundListeners.push(callback) - return () => { - const idx = backgroundListeners.indexOf(callback) - if (idx !== -1) { - backgroundListeners.splice(idx, 1) - } - } - }), - onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { - writeUnavailableListeners.push(callback) - return () => { - const idx = writeUnavailableListeners.indexOf(callback) - if (idx !== -1) { - writeUnavailableListeners.splice(idx, 1) - } - } - }), - onExit: vi.fn( - (callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => { - exitListeners.push(callback) - return () => { - const idx = exitListeners.indexOf(callback) - if (idx !== -1) { - exitListeners.splice(idx, 1) - } - } - } - ), - onDaemonIdentityChanged: vi.fn((callback: () => void) => { - identityChangeListeners.push(callback) - return () => { - const idx = identityChangeListeners.indexOf(callback) - if (idx !== -1) { - identityChangeListeners.splice(idx, 1) - } - } - }), - ackColdRestore: vi.fn(), - clearTombstone: vi.fn(), - reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), - dispose: vi.fn(), - disconnectOnly: vi.fn(async () => {}), - emitData: (id: string, data: string, sequenceChars?: number) => { - for (const listener of dataListeners) { - listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) - } - }, - emitBackground: (event: PtyBackgroundStreamEvent) => { - for (const listener of backgroundListeners) { - listener(event) - } - }, - emitExit: (id: string, code: number, incarnationId?: string) => { - for (const listener of exitListeners) { - listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) - } - }, - emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()), - triggerWriteUnavailable: (id: string) => { - for (const listener of writeUnavailableListeners) { - listener({ id }) - } - }, - _writes: writes - } as unknown as AdapterMock -} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 7e35d416b12..61db990b21c 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -1,20 +1,29 @@ -import { createAdapter } from './daemon-pty-router-test-fixture' import { describe, expect, it, vi } from 'vitest' import { DaemonPtyRouter } from './daemon-pty-router' -import { stubWriteSettlement } from '../providers/settled-pty-write-stub' import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { PtySpawnResult } from '../providers/types' +import { settledWriteStub, stubWriteSettlement } from '../providers/settled-pty-write-stub' +import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types' import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, PROTOCOL_VERSION, + SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' +type AdapterMock = DaemonPtyAdapter & { + emitData: (id: string, data: string, sequenceChars?: number) => void + emitBackground: (event: PtyBackgroundStreamEvent) => void + emitExit: (id: string, code: number, incarnationId?: string) => void + emitIdentityChange: () => void + triggerWriteUnavailable: (id: string) => void +} + const LARGE_RECONCILE_SESSION_COUNT = 150_000 function buildSessionIds(prefix: string, count: number): string[] { @@ -25,6 +34,152 @@ function buildSessionIds(prefix: string, count: number): string[] { return ids } +function createAdapter( + label: string, + sessions: string[] = [], + reconcileResult?: { alive: string[]; killed: string[] }, + protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION +): AdapterMock { + const writes: { id: string; data: string }[] = [] + const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = + [] + const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] + const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] + const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = + [] + const identityChangeListeners: (() => void)[] = [] + return { + protocolVersion, + supportsGitCredentialGuardHost: () => + protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, + supportsAgentSessionClaims: () => + protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + supportsAgentSessionCreateOperations: () => + protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + providesAgentSessionOwnerListings: () => + protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + canProvideAuthoritativeBufferSnapshot: () => + protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, + spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { + const id = opts.sessionId ?? `${label}-new` + sessions.push(id) + return { id } + }), + listProcesses: vi.fn(async () => + sessions.map((id) => ({ + id, + cwd: '', + title: label + })) + ), + hasPty: vi.fn((id: string) => sessions.includes(id)), + probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), + write: vi.fn((id: string, data: string) => { + writes.push({ id, data }) + }), + writeWithSettlement: vi.fn(settledWriteStub()), + resize: vi.fn(), + setPtyBackgrounded: vi.fn(), + getBufferSnapshot: vi.fn(async () => null), + shutdown: vi.fn(async (id: string) => { + const idx = sessions.indexOf(id) + if (idx !== -1) { + sessions.splice(idx, 1) + } + }), + attach: vi.fn(async () => {}), + sendSignal: vi.fn(async () => {}), + getCwd: vi.fn(async () => ''), + getInitialCwd: vi.fn(async () => ''), + clearBuffer: vi.fn(async () => {}), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(async () => false), + getForegroundProcess: vi.fn(async () => null), + inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), + confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), + serialize: vi.fn(async () => '{}'), + revive: vi.fn(async () => {}), + getDefaultShell: vi.fn(async () => '/bin/zsh'), + getProfiles: vi.fn(async () => []), + onData: vi.fn( + (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { + dataListeners.push(callback) + return () => { + const idx = dataListeners.indexOf(callback) + if (idx !== -1) { + dataListeners.splice(idx, 1) + } + } + } + ), + onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { + backgroundListeners.push(callback) + return () => { + const idx = backgroundListeners.indexOf(callback) + if (idx !== -1) { + backgroundListeners.splice(idx, 1) + } + } + }), + onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { + writeUnavailableListeners.push(callback) + return () => { + const idx = writeUnavailableListeners.indexOf(callback) + if (idx !== -1) { + writeUnavailableListeners.splice(idx, 1) + } + } + }), + onExit: vi.fn( + (callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => { + exitListeners.push(callback) + return () => { + const idx = exitListeners.indexOf(callback) + if (idx !== -1) { + exitListeners.splice(idx, 1) + } + } + } + ), + onDaemonIdentityChanged: vi.fn((callback: () => void) => { + identityChangeListeners.push(callback) + return () => { + const idx = identityChangeListeners.indexOf(callback) + if (idx !== -1) { + identityChangeListeners.splice(idx, 1) + } + } + }), + ackColdRestore: vi.fn(), + clearTombstone: vi.fn(), + reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), + dispose: vi.fn(), + disconnectOnly: vi.fn(async () => {}), + emitData: (id: string, data: string, sequenceChars?: number) => { + for (const listener of dataListeners) { + listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) + } + }, + emitBackground: (event: PtyBackgroundStreamEvent) => { + for (const listener of backgroundListeners) { + listener(event) + } + }, + emitExit: (id: string, code: number, incarnationId?: string) => { + for (const listener of exitListeners) { + listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) + } + }, + emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()), + triggerWriteUnavailable: (id: string) => { + for (const listener of writeUnavailableListeners) { + listener({ id }) + } + }, + _writes: writes + } as unknown as AdapterMock +} + it('forwards dead-endpoint write-unavailable signals from every routed adapter', () => { // Why revert-sensitive: main subscribes on the ROUTED provider, so if the router // does not forward this the STA-2373 fan-out never reaches the renderer and only @@ -92,83 +247,6 @@ it('forwards the owning legacy daemon sequence from attach', async () => { }) describe('DaemonPtyRouter', () => { - describe('idle retirement', () => { - it('retires every empty daemon generation and fences subsequent spawns', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' }) - expect(current.requestIdleRetirement).toHaveBeenCalledOnce() - expect(legacy.requestIdleRetirement).toHaveBeenCalledOnce() - await expect(router.spawn({ sessionId: 'late', cols: 80, rows: 24 })).rejects.toThrow( - 'Terminal daemon is decommissioning' - ) - }) - - it('reports live inventory before retiring any generation and reopens admission', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', ['legacy-live'], undefined, PROTOCOL_VERSION) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1, - admissionReopened: true - }) - expect(current.requestIdleRetirement).not.toHaveBeenCalled() - expect(legacy.requestIdleRetirement).not.toHaveBeenCalled() - await expect( - router.spawn({ sessionId: 'after-refusal', cols: 80, rows: 24 }) - ).resolves.toEqual({ - id: 'after-refusal' - }) - }) - - it('does not partially retire when a generation predates clean idle shutdown', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, 23) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'unsupported' }) - expect(current.requestIdleRetirement).not.toHaveBeenCalled() - expect(legacy.requestIdleRetirement).not.toHaveBeenCalled() - }) - - it('keeps admission fenced after a partial multi-generation retirement', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({ - state: 'busy', - liveSessions: 0 - }) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - await expect(router.spawn({ sessionId: 'unsafe', cols: 80, rows: 24 })).rejects.toThrow( - 'Terminal daemon is decommissioning' - ) - }) - - it('does not certify reopened admission when another generation retired beside live sessions', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({ - state: 'busy', - liveSessions: 1, - admissionReopened: true - }) - const router = new DaemonPtyRouter({ current, legacy: [legacy] }) - await expect(router.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1 - }) - await expect( - router.spawn({ sessionId: 'unsafe-partial', cols: 80, rows: 24 }) - ).rejects.toThrow('Terminal daemon is decommissioning') - }) - }) - it('reports separate conservative resume and fresh-create boundaries', () => { const current = createAdapter( 'current', diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 8b5b527cb31..0534c9d2869 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,4 +1,3 @@ -import { reconcileDaemonRouterSessions } from './daemon-router-session-reconciliation' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout' import type { @@ -13,8 +12,6 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' -import { DaemonRouterRetirement } from './daemon-router-retirement' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' @@ -24,7 +21,6 @@ export class DaemonPtyRouter implements IPtyProvider { private sessionAdapters = new Map() private readonly ownerResolver: DaemonSessionOwnerResolver private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout - private readonly retirement = new DaemonRouterRetirement(() => this.allAdapters()) constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { this.current = opts.current @@ -44,34 +40,17 @@ export class DaemonPtyRouter implements IPtyProvider { } async spawn(opts: PtySpawnOptions): Promise { - if (this.retirement.admissionClosed) { - throw new Error('Terminal daemon is decommissioning') + if (opts.attachOnly && opts.sessionId) { + return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) } - // Why counted: an idle-retirement census must not race a spawn it cannot yet see. - this.retirement.spawnInFlight++ - try { - if (opts.attachOnly && opts.sessionId) { - return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) - } - const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined - const target = adapter ?? this.current - const result = await target.spawn(opts) - // Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof. - if (!result.exitedBeforeSpawnReply) { - this.ownerResolver.recordRoute(result.id, target, result.incarnationId) - } - return result - } finally { - this.retirement.spawnInFlight-- + const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined + const target = adapter ?? this.current + const result = await target.spawn(opts) + // Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof. + if (!result.exitedBeforeSpawnReply) { + this.ownerResolver.recordRoute(result.id, target, result.incarnationId) } - } - - requestIdleRetirement(): Promise { - return this.retirement.requestIdleRetirement() - } - - releaseIdleRetirementFence(): void { - this.retirement.releaseFence() + return result } supportsGitCredentialGuardHost(sessionId?: string): boolean { @@ -278,10 +257,38 @@ export class DaemonPtyRouter implements IPtyProvider { this.adapterFor(sessionId).clearTombstone(sessionId) } - async reconcileOnStartup( - validWorktreeIds: Set - ): Promise<{ alive: string[]; killed: string[] }> { - return reconcileDaemonRouterSessions(this.allAdapters(), this.ownerResolver, validWorktreeIds) + async reconcileOnStartup(validWorktreeIds: Set): Promise<{ + alive: string[] + killed: string[] + }> { + const alive: string[] = [] + const killed: string[] = [] + const aliveProviders = new Map>() + for (const adapter of this.allAdapters()) { + const result = await adapter.reconcileOnStartup(validWorktreeIds) + // Why: daemon startup can reconcile many restored sessions; spreading + // those arrays into push can exceed JavaScript's argument limit. + for (const id of result.alive) { + alive.push(id) + } + for (const id of result.killed) { + killed.push(id) + } + for (const id of result.alive) { + const providers = aliveProviders.get(id) ?? new Set() + providers.add(adapter) + aliveProviders.set(id, providers) + } + } + for (const id of new Set([...alive, ...killed])) { + const providers = aliveProviders.get(id) + if (providers?.size === 1) { + this.ownerResolver.recordRoute(id, providers.values().next().value!) + } else { + this.ownerResolver.forgetRoute(id) + } + } + return { alive, killed } } dispose(): void { diff --git a/src/main/daemon/daemon-pty-runtime-state.ts b/src/main/daemon/daemon-pty-runtime-state.ts index 2d773d21ade..38480af1489 100644 --- a/src/main/daemon/daemon-pty-runtime-state.ts +++ b/src/main/daemon/daemon-pty-runtime-state.ts @@ -57,7 +57,6 @@ export type DaemonPtyAdapterOptions = { historyPath?: string runtimeDir?: string packagedAppVersion?: string | null - recoveryOnly?: boolean respawn?: (reason: DaemonRespawnReason) => Promise void)> } @@ -72,15 +71,8 @@ export type DaemonIdentityChangeEvent = { current: DaemonEndpointIdentity } -export type DaemonIdleRetirementResult = - | { state: 'retiring' } - | { state: 'busy'; liveSessions: number | null; admissionReopened?: true } - | { state: 'unsupported' } - | { state: 'unverifiable' } - export abstract class DaemonPtyRuntimeState { readonly protocolVersion: number - readonly recoveryOnly: boolean protected socketPath: string protected tokenPath: string protected pidPath: string | null @@ -100,9 +92,6 @@ export abstract class DaemonPtyRuntimeState { protected packagedAppVersion: string | null protected pendingRespawnAdoptionRelease: (() => void) | null = null protected respawnAdoptionClosed = false - protected idleRetirementAdmissionClosed = false - protected idleRetirementState: 'open' | 'checking' | 'retiring' | 'unverifiable' = 'open' - protected idleRetirementPromise: Promise | null = null protected respawnPromise: Promise | null = null protected staleBundleReplacementPromise: Promise | null = null protected writeRecoveryPromise: Promise | null = null @@ -201,7 +190,6 @@ export abstract class DaemonPtyRuntimeState { constructor(opts: DaemonPtyAdapterOptions) { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION - this.recoveryOnly = opts.recoveryOnly === true this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath this.pidPath = opts.pidPath ?? null @@ -221,7 +209,7 @@ export abstract class DaemonPtyRuntimeState { }) this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null - this.respawnFn = this.recoveryOnly ? null : (opts.respawn ?? null) + this.respawnFn = opts.respawn ?? null this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null this.packagedAppVersion = opts.packagedAppVersion ?? null this.supportsCheckpoints = this.protocolVersion >= 4 diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index e7d04f5851e..388919dd2e3 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -22,15 +22,10 @@ import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { resolveUnixShellPath } from '../providers/local-pty-utils' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' -import { assertDaemonRecoverySpawnAdmission } from './daemon-recovery-spawn-admission' import { addWslEnvKeys } from '../wsl-env' export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { async spawn(opts: PtySpawnOptions): Promise { - assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts) - if (this.idleRetirementAdmissionClosed) { - throw new Error('Terminal daemon is decommissioning') - } const spawnOpts = this.withHistoryIsolation(opts) const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) const operation: PendingDaemonSpawnOperation = { @@ -110,10 +105,6 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { operation: PendingDaemonSpawnOperation, historyRecovery: HistoryRecoveryContext ): Promise { - assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts) - if (this.idleRetirementAdmissionClosed) { - throw new Error('Terminal daemon is decommissioning') - } if ( opts.agentSessionEnsure && this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION diff --git a/src/main/daemon/daemon-recovery-only-adapter.test.ts b/src/main/daemon/daemon-recovery-only-adapter.test.ts deleted file mode 100644 index ba3bf9be04d..00000000000 --- a/src/main/daemon/daemon-recovery-only-adapter.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { rmSync } from 'node:fs' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { DaemonPtyAdapter } from './daemon-pty-adapter' -import { - createMockSubprocess, - startDaemonAdapterHarness, - waitFor, - type DaemonAdapterHarness -} from './daemon-pty-adapter-test-harness' -import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' -import { DaemonPtyRouter } from './daemon-pty-router' - -let harness: DaemonAdapterHarness -let recovery: DaemonPtyAdapter -let subprocess: ReturnType -const spawn = vi.fn(() => subprocess) -const respawn = vi.fn(async () => {}) - -beforeEach(async () => { - spawn.mockClear() - respawn.mockClear() - subprocess = createMockSubprocess() - harness = await startDaemonAdapterHarness(spawn) - await harness.adapter.spawn({ sessionId: 'existing', cols: 80, rows: 24 }) - recovery = new DaemonPtyAdapter({ - socketPath: harness.socketPath, - tokenPath: harness.tokenPath, - recoveryOnly: true, - respawn - }) -}) - -afterEach(async () => { - recovery?.dispose() - harness.adapter.dispose() - await harness.server.shutdown() - rmSync(harness.dir, { recursive: true, force: true }) -}) - -it('reattaches and controls existing work without admitting a new process', async () => { - await expect( - recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ id: 'existing', isReattach: true }) - recovery.write('existing', 'still live\n') - await waitFor(() => subprocess.write.mock.calls.length > 0) - expect(subprocess.write).toHaveBeenCalledWith('still live\n') - await expect(recovery.spawn({ sessionId: 'fresh', cols: 80, rows: 24 })).rejects.toThrow( - 'managed-stop recovery' - ) - await expect( - recovery.spawn({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 }) - ).rejects.toThrow() - expect(spawn).toHaveBeenCalledOnce() - expect(respawn).not.toHaveBeenCalled() -}) - -it('does not certify fresh admission after a confirmed native stop refusal', async () => { - await expect(recovery.requestIdleRetirement()).resolves.toEqual({ - state: 'busy', - liveSessions: 1 - }) - await expect( - recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ isReattach: true }) - await expect(recovery.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery') - expect(spawn).toHaveBeenCalledOnce() -}) - -it('does not invoke a supplied replacement launcher after endpoint loss', async () => { - await recovery.listProcesses() - await harness.server.shutdown() - await expect( - recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).rejects.toThrow() - expect(respawn).not.toHaveBeenCalled() -}) - -it('keeps recovery-only admission through routed inventory refusal', async () => { - const router = new DaemonPtyRouter({ current: recovery, legacy: [] }) - await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'busy', liveSessions: 1 }) - await expect( - router.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ isReattach: true }) - await expect(router.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery') - expect(spawn).toHaveBeenCalledOnce() -}) - -it('rejects legacy attach emulation before contacting the daemon', async () => { - const legacy = new DaemonPtyAdapter({ - socketPath: harness.socketPath, - tokenPath: harness.tokenPath, - protocolVersion: STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION - 1, - recoveryOnly: true - }) - try { - await expect( - legacy.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 }) - ).rejects.toThrow('managed-stop recovery') - expect(spawn).toHaveBeenCalledOnce() - } finally { - legacy.dispose() - } -}) diff --git a/src/main/daemon/daemon-recovery-provider-init.ts b/src/main/daemon/daemon-recovery-provider-init.ts deleted file mode 100644 index 806c45efcf2..00000000000 --- a/src/main/daemon/daemon-recovery-provider-init.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { rebindLocalProviderListeners } from '../ipc/pty' -import { getDaemonRuntimeDir, getDaemonHistoryDir } from './daemon-launch-paths' -import { createDaemonRecoveryProvider } from './daemon-recovery-provider' -import { installDaemonProvider } from './daemon-provider-state' - -export function initDaemonRecoveryProvider(): void { - const provider = createDaemonRecoveryProvider(getDaemonRuntimeDir(), getDaemonHistoryDir()) - installDaemonProvider(null, provider) - rebindLocalProviderListeners() -} diff --git a/src/main/daemon/daemon-recovery-provider.test.ts b/src/main/daemon/daemon-recovery-provider.test.ts deleted file mode 100644 index 17a53b370ea..00000000000 --- a/src/main/daemon/daemon-recovery-provider.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { copyFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' -import { createDaemonRecoveryProvider } from './daemon-recovery-provider' -import { getDaemonPidPath, getDaemonTokenPath } from './daemon-spawner' -import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS } from './types' -import { - createMockSubprocess, - startDaemonAdapterHarness, - type DaemonAdapterHarness -} from './daemon-pty-adapter-test-harness' -import type { DaemonPtyRouter } from './daemon-pty-router' - -let harness: DaemonAdapterHarness -let provider: DaemonPtyRouter | undefined -beforeEach(async () => { - harness = await startDaemonAdapterHarness(() => createMockSubprocess()) - copyFileSync(harness.tokenPath, getDaemonTokenPath(harness.dir)) -}) -afterEach(async () => { - provider?.dispose() - provider = undefined - harness.adapter.dispose() - await harness.server.shutdown() - rmSync(harness.dir, { recursive: true, force: true }) -}) - -it('recovers live terminals without pruning folder or unknown workspace sessions', async () => { - await harness.adapter.spawn({ sessionId: 'folder-terminal', cols: 80, rows: 24 }) - provider = createDaemonRecoveryProvider(harness.dir, join(harness.dir, 'history')) - expect(provider.getAllAdapters().every((entry) => entry.recoveryOnly)).toBe(true) - await expect(provider.reconcileOnStartup(new Set())).resolves.toEqual({ - alive: ['folder-terminal'], - killed: [] - }) - await expect( - provider.spawn({ sessionId: 'folder-terminal', attachOnly: true, cols: 80, rows: 24 }) - ).resolves.toMatchObject({ isReattach: true }) - await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery') -}) - -it('retains unreachable legacy generations and their credentials', async () => { - const version = PREVIOUS_DAEMON_PROTOCOL_VERSIONS[0] - const tokenPath = getDaemonTokenPath(harness.dir, version) - const pidPath = getDaemonPidPath(harness.dir, version) - writeFileSync(tokenPath, 'retained-secret') - writeFileSync(pidPath, '{unreadable pid') - provider = createDaemonRecoveryProvider(harness.dir, join(harness.dir, 'history')) - const legacy = provider.getAllAdapters().find((entry) => entry.protocolVersion === version) - expect(legacy?.recoveryOnly).toBe(true) - await expect(legacy!.listSessions()).rejects.toThrow() - expect(readFileSync(tokenPath, 'utf8')).toBe('retained-secret') - expect(readFileSync(pidPath, 'utf8')).toBe('{unreadable pid') -}) diff --git a/src/main/daemon/daemon-recovery-provider.ts b/src/main/daemon/daemon-recovery-provider.ts deleted file mode 100644 index c27b24a7151..00000000000 --- a/src/main/daemon/daemon-recovery-provider.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { lstatSync } from 'node:fs' -import { DaemonPtyAdapter } from './daemon-pty-adapter' -import { DaemonPtyRouter } from './daemon-pty-router' -import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner' -import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' - -function hasEndpointEvidence(path: string): boolean { - try { - lstatSync(path) - return true - } catch (error) { - // Unreadable evidence must keep the generation represented as unverifiable. - return !(error instanceof Error && 'code' in error && error.code === 'ENOENT') - } -} - -export function createDaemonRecoveryProvider( - runtimeDir: string, - historyPath: string -): DaemonPtyRouter { - const create = (protocolVersion: number): DaemonPtyAdapter => - new DaemonPtyAdapter({ - socketPath: getDaemonSocketPath(runtimeDir, protocolVersion), - tokenPath: getDaemonTokenPath(runtimeDir, protocolVersion), - pidPath: getDaemonPidPath(runtimeDir, protocolVersion), - profileScope: runtimeDir, - runtimeDir, - historyPath, - protocolVersion, - recoveryOnly: true - }) - const legacy = PREVIOUS_DAEMON_PROTOCOL_VERSIONS.filter((version) => - [ - getDaemonPidPath(runtimeDir, version), - getDaemonTokenPath(runtimeDir, version), - ...(process.platform === 'win32' ? [] : [getDaemonSocketPath(runtimeDir, version)]) - ].some(hasEndpointEvidence) - ).map(create) - // Represent the current endpoint even when absent; missing contact is not an empty census. - return new DaemonPtyRouter({ current: create(PROTOCOL_VERSION), legacy }) -} diff --git a/src/main/daemon/daemon-recovery-spawn-admission.ts b/src/main/daemon/daemon-recovery-spawn-admission.ts deleted file mode 100644 index f7e83869eb2..00000000000 --- a/src/main/daemon/daemon-recovery-spawn-admission.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { PtySpawnOptions } from '../providers/types' -import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' - -export function assertDaemonRecoverySpawnAdmission( - recoveryOnly: boolean, - protocolVersion: number, - opts: PtySpawnOptions -): void { - if (!recoveryOnly) { - return - } - // Legacy attach emulation can create before rejecting the result. - if ( - opts.attachOnly !== true || - !opts.sessionId || - opts.agentSessionEnsure || - protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION - ) { - throw new Error('Terminal daemon admission is fenced for managed-stop recovery') - } -} diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 651eaf36ab6..d15514ea2ae 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -10,7 +10,6 @@ import type { DaemonSessionBackgroundRouting } from './daemon-session-background import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import type { DaemonTerminalAdmission } from './daemon-terminal-admission' -import { readDaemonHealthIdentity } from './daemon-health-identity' import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' import type { TerminalHost } from './terminal-host' import { SessionNotFoundError, type DaemonRequest } from './types' @@ -157,7 +156,7 @@ export class DaemonRequestRouter { return { health: await readCurrentProcessMacSystemResolverHealth() } case 'ptySpawnHealth': await this.options.ptySpawnHealthCheck() - return { healthy: true, ...readDaemonHealthIdentity() } + return { healthy: true } case 'shutdown': return this.shutdown(clientId, request.id, request.payload.killSessions) } diff --git a/src/main/daemon/daemon-router-retirement.test.ts b/src/main/daemon/daemon-router-retirement.test.ts deleted file mode 100644 index 66e6e60172e..00000000000 --- a/src/main/daemon/daemon-router-retirement.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { DaemonRouterRetirement } from './daemon-router-retirement' -import { createAdapter } from './daemon-pty-router-test-fixture' -import { PROTOCOL_VERSION } from './types' - -it.each(['inventory', 'protocol', 'spawn', 'live'] as const)( - 'does not reopen admission on a %s retry after partial retirement', - async (failure) => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION) - let adapters = [current, legacy] - const retirement = new DaemonRouterRetirement(() => adapters) - vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({ - state: 'busy', - liveSessions: 0 - }) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) - if (failure === 'inventory') { - vi.mocked(current.listSessions).mockRejectedValueOnce(new Error('lost connection')) - } else if (failure === 'protocol') { - adapters = [createAdapter('old', [], undefined, 23)] - } else if (failure === 'spawn') { - retirement.spawnInFlight = 1 - } else { - adapters = [createAdapter('live', ['existing'], undefined, PROTOCOL_VERSION)] - } - expect(await retirement.requestIdleRetirement()).not.toHaveProperty('admissionReopened') - expect(retirement.admissionClosed).toBe(true) - } -) - -it('does not reopen when every native result is busy without reopening proof', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - vi.mocked(current.requestIdleRetirement).mockResolvedValue({ state: 'busy', liveSessions: 0 }) - const retirement = new DaemonRouterRetirement(() => [current]) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) -}) - -it('keeps the fence through a lost native reply and failed retry inventory', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - vi.mocked(current.requestIdleRetirement).mockRejectedValueOnce(new Error('lost stop reply')) - const retirement = new DaemonRouterRetirement(() => [current]) - await expect(retirement.requestIdleRetirement()).rejects.toThrow('lost stop reply') - vi.mocked(current.listSessions).mockRejectedValueOnce(new Error('lost connection')) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) -}) - -it('releases a fence left by an incomplete retirement, but never one that retired', async () => { - const current = createAdapter('current', [], undefined, PROTOCOL_VERSION) - vi.mocked(current.requestIdleRetirement).mockResolvedValueOnce({ state: 'busy', liveSessions: 0 }) - const retirement = new DaemonRouterRetirement(() => [current]) - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' }) - expect(retirement.admissionClosed).toBe(true) - retirement.releaseFence() - expect(retirement.admissionClosed).toBe(false) - expect(current.releaseIdleRetirementFence).toHaveBeenCalledOnce() - - await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' }) - retirement.releaseFence() - expect(retirement.admissionClosed).toBe(true) -}) diff --git a/src/main/daemon/daemon-router-retirement.ts b/src/main/daemon/daemon-router-retirement.ts deleted file mode 100644 index 3ea17ab581a..00000000000 --- a/src/main/daemon/daemon-router-retirement.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state' -import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' - -export class DaemonRouterRetirement { - admissionClosed = false - spawnInFlight = 0 - private retirementAttempted = false - private retired = false - private idleRetirementPromise: Promise | null = null - - constructor(private readonly allAdapters: () => DaemonPtyAdapter[]) {} - - /** Reopens a fence left by an attempt that did not retire every generation. */ - releaseFence(): void { - if (this.idleRetirementPromise || this.retired) { - return - } - this.admissionClosed = false - for (const adapter of this.allAdapters()) { - adapter.releaseIdleRetirementFence() - } - } - - async requestIdleRetirement(): Promise { - if (this.idleRetirementPromise) { - return this.idleRetirementPromise - } - this.admissionClosed = true - const request = this.finishIdleRetirementRequest().finally(() => { - if (this.idleRetirementPromise === request) { - this.idleRetirementPromise = null - } - }) - this.idleRetirementPromise = request - return request - } - - private async finishIdleRetirementRequest(): Promise { - const adapters = this.allAdapters() - if (this.spawnInFlight > 0) { - this.admissionClosed = this.retirementAttempted - return { state: 'busy', liveSessions: null } - } - if (adapters.some((adapter) => adapter.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION)) { - this.admissionClosed = this.retirementAttempted - return { state: 'unsupported' } - } - const inventories = await Promise.allSettled(adapters.map((adapter) => adapter.listSessions())) - if (inventories.some((inventory) => inventory.status === 'rejected')) { - this.admissionClosed = this.retirementAttempted - return { state: 'unverifiable' } - } - const liveSessions = inventories.reduce( - (count, inventory) => - count + - (inventory.status === 'fulfilled' - ? inventory.value.filter((session) => session.isAlive).length - : 0), - 0 - ) - if (liveSessions > 0) { - this.admissionClosed = this.retirementAttempted - return { - state: 'busy', - liveSessions, - ...(!this.retirementAttempted && !adapters.some((adapter) => adapter.recoveryOnly) - ? { admissionReopened: true as const } - : {}) - } - } - this.retirementAttempted = true - const results = await Promise.all(adapters.map((adapter) => adapter.requestIdleRetirement())) - if (results.every((result) => result.state === 'retiring')) { - this.retired = true - return { state: 'retiring' } - } - const refusedLiveSessions = results.reduce( - (count, result) => count + (result.state === 'busy' ? (result.liveSessions ?? 0) : 0), - 0 - ) - if (refusedLiveSessions > 0) { - return { state: 'busy', liveSessions: refusedLiveSessions } - } - return { state: 'unverifiable' } - } -} diff --git a/src/main/daemon/daemon-router-session-reconciliation.ts b/src/main/daemon/daemon-router-session-reconciliation.ts deleted file mode 100644 index d58487da106..00000000000 --- a/src/main/daemon/daemon-router-session-reconciliation.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' - -export async function reconcileDaemonRouterSessions( - adapters: readonly DaemonPtyAdapter[], - ownerResolver: DaemonSessionOwnerResolver, - validWorktreeIds: Set -): Promise<{ alive: string[]; killed: string[] }> { - const alive: string[] = [] - const killed: string[] = [] - const aliveProviders = new Map>() - for (const adapter of adapters) { - const result = await adapter.reconcileOnStartup(validWorktreeIds) - // Why: daemon startup can reconcile many restored sessions; spreading - // those arrays into push can exceed JavaScript's argument limit. - for (const id of result.alive) { - alive.push(id) - } - for (const id of result.killed) { - killed.push(id) - } - for (const id of result.alive) { - const providers = aliveProviders.get(id) ?? new Set() - providers.add(adapter) - aliveProviders.set(id, providers) - } - } - for (const id of new Set([...alive, ...killed])) { - const providers = aliveProviders.get(id) - if (providers?.size === 1) { - ownerResolver.recordRoute(id, providers.values().next().value!) - } else { - ownerResolver.forgetRoute(id) - } - } - return { alive, killed } -} diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts index 7d6f825b3f7..f10715e8711 100644 --- a/src/main/daemon/daemon-stream-data-batcher.test.ts +++ b/src/main/daemon/daemon-stream-data-batcher.test.ts @@ -118,20 +118,6 @@ describe('DaemonStreamDataBatcher', () => { }) }) - it('preserves PTY incarnation identity through stream serialization', () => { - const { batcher, streamSocket } = createBatcher() - - batcher.enqueue('client-1', 'session-1', 'output', { - flushImmediately: true, - incarnationId: 'incarnation-1' - }) - - expect(JSON.parse(String(streamSocket.write.mock.calls[0]?.[0]))).toMatchObject({ - event: 'data', - payload: { data: 'output', incarnationId: 'incarnation-1' } - }) - }) - it('keeps large pending output batched even when an interactive redraw follows', () => { vi.useFakeTimers() try { diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 202d59c4009..a562a60ee9d 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -246,8 +246,7 @@ export class DaemonStreamDataBatcher { this.maxLineBytes, sliceSequenceChars, entry.seq, - entry.transformed, - entry.incarnationId + entry.transformed ) } this.updateBackpressure(clientId, batch) diff --git a/src/main/daemon/daemon-stream-data-entry.ts b/src/main/daemon/daemon-stream-data-entry.ts index e4159aaa88a..017d21fde8c 100644 --- a/src/main/daemon/daemon-stream-data-entry.ts +++ b/src/main/daemon/daemon-stream-data-entry.ts @@ -2,7 +2,6 @@ import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' import { writeStreamDataEvents } from './daemon-stream-data-split' import { encodeNdjson } from './ndjson' import { accountDaemonStreamEntry } from './daemon-stream-entry-accounting' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type DaemonStreamEnqueueOptions = { flushImmediately?: boolean @@ -10,7 +9,6 @@ export type DaemonStreamEnqueueOptions = { rawLength?: number transformed?: boolean seq?: number - incarnationId?: PtyIncarnationId } export function appendDaemonStreamData( @@ -25,8 +23,7 @@ export function appendDaemonStreamData( last?.sessionId === sessionId && !last.control && !last.transformed && - options.transformed !== true && - last.incarnationId === options.incarnationId + options.transformed !== true ) { last.data += data const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length @@ -42,8 +39,7 @@ export function appendDaemonStreamData( ? {} : { sequenceChars: options.rawLength }), ...(options.transformed ? { transformed: true } : {}), - ...(options.seq === undefined ? {} : { seq: options.seq }), - ...(options.incarnationId === undefined ? {} : { incarnationId: options.incarnationId }) + ...(options.seq === undefined ? {} : { seq: options.seq }) }) ) } @@ -95,8 +91,7 @@ export function flushDaemonStreamSession( maxLineBytes, entry.sequenceChars ?? entry.data.length, entry.seq, - entry.transformed, - entry.incarnationId + entry.transformed ) } } diff --git a/src/main/daemon/daemon-stream-data-split.test.ts b/src/main/daemon/daemon-stream-data-split.test.ts index 2fe9983252a..48cf8cbc80e 100644 --- a/src/main/daemon/daemon-stream-data-split.test.ts +++ b/src/main/daemon/daemon-stream-data-split.test.ts @@ -18,8 +18,7 @@ function write( rawLength = data.length, seq?: number, transformed = false, - sessionId = 'session-1', - incarnationId?: string + sessionId = 'session-1' ): string[] { const lines: string[] = [] const socket: Pick = { @@ -28,16 +27,7 @@ function write( return true }) } - writeStreamDataEvents( - socket, - sessionId, - data, - maxLineBytes, - rawLength, - seq, - transformed, - incarnationId - ) + writeStreamDataEvents(socket, sessionId, data, maxLineBytes, rawLength, seq, transformed) return lines } @@ -94,15 +84,6 @@ describe('writeStreamDataEvents serialization budget', () => { expect(encodeNdjson).toHaveBeenCalledTimes(1) }) - it('retains incarnation metadata in the single-encode fast path', () => { - const line = encodeStreamDataEvent('session-1', 'é🐙', undefined, undefined, false, 'epoch-1') - vi.mocked(encodeNdjson).mockClear() - expect( - write('é🐙', Buffer.byteLength(line), 3, undefined, false, 'session-1', 'epoch-1') - ).toEqual([line]) - expect(encodeNdjson).toHaveBeenCalledTimes(1) - }) - it('does not add a duplicate full-data sizing probe to oversized writes', () => { const data = '🐙\x1b[0m'.repeat(100) const expected = previousWrites(data, 160) @@ -121,24 +102,6 @@ describe('writeStreamDataEvents serialization budget', () => { }) describe('writeStreamDataEvents wire parity', () => { - it.each([undefined, 9000])('budgets incarnation metadata on oversized writes (seq=%s)', (seq) => { - const data = '🐙é中\x1b[0m"\\\n'.repeat(100) - const incarnationId = 'epoch-'.repeat(16) - const lines = write(data, 384, data.length, seq, false, 'session-1', incarnationId) - expect(lines.length).toBeGreaterThan(1) - let consumed = 0 - const chunks = lines.map((line) => { - expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(384) - const { payload } = JSON.parse(line) - expect(payload.incarnationId).toBe(incarnationId) - expect(typeof payload.data).toBe('string') - consumed += payload.data.length - expect(payload.seq).toBe(seq === undefined ? undefined : seq - data.length + consumed) - return payload.data - }) - expect(chunks.join('')).toBe(data) - }) - it('preserves exact frames, chunk boundaries and metadata across payloads and caps', () => { const payloads = [ '', diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts index b644d971e4b..c31c4437b84 100644 --- a/src/main/daemon/daemon-stream-data-split.ts +++ b/src/main/daemon/daemon-stream-data-split.ts @@ -5,15 +5,13 @@ */ import { resolveSynchronizedOutputSafeSplit } from '../../shared/terminal-synchronized-output-scan' import { encodeNdjson } from './ndjson' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' export function encodeStreamDataEvent( sessionId: string, data: string, rawLength?: number, seq?: number, - transformed?: boolean, - incarnationId?: PtyIncarnationId + transformed?: boolean ): string { return encodeNdjson({ type: 'event', @@ -21,7 +19,6 @@ export function encodeStreamDataEvent( sessionId, payload: { data, - ...(incarnationId === undefined ? {} : { incarnationId }), ...(seq === undefined ? {} : { seq }), ...(rawLength === undefined ? {} : { rawLength }), ...(rawLength === undefined ? {} : { sequenceChars: rawLength }), @@ -30,16 +27,8 @@ export function encodeStreamDataEvent( }) } -function streamDataEventLineBytes( - sessionId: string, - data: string, - rawLength?: number, - incarnationId?: PtyIncarnationId -): number { - return Buffer.byteLength( - encodeStreamDataEvent(sessionId, data, rawLength, undefined, false, incarnationId), - 'utf8' - ) +function streamDataEventLineBytes(sessionId: string, data: string, rawLength?: number): number { + return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, rawLength), 'utf8') } function isHighSurrogate(value: number): boolean { @@ -90,28 +79,20 @@ export function splitStreamDataForNdjson( sessionId: string, data: string, maxLineBytes: number, - sequenceChars?: number, - incarnationId?: PtyIncarnationId + sequenceChars?: number ): string[] { - if (streamDataEventLineBytes(sessionId, data, sequenceChars, incarnationId) <= maxLineBytes) { + if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) { return [data] } - return splitOversizedStreamDataForNdjson( - sessionId, - data, - maxLineBytes, - sequenceChars, - incarnationId - ) + return splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes, sequenceChars) } function splitOversizedStreamDataForNdjson( sessionId: string, data: string, maxLineBytes: number, - sequenceChars?: number, - incarnationId?: PtyIncarnationId + sequenceChars?: number ): string[] { const chunks: string[] = [] let start = 0 @@ -129,8 +110,7 @@ function splitOversizedStreamDataForNdjson( } if ( - streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars, incarnationId) <= - maxLineBytes + streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes ) { best = mid low = rawMid + 1 @@ -154,36 +134,28 @@ export function writeStreamDataEvents( maxLineBytes: number, rawLength = data.length, seq?: number, - transformed = false, - incarnationId?: PtyIncarnationId + transformed = false ): void { const explicitRawLength = rawLength === data.length ? undefined : rawLength if (transformed) { - streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true, incarnationId)) + streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true)) return } const carriesMetadata = explicitRawLength !== undefined || seq !== undefined let chunks: string[] if (!carriesMetadata) { - const line = encodeStreamDataEvent(sessionId, data, undefined, undefined, false, incarnationId) + const line = encodeStreamDataEvent(sessionId, data) if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) { streamSocket.write(line) return } - chunks = splitOversizedStreamDataForNdjson( - sessionId, - data, - maxLineBytes, - undefined, - incarnationId - ) + chunks = splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes) } else { chunks = splitStreamDataForNdjson( sessionId, data, Math.max(1, maxLineBytes - 96), - explicitRawLength, - incarnationId + explicitRawLength ) } let consumed = 0 @@ -191,8 +163,6 @@ export function writeStreamDataEvents( consumed += chunk.length const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed) const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined - streamSocket.write( - encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq, false, incarnationId) - ) + streamSocket.write(encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq)) } } diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts index ede46bff133..55e9f053316 100644 --- a/src/main/daemon/daemon-stream-events.ts +++ b/src/main/daemon/daemon-stream-events.ts @@ -9,7 +9,6 @@ export type DataEvent = { sessionId: string payload: { data: string - incarnationId?: PtyIncarnationId seq?: number rawLength?: number transformed?: boolean diff --git a/src/main/daemon/daemon-stream-incarnation.test.ts b/src/main/daemon/daemon-stream-incarnation.test.ts deleted file mode 100644 index e52a49c444a..00000000000 --- a/src/main/daemon/daemon-stream-incarnation.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { appendDaemonStreamData, flushDaemonStreamSession } from './daemon-stream-data-entry' -import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' -import { SessionOutputPlane } from './session-output-plane' - -function batch(): PendingStreamDataBatch { - return { - timer: null, - queue: [], - queuedChars: 0, - queuedCharsBySession: new Map(), - queuedMetadataBytesBySession: new Map(), - droppableQueuedSessionIds: new Set() - } -} - -describe('daemon stream incarnation ids', () => { - it('never merges output from two incarnations into one queued entry', () => { - const pending = batch() - appendDaemonStreamData(pending, 'session-1', 'old', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'more', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'new', { incarnationId: 'incarnation-2' }) - - expect(pending.queue.map(({ data, incarnationId }) => ({ data, incarnationId }))).toEqual([ - { data: 'oldmore', incarnationId: 'incarnation-1' }, - { data: 'new', incarnationId: 'incarnation-2' } - ]) - }) - - it('writes the id on per-session flushes and omits it when absent', () => { - const pending = batch() - appendDaemonStreamData(pending, 'session-1', 'tagged', { incarnationId: 'incarnation-1' }) - appendDaemonStreamData(pending, 'session-1', 'legacy', {}) - const lines: string[] = [] - flushDaemonStreamSession(pending, 'session-1', 64 * 1024, (line) => lines.push(line)) - - const payloads = lines.map((line) => JSON.parse(line).payload) - expect(payloads).toEqual([ - { data: 'tagged', incarnationId: 'incarnation-1' }, - { data: 'legacy' } - ]) - }) - - it('hands the session incarnation only to identity-aware clients', () => { - const plane = new SessionOutputPlane({ cols: 80, rows: 24, incarnationId: 'incarnation-1' }) - const legacy = vi.fn() - const aware = vi.fn() - const unused = vi.fn() - plane.attachClient({ onData: legacy, onExit: vi.fn() }) - plane.attachClient({ onData: unused, onDataWithIncarnation: aware, onExit: vi.fn() }) - - plane.emit({ data: 'hi', rawStartSeq: 0, rawEndSeq: 2, transformed: false }) - - expect(legacy).toHaveBeenCalledWith('hi') - expect(aware).toHaveBeenCalledWith('hi', undefined, undefined, undefined, 'incarnation-1') - expect(unused).not.toHaveBeenCalled() - plane.disposeEmulator() - }) -}) diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts index 5fcfeed09a0..7bbd20047fe 100644 --- a/src/main/daemon/daemon-stream-keep-tail-drop.ts +++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts @@ -14,7 +14,6 @@ import { accountDaemonStreamEntry, releaseDaemonStreamEntry } from './daemon-stream-entry-accounting' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' // A control entry carries a whole pre-shaped stream event (background marker, // data gap, transient fact) that must ride at its exact position in the @@ -28,7 +27,6 @@ export type StreamQueueEntry = { sequenceChars?: number seq?: number transformed?: boolean - incarnationId?: PtyIncarnationId control?: DaemonEvent retainedBytes?: number } diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index 984cbc87dec..f02cbdb9205 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -173,32 +173,23 @@ export class DaemonTerminalAdmission { clientId: string, sessionId: () => string ): CreateOrAttachOptions['streamClient'] { - const onData = ( - data: string, - rawLength = data.length, - transformed = false, - seq?: number, - incarnationId?: string - ): void => { - const routedSessionId = sessionId() - this.options.transientFactRelay.onSessionData(routedSessionId, data) - const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) - const isInteractiveOutput = - data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS - this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { - flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, - rawLength, - transformed, - seq, - ...(incarnationId === undefined ? {} : { incarnationId }) - }) - } return { - onData, - onDataWithIncarnation: onData, + onData: (data, rawLength = data.length, transformed = false, seq) => { + const routedSessionId = sessionId() + this.options.transientFactRelay.onSessionData(routedSessionId, data) + const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) + const isInteractiveOutput = + data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && + lastInputAt !== undefined && + performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS + this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { + flushImmediately: isInteractiveOutput, + flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, + rawLength, + transformed, + seq + }) + }, onExit: (code, incarnationId, cause) => { const routedSessionId = sessionId() this.options.log.log('session-exited', { diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts index 819b9c72259..f67d2e15b92 100644 --- a/src/main/daemon/session-output-pipeline.ts +++ b/src/main/daemon/session-output-pipeline.ts @@ -1,7 +1,6 @@ import { SessionOutputPlane } from './session-output-plane' import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' import type { SubprocessHandle } from './session-subprocess-handle' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' /** The session's ordered output pipeline: the recovery barrier feeding the * output plane. Built together because the barrier's owner is what the @@ -12,7 +11,6 @@ export function createSessionOutputPipeline(opts: { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined - incarnationId?: PtyIncarnationId | undefined subprocess: SubprocessHandle isAlive: () => boolean }): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { @@ -24,7 +22,6 @@ export function createSessionOutputPipeline(opts: { scrollback: opts.scrollback, wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, - incarnationId: opts.incarnationId, getTerminalOwner: () => barrier?.getOwner() }) const recoveryBarrier = new TerminalShellRecoveryBarrier({ diff --git a/src/main/daemon/session-output-plane.ts b/src/main/daemon/session-output-plane.ts index d994fdf4b48..04b3f773bda 100644 --- a/src/main/daemon/session-output-plane.ts +++ b/src/main/daemon/session-output-plane.ts @@ -5,7 +5,6 @@ import { normalizePtySize } from './daemon-pty-size' import type { PtyIngressEmission } from '../../shared/pty-startup-ingress' import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types' import type { TerminalOwner } from '../../shared/terminal-owner' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { SubprocessHandle } from './session-subprocess-handle' import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint' @@ -16,14 +15,6 @@ const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024 export type AttachedClient = { token: symbol onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - /** Identity-bearing callback used by mutation-aware consumers; legacy clients keep the old shape. */ - onDataWithIncarnation?: ( - data: string, - rawLength: number | undefined, - transformed: boolean | undefined, - seq: number | undefined, - incarnationId: PtyIncarnationId - ) => void onExit: (code: number, incarnationId: string, cause?: TerminalExitCause) => void } @@ -33,7 +24,6 @@ export type SessionOutputPlaneOptions = { scrollback?: number | undefined wslDistro?: string | undefined historySeedChunks?: readonly string[] | undefined - incarnationId?: PtyIncarnationId | undefined /** Read from the recovery barrier at snapshot time; the barrier scans bytes * before this plane receives them, so its owner never lags the emulator. */ getTerminalOwner?: (() => TerminalOwner | undefined) | undefined @@ -45,7 +35,6 @@ export class SessionOutputPlane { readonly historySeeded: boolean | undefined private readonly emulator: HeadlessEmulator private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined - private readonly incarnationId: PtyIncarnationId | undefined private attachedClients: AttachedClient[] = [] private pendingOutputRecords: PendingOutputRecord[] = [] private pendingOutputBytes = 0 @@ -75,7 +64,6 @@ export class SessionOutputPlane { ? undefined : opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk)) this.readTerminalOwner = opts.getTerminalOwner - this.incarnationId = opts.incarnationId } get responderParser(): HeadlessEmulator['responderParser'] { @@ -209,7 +197,9 @@ export class SessionOutputPlane { return } this.record({ kind: 'output', data: pending }) - this.broadcastData(pending, 0, true, this._outputSequence) + for (const client of this.attachedClients) { + client.onData(pending, 0, true, this._outputSequence) + } } emit(emission: PtyIngressEmission): void { @@ -226,24 +216,9 @@ export class SessionOutputPlane { } // Broadcast to attached clients - if (emission.transformed || rawLength !== data.length) { - this.broadcastData(data, rawLength, true, this._outputSequence) - } else { - this.broadcastData(data) - } - } - - private broadcastData( - data: string, - rawLength?: number, - transformed?: boolean, - seq?: number - ): void { for (const client of this.attachedClients) { - if (client.onDataWithIncarnation && this.incarnationId) { - client.onDataWithIncarnation(data, rawLength, transformed, seq, this.incarnationId) - } else if (transformed || rawLength !== undefined || seq !== undefined) { - client.onData(data, rawLength, transformed, seq) + if (emission.transformed || rawLength !== data.length) { + client.onData(data, rawLength, true, this._outputSequence) } else { client.onData(data) } diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7c63dbd1d3f..7d113665292 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -12,7 +12,12 @@ import type { TuiAgent } from '../../shared/tui-agent' import { randomUUID } from 'node:crypto' import { PtyStartupIngress } from '../../shared/pty-startup-ingress' -import type * as SessionProtocol from './types' +import type { + SessionState, + ShellReadyState, + TakePendingOutputResult, + TerminalSnapshot +} from './types' import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' @@ -23,7 +28,7 @@ export class Session { readonly launchAgent: TuiAgent | null readonly wslDistro: string | null readonly processNameIsSpawnFile: boolean - private _state: SessionProtocol.SessionState = 'running' + private _state: SessionState = 'running' private _exitCode: number | null = null private _disposed = false private subprocess: SubprocessHandle @@ -50,8 +55,7 @@ export class Session { wslDistro: opts.wslDistro, historySeedChunks: opts.historySeedChunks, subprocess: this.subprocess, - isAlive: () => !this._disposed && this._state !== 'exited', - incarnationId: this.incarnationId + isAlive: () => !this._disposed && this._state !== 'exited' }) this.output = pipeline.output this.recoveryBarrier = pipeline.recoveryBarrier @@ -91,11 +95,11 @@ export class Session { this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause)) } - get state(): SessionProtocol.SessionState { + get state(): SessionState { return this._state } - get shellState(): SessionProtocol.ShellReadyState { + get shellState(): ShellReadyState { return this.shellReady.state } @@ -212,7 +216,7 @@ export class Session { this.producerPause.release({ resume: true }) } - getSnapshot(opts: { scrollbackRows?: number } = {}): SessionProtocol.TerminalSnapshot | null { + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { this.startupIngress.snapshotBarrier() return this.output.getSnapshot(opts) } @@ -228,7 +232,7 @@ export class Session { takePendingOutput( includeSnapshot: boolean, opts: { teardownSnapshot?: boolean } = {} - ): SessionProtocol.TakePendingOutputResult | null { + ): TakePendingOutputResult | null { if (this._disposed) { return null } diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 654df23af81..9f6e6fbec1b 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -37,13 +37,6 @@ export type CreateOrAttachOptions = { } streamClient: { onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void - onDataWithIncarnation?: ( - data: string, - rawLength: number | undefined, - transformed: boolean | undefined, - seq: number | undefined, - incarnationId: PtyIncarnationId - ) => void onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void } /** Lets the daemon route output under the adopted owner's canonical id before diff --git a/src/main/git/local-repo-ref-maintenance.test.ts b/src/main/git/local-repo-ref-maintenance.test.ts index f6a411733c3..7d084a4bd5a 100644 --- a/src/main/git/local-repo-ref-maintenance.test.ts +++ b/src/main/git/local-repo-ref-maintenance.test.ts @@ -77,7 +77,12 @@ describe('local repo ref maintenance target', () => { it('reads either Git auto-maintenance opt-out, and unset keys as consent', async () => { for (const stdout of [ 'maintenance.auto false\n', + 'maintenance.auto NO\n', + 'maintenance.auto off\n', + 'maintenance.auto 0\n', + 'maintenance.auto \n', 'gc.auto 0\n', + 'gc.auto 0k\n', 'gc.auto 6700\nmaintenance.auto false\n' ]) { gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' }) @@ -91,10 +96,20 @@ describe('local repo ref maintenance target', () => { await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) // `git config --get-regexp` exits non-zero when nothing matches. - gitExecFileAsyncMock.mockRejectedValue(new Error('exit 1')) + gitExecFileAsyncMock.mockRejectedValue(Object.assign(new Error('key unset'), { code: 1 })) await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) }) + it('fails closed when the auto-maintenance config cannot be read', async () => { + for (const error of [ + new Error('spawn failed'), + Object.assign(new Error('bad config'), { code: 128 }) + ]) { + gitExecFileAsyncMock.mockRejectedValue(error) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(true) + } + }) + it('walks the POSIX refs directory for a native repo', async () => { readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') diff --git a/src/main/git/local-repo-ref-maintenance.ts b/src/main/git/local-repo-ref-maintenance.ts index e84f7d1ae30..6c0836b58b1 100644 --- a/src/main/git/local-repo-ref-maintenance.ts +++ b/src/main/git/local-repo-ref-maintenance.ts @@ -12,6 +12,11 @@ import { import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { withSpan } from '../observability/tracer' import { PackRefsLockOwnership } from './pack-refs-lock-ownership' +import { + clearRepoPackIndexMaintenanceCache, + isUnsetGitConfigError, + maintainRepoPackIndex +} from './repo-pack-index-maintenance' import { gitExecFileAsync } from './runner' import { readRepoCommonDirFromGit } from './worktree-list-reader' @@ -96,6 +101,7 @@ export function disposeLocalRepoRefMaintenance(): Promise { shared?.dispose() shared = null repoBusyProbes.clear() + clearRepoPackIndexMaintenanceCache() return settling } @@ -149,6 +155,7 @@ export function _resetLocalRepoRefMaintenanceForTests( shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null activityProbe = null repoBusyProbes.clear() + clearRepoPackIndexMaintenanceCache() } /** @@ -174,8 +181,11 @@ function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | unde export function isGitAutoMaintenanceDisabled(configOutput: string): boolean { return configOutput .split('\n') - .map((line) => line.trim()) - .some((line) => line === 'maintenance.auto false' || line === 'gc.auto 0') + .some( + (line) => + /^\s*maintenance\.auto\s+(?:false|no|off|0)?\s*$/i.test(line) || + /^\s*gc\.auto\s+[+-]?0+(?:[kmg])?\s*$/i.test(line) + ) } /** @@ -237,11 +247,25 @@ export function createLocalRepoRefMaintenanceTarget( { cwd: args.repoPath, ...gitOptions, admissionTier: 'background', signal } ) return isGitAutoMaintenanceDisabled(stdout) - } catch { - // Neither key set is the common case and exits non-zero; that is consent. - return false + } catch (error) { + // An unset key is consent; unreadable or invalid config must fail closed. + return !isUnsetGitConfigError(error) } }, + async maintainPackIndex(signal, span, canWrite) { + const resolved = await resolveCommonDir(signal) + if (resolved) { + return maintainRepoPackIndex({ + repoPath: args.repoPath, + commonDir: gitCommonDirForMainProcess(resolved, args.wslDistro), + ...gitOptions, + signal, + span, + canWrite + }) + } + return 'failed' + }, async packRefs(lock: PackedRefsLockReporter) { const resolved = await resolveCommonDir() const owner = resolved diff --git a/src/main/git/repo-pack-index-maintenance.test.ts b/src/main/git/repo-pack-index-maintenance.test.ts new file mode 100644 index 00000000000..1fee9644f03 --- /dev/null +++ b/src/main/git/repo-pack-index-maintenance.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, opendirMock, statMock, openMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + opendirMock: vi.fn(), + statMock: vi.fn(), + openMock: vi.fn() +})) + +vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('node:fs/promises', () => ({ opendir: opendirMock, stat: statMock, open: openMock })) + +import { + maintainRepoPackIndex, + clearRepoPackIndexMaintenanceCache, + PACK_INDEX_FORCE_REFRESH_MS, + PACK_INDEX_THRESHOLD, + PACK_INDEX_TIMEOUT_MS +} from './repo-pack-index-maintenance' +import { PACK_INDEX_PROBE_ENTRY_LIMIT } from './repo-pack-index-state' + +function directory(packs: number) { + return { + async *[Symbol.asyncIterator]() { + for (let index = 0; index < packs; index += 1) { + yield { name: `pack-${index}.idx`, isFile: () => true, isSymbolicLink: () => false } + yield { name: `pack-${index}.pack`, isFile: () => true, isSymbolicLink: () => false } + } + } + } +} + +function args(wslDistro?: string) { + const attributes: Record = {} + return { + repoPath: wslDistro ? '//wsl$/Ubuntu/repo' : '/repo', + commonDir: wslDistro ? String.raw`\\wsl.localhost\Ubuntu\repo\.git` : '/repo/.git', + ...(wslDistro ? { wslDistro } : {}), + signal: new AbortController().signal, + canWrite: () => true, + span: { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value + } + }, + attributes + } +} + +beforeEach(() => { + vi.resetAllMocks() + clearRepoPackIndexMaintenanceCache() + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'config') { + throw Object.assign(new Error('unset'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }) + opendirMock.mockResolvedValue(directory(PACK_INDEX_THRESHOLD)) + statMock.mockResolvedValue({ dev: 1n, ino: 2n, mtimeNs: 3n, ctimeNs: 4n }) + openMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) +}) + +describe('idle pack index maintenance', () => { + it('leaves a healthy repository alone', async () => { + const options = args() + opendirMock.mockResolvedValue(directory(PACK_INDEX_THRESHOLD - 1)) + await maintainRepoPackIndex(options) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(options.attributes['git.pack_index_outcome']).toBe('below_threshold') + }) + + it('writes only the lookup index with background admission and a deadline', async () => { + const options = args() + await maintainRepoPackIndex(options) + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(['multi-pack-index', 'write'], { + cwd: '/repo', + admissionTier: 'background', + timeout: PACK_INDEX_TIMEOUT_MS + }) + expect(options.attributes['git.pack_index_outcome']).toBe('written') + }) + + it('rechecks idle admission after probing and never aborts an admitted writer', async () => { + const options = args() + await maintainRepoPackIndex({ ...options, canWrite: () => false }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(opendirMock).not.toHaveBeenCalled() + expect(options.attributes['git.pack_index_outcome']).toBe('deferred') + let idle = true + opendirMock.mockResolvedValueOnce({ + async *[Symbol.asyncIterator]() { + yield* directory(PACK_INDEX_THRESHOLD) + idle = false + } + }) + await expect(maintainRepoPackIndex({ ...args(), canWrite: () => idle })).resolves.toBe( + 'deferred' + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + await maintainRepoPackIndex(args()) + expect(gitExecFileAsyncMock.mock.lastCall?.[1]).not.toHaveProperty('signal') + }) + + it('caps a directory stream and skips writes when bitmap absence cannot be proved', async () => { + let produced = 0 + let closed = false + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + try { + while (produced < 100_000) { + produced += 1 + yield { name: `pack-${produced}.pack`, isFile: () => true, isSymbolicLink: () => false } + } + } finally { + closed = true + } + } + }) + const options = args() + await maintainRepoPackIndex(options) + expect(produced).toBe(PACK_INDEX_PROBE_ENTRY_LIMIT) + expect(closed).toBe(true) + expect(options.attributes['git.pack_index_outcome']).toBe('protected') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('inspects the measured 11,424-pack repository with four directory entries per pack', async () => { + let produced = 0 + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (let index = 0; index < 11_424; index += 1) { + for (const suffix of ['pack', 'idx', 'rev', 'keep']) { + produced += 1 + yield { + name: `pack-${index}.${suffix}`, + isFile: () => true, + isSymbolicLink: () => false + } + } + } + } + }) + await expect(maintainRepoPackIndex(args())).resolves.toBe('written') + expect(produced).toBe(11_424 * 4) + expect(opendirMock).toHaveBeenCalledOnce() + }) + + it('honours an explicit multi-pack-index opt-out before walking objects', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'false\n', stderr: '' }) + await maintainRepoPackIndex(args()) + expect(opendirMock).not.toHaveBeenCalled() + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('fails closed on config errors other than an unset key', async () => { + gitExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('invalid config'), { code: 128 }) + ) + const options = args() + await maintainRepoPackIndex(options) + expect(opendirMock).not.toHaveBeenCalled() + expect(options.attributes['git.pack_index_outcome']).toBe('failed') + }) + + it('does not infer consent from an unreadable boolean or a missing Git binary', async () => { + for (const config of [ + () => Promise.resolve({ stdout: 'unexpected', stderr: '' }), + () => Promise.reject(Object.assign(new Error('missing Git'), { code: 'ENOENT' })) + ]) { + gitExecFileAsyncMock.mockImplementationOnce(config) + const options = args() + await maintainRepoPackIndex(options) + expect(options.attributes['git.pack_index_outcome']).toBe('failed') + } + expect(opendirMock).not.toHaveBeenCalled() + }) + + it('records index failures without preventing later ref maintenance', async () => { + gitExecFileAsyncMock + .mockRejectedValueOnce(Object.assign(new Error('unset'), { code: 1 })) + .mockRejectedValueOnce(new Error('index locked')) + const options = args() + await expect(maintainRepoPackIndex(options)).resolves.toBe('failed') + expect(options.attributes['git.pack_index_outcome']).toBe('failed') + }) + + it('skips repositories without pack files and cancelled attempts', async () => { + statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + const options = args() + await maintainRepoPackIndex(options) + expect(options.attributes['git.pack_index_outcome']).toBe('below_threshold') + const abort = new AbortController() + abort.abort() + gitExecFileAsyncMock.mockClear() + await maintainRepoPackIndex({ ...args(), signal: abort.signal }) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('walks the WSL share and runs Git on that execution host', async () => { + const options = args('Ubuntu') + await maintainRepoPackIndex(options) + expect(opendirMock).toHaveBeenCalledWith( + String.raw`\\wsl.localhost\Ubuntu\repo\.git\objects\pack` + ) + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['multi-pack-index', 'write'], + expect.objectContaining({ cwd: '//wsl$/Ubuntu/repo', wslDistro: 'Ubuntu' }) + ) + }) + + it('skips unchanged directory stamps but refreshes changed packs and periodically rechecks', async () => { + const options = args() + await maintainRepoPackIndex(options) + await expect(maintainRepoPackIndex(options)).resolves.toBe('unchanged') + expect(opendirMock).toHaveBeenCalledOnce() + statMock.mockResolvedValue({ dev: 1n, ino: 2n, mtimeNs: 5n, ctimeNs: 6n }) + await expect(maintainRepoPackIndex(options)).resolves.toBe('written') + const now = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(now + PACK_INDEX_FORCE_REFRESH_MS + 1) + await expect(maintainRepoPackIndex(options)).resolves.toBe('written') + vi.restoreAllMocks() + }) + + it('isolates directory stamps by the execution host', async () => { + const options = args() + await maintainRepoPackIndex(options) + await expect(maintainRepoPackIndex({ ...options, wslDistro: 'Ubuntu' })).resolves.toBe( + 'written' + ) + }) + + it('protects metadata discovered after the pack threshold during the final probe', async () => { + opendirMock.mockResolvedValueOnce({ + async *[Symbol.asyncIterator]() { + yield* directory(PACK_INDEX_THRESHOLD) + yield { name: 'multi-pack-index-old.bitmap', isFile: () => true } + } + }) + const options = args() + await expect(maintainRepoPackIndex(options)).resolves.toBe('protected') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('does not start the writer when the final probe is cancelled', async () => { + const controller = new AbortController() + opendirMock.mockResolvedValueOnce({ + async *[Symbol.asyncIterator]() { + yield* directory(PACK_INDEX_THRESHOLD) + controller.abort() + } + }) + const options = { ...args(), signal: controller.signal } + await expect(maintainRepoPackIndex(options)).resolves.toBe('deferred') + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(options.attributes['git.pack_index_pack_count_floor']).toBe(PACK_INDEX_THRESHOLD) + }) +}) diff --git a/src/main/git/repo-pack-index-maintenance.ts b/src/main/git/repo-pack-index-maintenance.ts new file mode 100644 index 00000000000..2177f4d2166 --- /dev/null +++ b/src/main/git/repo-pack-index-maintenance.ts @@ -0,0 +1,122 @@ +import { posix, win32 } from 'node:path' +import { BoundedMap } from '../../shared/bounded-map' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import type { PackIndexMaintenanceOutcome } from '../../shared/repo-pack-index-maintenance-policy' +import type { RefMaintenanceSpan } from '../../shared/repo-ref-maintenance-policy' +import { probeRepoPackIndexDirectory, readRepoPackDirectoryStamp } from './repo-pack-index-state' +import { gitExecFileAsync } from './runner' + +// Git's default auto-GC pack limit is 50; defer indexing until fragmentation is clear. +export const PACK_INDEX_THRESHOLD = 64 +export const PACK_INDEX_TIMEOUT_MS = 60_000 +export const PACK_INDEX_FORCE_REFRESH_MS = 6 * 60 * 60_000 + +const indexedDirectories = new BoundedMap({ + maxEntries: 64 +}) + +export function clearRepoPackIndexMaintenanceCache(): void { + indexedDirectories.clear() +} + +export function isUnsetGitConfigError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 1 +} + +type PackIndexMaintenanceArgs = { + repoPath: string + /** Common directory in the spelling the main process can open. */ + commonDir: string + wslDistro?: string + signal: AbortSignal + span: RefMaintenanceSpan + canWrite: () => boolean +} + +export async function maintainRepoPackIndex( + args: PackIndexMaintenanceArgs +): Promise { + const { signal, span } = args + const outcome = (value: PackIndexMaintenanceOutcome): PackIndexMaintenanceOutcome => { + span.setAttribute('git.pack_index_outcome', value) + return value + } + if (signal.aborted) { + return outcome('deferred') + } + const gitOptions = { + cwd: args.repoPath, + ...(args.wslDistro ? { wslDistro: args.wslDistro } : {}), + admissionTier: 'background' as const, + signal + } + try { + try { + const { stdout } = await gitExecFileAsync( + ['config', '--bool', '--get', 'core.multiPackIndex'], + gitOptions + ) + const configured = stdout.trim() + if (configured === 'false') { + return outcome('opted_out') + } + if (configured !== 'true') { + throw new Error('Unrecognized core.multiPackIndex config') + } + } catch (error) { + if (!isUnsetGitConfigError(error)) { + throw error + } + } + if (signal.aborted) { + return outcome('deferred') + } + const paths = isWindowsAbsolutePathLike(args.commonDir) ? win32 : posix + const directory = paths.join(args.commonDir, 'objects', 'pack') + const stamp = await readRepoPackDirectoryStamp(directory) + if (!stamp) { + return outcome('below_threshold') + } + const key = `${args.wslDistro ? `wsl:${args.wslDistro}` : 'local'}::${args.commonDir}` + const previous = indexedDirectories.get(key) + if ( + previous?.stamp === stamp && + Date.now() - previous.writtenAt < PACK_INDEX_FORCE_REFRESH_MS + ) { + return outcome('unchanged') + } + if (signal.aborted || !args.canWrite()) { + return outcome('deferred') + } + const probe = await probeRepoPackIndexDirectory(directory, PACK_INDEX_THRESHOLD, signal) + span.setAttribute('git.pack_index_pack_count_floor', probe.packCountFloor) + if (signal.aborted || !args.canWrite()) { + return outcome('deferred') + } + if (probe.protected) { + return outcome('protected') + } + if (probe.packCountFloor < PACK_INDEX_THRESHOLD) { + return outcome('below_threshold') + } + const startedAt = Date.now() + // Git 2.20+: writes lookup metadata atomically without rewriting or deleting packs. + // Let the writer finish: force-killing it on Windows can strand its index lock. + await gitExecFileAsync(['multi-pack-index', 'write'], { + cwd: args.repoPath, + ...(args.wslDistro ? { wslDistro: args.wslDistro } : {}), + admissionTier: 'background', + timeout: PACK_INDEX_TIMEOUT_MS + }) + const writtenStamp = await readRepoPackDirectoryStamp(directory).catch(() => undefined) + // A racing new pack remains readable; the forced refresh bounds a missed directory change. + if (writtenStamp) { + indexedDirectories.set(key, { stamp: writtenStamp, writtenAt: Date.now() }) + } + span.setAttribute('git.pack_index_write_ms', Date.now() - startedAt) + return outcome('written') + } catch (error) { + span.setAttribute('git.pack_index_error', String(error)) + return outcome('failed') + } +} diff --git a/src/main/git/repo-pack-index-state.test.ts b/src/main/git/repo-pack-index-state.test.ts new file mode 100644 index 00000000000..86689877351 --- /dev/null +++ b/src/main/git/repo-pack-index-state.test.ts @@ -0,0 +1,63 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { probeRepoPackIndexDirectory } from './repo-pack-index-state' + +const roots: string[] = [] +async function directory(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-pack-index-state-')) + roots.push(root) + return root +} + +function indexHeader(version = 1, hash = 1): Buffer { + const value = Buffer.alloc(1200) + value.write('MIDX') + value[4] = version + value[5] = hash + value[6] = 4 + return value +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('pack-index metadata protection', () => { + it('protects every retained bitmap regardless of its checksum', async () => { + const packs = await directory() + await writeFile(join(packs, 'multi-pack-index'), indexHeader()) + await writeFile(join(packs, 'multi-pack-index-old.bitmap'), 'retained bitmap') + await expect( + probeRepoPackIndexDirectory(packs, 64, new AbortController().signal) + ).resolves.toMatchObject({ protected: true }) + }) + + it('protects an incremental chain without reading its layers', async () => { + const packs = await directory() + await mkdir(join(packs, 'multi-pack-index.d')) + await expect( + probeRepoPackIndexDirectory(packs, 64, new AbortController().signal) + ).resolves.toMatchObject({ protected: true }) + }) + + it.each([indexHeader(2), indexHeader(1, 3), Buffer.from('MIDX'), Buffer.alloc(1200)])( + 'fails closed on an unknown or truncated MIDX', + async (header) => { + const packs = await directory() + await writeFile(join(packs, 'multi-pack-index'), header) + await expect( + probeRepoPackIndexDirectory(packs, 64, new AbortController().signal) + ).resolves.toMatchObject({ protected: true }) + } + ) + + it.each([1, 2])('accepts the standalone v1 SHA hash format %s', async (hash) => { + const packs = await directory() + await writeFile(join(packs, 'multi-pack-index'), indexHeader(1, hash)) + await expect( + probeRepoPackIndexDirectory(packs, 64, new AbortController().signal) + ).resolves.toEqual({ protected: false, packCountFloor: 0 }) + }) +}) diff --git a/src/main/git/repo-pack-index-state.ts b/src/main/git/repo-pack-index-state.ts new file mode 100644 index 00000000000..92ba425d8fd --- /dev/null +++ b/src/main/git/repo-pack-index-state.ts @@ -0,0 +1,94 @@ +import { open, opendir, stat } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +export const PACK_INDEX_PROBE_ENTRY_LIMIT = 65_536 +export const PACK_INDEX_PROBE_TIMEOUT_MS = 1_000 + +export function isMissingPackIndexPath(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' +} + +export async function readRepoPackDirectoryStamp(directory: string): Promise { + try { + const value = await stat(directory, { bigint: true }) + return `${value.dev}:${value.ino}:${value.mtimeNs}:${value.ctimeNs}` + } catch (error) { + if (isMissingPackIndexPath(error)) { + return undefined + } + throw error + } +} + +async function hasUnsupportedPackIndex(directory: string): Promise { + const paths = isWindowsAbsolutePathLike(directory) ? win32 : posix + const handle = await open(paths.join(directory, 'multi-pack-index'), 'r').catch( + (error: unknown) => { + if (isMissingPackIndexPath(error)) { + return undefined + } + throw error + } + ) + if (!handle) { + return false + } + try { + const header = Buffer.alloc(12) + const { bytesRead } = await handle.read(header, 0, header.length, 0) + const hashBytes = header[5] === 1 ? 20 : header[5] === 2 ? 32 : 0 + const chunks = header[6] ?? 0 + const size = (await handle.stat()).size + if ( + bytesRead !== header.length || + header.subarray(0, 4).toString() !== 'MIDX' || + header[4] !== 1 || + !hashBytes || + chunks < 4 || + header[7] !== 0 || + size < 12 + (chunks + 1) * 12 + 1024 + hashBytes + ) { + return true + } + const trailer = Buffer.alloc(hashBytes) + return (await handle.read(trailer, 0, hashBytes, size - hashBytes)).bytesRead !== hashBytes + } finally { + await handle.close() + } +} + +/** A capped name walk protects every retained bitmap, including an older index's bitmap. */ +export async function probeRepoPackIndexDirectory( + directory: string, + packThreshold: number, + signal: AbortSignal +): Promise<{ packCountFloor: number; protected: boolean }> { + const entries = await opendir(directory) + const startedAt = Date.now() + let visited = 0 + let packCountFloor = 0 + for await (const entry of entries) { + if (signal.aborted) { + return { packCountFloor, protected: true } + } + if ( + entry.name === 'multi-pack-index.d' || + (entry.name.startsWith('multi-pack-index') && entry.name.endsWith('.bitmap')) || + (entry.name === 'multi-pack-index' && !entry.isFile()) + ) { + return { packCountFloor, protected: true } + } + if (entry.name.endsWith('.pack') && (entry.isFile() || entry.isSymbolicLink())) { + packCountFloor = Math.min(packThreshold, packCountFloor + 1) + } + visited += 1 + if ( + visited >= PACK_INDEX_PROBE_ENTRY_LIMIT || + Date.now() - startedAt >= PACK_INDEX_PROBE_TIMEOUT_MS + ) { + return { packCountFloor, protected: true } + } + } + return { packCountFloor, protected: await hasUnsupportedPackIndex(directory) } +} diff --git a/src/main/git/repo-ref-maintenance-real-git.test.ts b/src/main/git/repo-ref-maintenance-real-git.test.ts index 30c67b0365a..7c3041e6dfe 100644 --- a/src/main/git/repo-ref-maintenance-real-git.test.ts +++ b/src/main/git/repo-ref-maintenance-real-git.test.ts @@ -1,10 +1,11 @@ import { execFileSync } from 'node:child_process' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { countLooseRefs } from '../../shared/loose-ref-count' import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' +import { PACK_INDEX_MAINTENANCE_COOLDOWN_MS } from '../../shared/repo-pack-index-maintenance-policy' import { _resetLocalRepoRefMaintenanceForTests, createLocalRepoRefMaintenanceTarget, @@ -12,6 +13,7 @@ import { setRepoMaintenanceActivityProbe } from './local-repo-ref-maintenance' import { forceDeleteLocalBranch } from './worktree-branch-removal' +import { maintainRepoPackIndex, PACK_INDEX_THRESHOLD } from './repo-pack-index-maintenance' const roots: string[] = [] // Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts @@ -19,14 +21,51 @@ const roots: string[] = [] const QUIET_MS = 25 const THRESHOLD = 20 -function git(cwd: string, args: string[]): string { +function git(cwd: string, args: string[], input?: string): string { return execFileSync('git', args, { cwd, encoding: 'utf8', + input, stdio: ['pipe', 'pipe', 'pipe'] }).trim() } +function hasWriteOption(option: string): boolean { + try { + git(process.cwd(), ['multi-pack-index', 'write', '-h']) + } catch (error) { + if (typeof error === 'object' && error !== null) { + return ( + ('stderr' in error && String(error.stderr).includes(option)) || + ('stdout' in error && String(error.stdout).includes(option)) + ) + } + } + return false +} + +async function createFragmentedPacks(repoPath: string): Promise { + const objects = join(repoPath, '.git', 'objects') + const blobs: string[] = [] + for (let index = 0; index < PACK_INDEX_THRESHOLD; index += 1) { + const blob = git(repoPath, ['hash-object', '-w', '--stdin'], `packed-${index}\n`) + blobs.push(blob) + git(repoPath, ['pack-objects', join(objects, 'pack', 'pack')], `${blob}\n`) + await rm(join(objects, blob.slice(0, 2), blob.slice(2))) + } + return blobs +} + +function maintainIndex(repoPath: string) { + return maintainRepoPackIndex({ + repoPath, + commonDir: join(repoPath, '.git'), + signal: new AbortController().signal, + span: { setAttribute: () => {} }, + canWrite: () => true + }) +} + /** A repo whose only loose-ref backlog is the one the test asks for. */ async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> { const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-')) @@ -49,13 +88,17 @@ async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDi return { repoPath, refsDir: join(repoPath, '.git', 'refs') } } -function createMaintenance(onPackRefs: () => void = () => {}): { +function createMaintenance( + onPackRefs: () => void = () => {}, + now?: () => number +): { maintenance: RepoRefMaintenance arm: (repoPath: string) => void } { const maintenance = new RepoRefMaintenance({ quietPeriodMs: QUIET_MS, - looseRefThreshold: THRESHOLD + looseRefThreshold: THRESHOLD, + ...(now ? { now } : {}) }) return { maintenance, @@ -99,6 +142,117 @@ afterEach(async () => { }) describe('idle ref maintenance against real Git', () => { + it('indexes fragmented packs without rewriting objects or requiring loose-ref debt', async () => { + const { repoPath } = await createRepo(0) + git(repoPath, ['config', 'maintenance.auto', 'true']) + git(repoPath, ['config', 'gc.auto', '6700']) + const objects = join(repoPath, '.git', 'objects') + const packs = join(objects, 'pack') + const blobs = await createFragmentedPacks(repoPath) + const originalPacks = (await readdir(packs)).sort() + const lock = join(packs, 'multi-pack-index.lock') + await writeFile(lock, 'another writer') + const blocked = createMaintenance() + blocked.arm(repoPath) + await settle(blocked.maintenance) + blocked.maintenance.dispose() + await expect(readFile(lock, 'utf8')).resolves.toBe('another writer') + await expect(readFile(join(packs, 'multi-pack-index'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + await rm(lock) + const { maintenance, arm } = createMaintenance() + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + const index = await readFile(join(packs, 'multi-pack-index')) + expect(index.subarray(0, 4).toString()).toBe('MIDX') + expect((await readdir(packs)).filter((name) => name !== 'multi-pack-index').sort()).toEqual( + originalPacks + ) + expect(git(repoPath, ['multi-pack-index', 'verify'])).toBe('') + expect(git(repoPath, ['-c', 'core.multiPackIndex=true', 'cat-file', '-p', blobs[0]])).toBe( + 'packed-0' + ) + expect( + git(repoPath, [ + '-c', + 'core.multiPackIndex=true', + 'cat-file', + '-p', + blobs[PACK_INDEX_THRESHOLD - 1] + ]) + ).toBe(`packed-${PACK_INDEX_THRESHOLD - 1}`) + await expect(readFile(join(repoPath, '.git', 'packed-refs'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('refreshes new packs during ref cooldown and keeps readers working between writes', async () => { + const { repoPath } = await createRepo(0) + const blobs = await createFragmentedPacks(repoPath) + const packs = join(repoPath, '.git', 'objects', 'pack') + let clock = 0 + const { maintenance, arm } = createMaintenance( + () => {}, + () => clock + ) + arm(repoPath) + await settle(maintenance) + expect((await readFile(join(packs, 'multi-pack-index'))).readUInt32BE(8)).toBe( + PACK_INDEX_THRESHOLD + ) + const added = git(repoPath, ['hash-object', '-w', '--stdin'], 'new fetched object\n') + git(repoPath, ['pack-objects', join(packs, 'pack')], `${added}\n`) + await rm(join(repoPath, '.git', 'objects', added.slice(0, 2), added.slice(2))) + expect(git(repoPath, ['cat-file', '-p', added])).toBe('new fetched object') + expect(git(repoPath, ['cat-file', '-p', blobs[0]])).toBe('packed-0') + clock = PACK_INDEX_MAINTENANCE_COOLDOWN_MS + 1 + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + expect((await readFile(join(packs, 'multi-pack-index'))).readUInt32BE(8)).toBe( + PACK_INDEX_THRESHOLD + 1 + ) + expect(git(repoPath, ['multi-pack-index', 'verify'])).toBe('') + await expect(readFile(join(repoPath, '.git', 'packed-refs'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it.skipIf(!hasWriteOption('bitmap'))( + 'preserves a real MIDX bitmap and its index byte for byte', + async () => { + const { repoPath } = await createRepo(0) + await createFragmentedPacks(repoPath) + const packs = join(repoPath, '.git', 'objects', 'pack') + git(repoPath, ['pack-objects', '--revs', join(packs, 'pack')], 'HEAD\n') + git(repoPath, ['multi-pack-index', 'write', '--bitmap']) + const metadata = (await readdir(packs)).filter((name) => name.startsWith('multi-pack-index')) + expect(metadata.some((name) => name.endsWith('.bitmap'))).toBe(true) + const before = await Promise.all(metadata.map((name) => readFile(join(packs, name)))) + await expect(maintainIndex(repoPath)).resolves.toBe('protected') + const after = await Promise.all(metadata.map((name) => readFile(join(packs, name)))) + expect(after).toEqual(before) + } + ) + + it.skipIf(!hasWriteOption('incremental'))( + 'preserves a real incremental MIDX chain and its layers', + async () => { + const { repoPath } = await createRepo(0) + await createFragmentedPacks(repoPath) + const chain = join(repoPath, '.git', 'objects', 'pack', 'multi-pack-index.d') + git(repoPath, ['multi-pack-index', 'write', '--incremental']) + const metadata = (await readdir(chain)).sort() + expect(metadata).toContain('multi-pack-index-chain') + const before = await Promise.all(metadata.map((name) => readFile(join(chain, name)))) + await expect(maintainIndex(repoPath)).resolves.toBe('protected') + expect((await readdir(chain)).sort()).toEqual(metadata) + expect(await Promise.all(metadata.map((name) => readFile(join(chain, name))))).toEqual(before) + } + ) + it('packs a backlogged repository down to zero loose refs', async () => { const { repoPath, refsDir } = await createRepo(THRESHOLD + 30) const { maintenance, arm } = createMaintenance() diff --git a/src/main/git/worktree-create-admission-tier.test.ts b/src/main/git/worktree-create-admission-tier.test.ts index f84311f14f6..84bcce00237 100644 --- a/src/main/git/worktree-create-admission-tier.test.ts +++ b/src/main/git/worktree-create-admission-tier.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WorktreePreparationLock from './worktree-preparation-lock' type GitExec = ( args: string[], @@ -8,6 +9,12 @@ type GitExec = ( const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('./worktree-preparation-lock', async (importOriginal) => ({ + ...(await importOriginal()), + verifyWorktreePreparationLock: vi.fn(async () => '/owned-lock'), + verifyWorktreePreparationLockAtPath: vi.fn(), + unlockWorktreePreparationAtPath: vi.fn() +})) import { addWorktree } from './worktree-add' import { listWorktreesSharedStrict } from './worktree-scan-cache' @@ -51,11 +58,18 @@ describe('worktree create admission tier', () => { }) it('runs the prepared-checkout finalize at the tier the caller asked for', async () => { - await finalizePreparedWorktree('/repo', '/prepared', '/repo-wt', 'feature', 'main', false, { - admissionTier: 'interactive' - }) + await finalizePreparedWorktree( + '/repo', + '/prepared', + '/repo-wt', + 'feature', + 'main', + false, + { admissionTier: 'interactive' }, + 'owner' + ) - for (const match of ['worktree move', 'checkout --no-track', 'worktree unlock']) { + for (const match of ['worktree move', 'checkout --no-track']) { const options = optionsForCommand(match) expect(options, match).toHaveLength(1) expect(options[0], match).toMatchObject({ admissionTier: 'interactive' }) diff --git a/src/main/git/worktree-create-git-executor-real-git.test.ts b/src/main/git/worktree-create-git-executor-real-git.test.ts index 4aa5322e7eb..02989fc995e 100644 --- a/src/main/git/worktree-create-git-executor-real-git.test.ts +++ b/src/main/git/worktree-create-git-executor-real-git.test.ts @@ -81,7 +81,10 @@ it('creates cold and prepared worktrees with real Git while status capacity is o prepared.preparedPath, join(root, 'warm'), 'warm', - 'main' + 'main', + false, + {}, + prepared.lockReason ) expect(await listWorktrees(repo)).toHaveLength(3) }) diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts index 08795fcab1f..7233cc681fc 100644 --- a/src/main/git/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -55,6 +55,241 @@ afterEach(async () => { }) describe('prepared worktree creation with real Git', () => { + it('registers during fetch and materializes its settled tip only once before the final hook', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'prepared-barrier') + const finalPath = join(root, 'final-barrier') + const base = 'refs/remotes/origin/main' + const reason = createWorktreePreparationLockReason('barrier-tip') + const originalHead = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['update-ref', base, originalHead]) + const hooksPath = join(root, 'hooks') + await mkdir(hooksPath) + await writeFile( + join(hooksPath, 'post-checkout'), + '#!/bin/sh\nprintf \'%s\\n\' "$@" >> checkout-hook.txt\n', + { mode: 0o755 } + ) + git(repoPath, ['config', 'core.hooksPath', hooksPath]) + let release!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync') + const preparing = prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + base, + reason, + {}, + barrier + ) + await vi.waitFor(() => expect(existsSync(join(preparedPath, '.git'))).toBe(true)) + await expect(readFile(join(preparedPath, 'version.txt'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + await writeFile(join(repoPath, 'version.txt'), 'fetched\n') + git(repoPath, ['commit', '--quiet', '-am', 'fetched tip']) + const fetchedHead = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['update-ref', base, fetchedHead]) + release() + try { + await preparing + expect(git(preparedPath, ['rev-parse', 'HEAD'])).toBe(fetchedHead) + expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('fetched\n') + expect(existsSync(join(preparedPath, 'checkout-hook.txt'))).toBe(false) + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/barrier', + base, + false, + {}, + reason + ) + const resets = spy.mock.calls.filter(([args]) => args.includes('reset')) + expect(resets).toHaveLength(1) + expect(resets[0]?.[0].at(-1)).toBe(fetchedHead) + expect(await readFile(join(finalPath, 'checkout-hook.txt'), 'utf8')).toBe( + `${fetchedHead}\n${fetchedHead}\n1\n` + ) + expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/barrier') + await rm(join(finalPath, 'checkout-hook.txt')) + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + } finally { + release() + spy.mockRestore() + } + }) + + it('cancels a never-settling fetch barrier and cleans the registered checkout before it resolves', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'canceled-barrier') + const reason = createWorktreePreparationLockReason('barrier-cancel') + const controller = new AbortController() + let release!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync') + const preparing = prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + reason, + { signal: controller.signal }, + barrier + ) + const assertion = expect(preparing).rejects.toThrow('expired while fetching') + try { + await vi.waitFor(() => expect(existsSync(join(preparedPath, '.git'))).toBe(true)) + const lock = git(preparedPath, ['rev-parse', '--git-path', 'locked']) + await vi.waitFor(async () => expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`)) + controller.abort(new Error('expired while fetching')) + await assertion + expect(existsSync(preparedPath)).toBe(false) + expect(git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain(preparedPath) + release() + await Promise.resolve() + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + } finally { + release() + spy.mockRestore() + } + }) + + it('preserves a replacement owner after the fetch barrier before probing or materializing', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'replaced-barrier') + const reason = createWorktreePreparationLockReason('barrier-replacement') + let release!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + const preparing = prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + reason, + {}, + barrier + ) + const assertion = expect(preparing).rejects.toThrow('lock owner changed') + await vi.waitFor(() => expect(existsSync(join(preparedPath, '.git'))).toBe(true)) + const lock = git(preparedPath, ['rev-parse', '--git-path', 'locked']) + await vi.waitFor(async () => expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`)) + await writeFile(lock, 'manual barrier owner\n') + await writeFile(join(preparedPath, 'version.txt'), 'manual content\n') + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync') + try { + release() + await assertion + expect(spy).not.toHaveBeenCalled() + expect(await readFile(lock, 'utf8')).toBe('manual barrier owner\n') + expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('manual content\n') + } finally { + release() + spy.mockRestore() + } + }) + + it('reports an unrelated barrier failure and removes only its owned registration', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'failed-barrier') + const failure = new Error('unexpected barrier failure') + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync') + try { + await expect( + prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('barrier-failure'), + {}, + Promise.reject(failure) + ) + ).rejects.toBe(failure) + expect(existsSync(preparedPath)).toBe(false) + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + expect(git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain(preparedPath) + } finally { + spy.mockRestore() + } + }) + + it('pins first materialization to the resolved fetched OID when the ref moves before reset', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'moving-barrier-tip') + const originalHead = git(repoPath, ['rev-parse', 'HEAD']) + await writeFile(join(repoPath, 'version.txt'), 'newer\n') + git(repoPath, ['commit', '--quiet', '-am', 'later tip']) + const newerHead = git(repoPath, ['rev-parse', 'HEAD']) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', originalHead]) + const run = gitRunner.gitExecFileAsync + const spy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation((args, options) => { + if (args.includes('reset')) { + git(repoPath, ['update-ref', 'refs/remotes/origin/main', newerHead]) + } + return run(args, options) + }) + try { + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'refs/remotes/origin/main', + createWorktreePreparationLockReason('barrier-oid-race'), + {}, + Promise.resolve() + ) + expect(git(preparedPath, ['rev-parse', 'HEAD'])).toBe(originalHead) + expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('one\n') + expect(spy.mock.calls.find(([args]) => args.includes('reset'))?.[0].at(-1)).toBe(originalHead) + } finally { + spy.mockRestore() + } + }) + + it('rechecks the barrier marker after reading the fresh commit before the first reset', async () => { + const { repoPath, root } = await createRepo() + const preparedPath = join(root, 'replaced-after-tip-read') + const reason = createWorktreePreparationLockReason('barrier-tip-owner') + const run = gitRunner.gitExecFileAsync + let lock = '' + const spy = vi + .spyOn(gitRunner, 'gitExecFileAsync') + .mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args[0] === 'rev-parse' && args.includes('refs/heads/main^{commit}')) { + lock = git(preparedPath, ['rev-parse', '--git-path', 'locked']) + await writeFile(lock, 'manual after tip read\n') + } + return result + }) + try { + await expect( + prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'refs/heads/main', + reason, + {}, + Promise.resolve() + ) + ).rejects.toThrow('lock owner changed') + expect( + spy.mock.calls.some(([args]) => args.includes('reset') || args.includes('remove')) + ).toBe(false) + expect(await readFile(lock, 'utf8')).toBe('manual after tip read\n') + await expect(readFile(join(preparedPath, 'version.txt'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + } finally { + spy.mockRestore() + } + }) + it.each([false, true])( 'attaches the prepared HEAD and runs the hook (base advanced: %s)', async (advanceBase) => { @@ -70,19 +305,24 @@ describe('prepared worktree creation with real Git', () => { ) git(repoPath, ['config', 'core.hooksPath', hooksPath]) git(repoPath, ['config', 'branch.autoSetupMerge', 'always']) - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('attach-with-hook') - ) + const lockReason = createWorktreePreparationLockReason('attach-with-hook') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) expect(existsSync(join(preparedPath, 'checkout-hook.txt'))).toBe(false) if (advanceBase) { await writeFile(join(repoPath, 'version.txt'), 'advanced\n') git(repoPath, ['commit', '--quiet', '-am', 'advance base']) } const targetHead = git(repoPath, ['rev-parse', 'HEAD']) - await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/attached', 'main') + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/attached', + 'main', + false, + {}, + lockReason + ) expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(targetHead) expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/attached') @@ -104,12 +344,8 @@ describe('prepared worktree creation with real Git', () => { const { repoPath, root } = await createRepo() const preparedPath = join(root, 'prepared-race') const finalPath = join(root, 'final-race') - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('head-race') - ) + const lockReason = createWorktreePreparationLockReason('head-race') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) const expectedHead = git(repoPath, ['rev-parse', 'HEAD']) git(repoPath, ['checkout', '--quiet', '-b', 'other']) await writeFile(join(repoPath, 'version.txt'), 'other\n') @@ -125,7 +361,16 @@ describe('prepared worktree creation with real Git', () => { return original(args, options) }) try { - await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/race', 'main') + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/race', + 'main', + false, + {}, + lockReason + ) } finally { spy.mockRestore() } @@ -147,15 +392,20 @@ describe('prepared worktree creation with real Git', () => { { mode: 0o755 } ) git(repoPath, ['config', 'core.hooksPath', hooksPath]) - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('hook-commit') - ) + const lockReason = createWorktreePreparationLockReason('hook-commit') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) const baseHead = git(repoPath, ['rev-parse', 'HEAD']) - await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-commit', 'main') + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/hook-commit', + 'main', + false, + {}, + lockReason + ) expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/hook-commit') expect(git(finalPath, ['rev-parse', 'HEAD^'])).toBe(baseHead) @@ -172,15 +422,20 @@ describe('prepared worktree creation with real Git', () => { await mkdir(hooksPath) await writeFile(join(hooksPath, 'post-checkout'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }) git(repoPath, ['config', 'core.hooksPath', hooksPath]) - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('hook-failure') - ) + const lockReason = createWorktreePreparationLockReason('hook-failure') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) await expect( - finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-failure', 'main') + finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/hook-failure', + 'main', + false, + {}, + lockReason + ) ).rejects.toThrow() expect(existsSync(finalPath)).toBe(false) expect(git(repoPath, ['branch', '--list', 'feature/hook-failure'])).toBe('') @@ -202,7 +457,7 @@ describe('prepared worktree creation with real Git', () => { return original(args, options) }) try { - await expect(discardPreparedWorktree(repoPath, preparedPath)).rejects.toThrow( + await expect(discardPreparedWorktree(repoPath, preparedPath, {}, lockReason)).rejects.toThrow( 'injected removal launch failure' ) const remaining = await listWorktrees(repoPath, { includeCreatePreparations: true }) @@ -214,7 +469,7 @@ describe('prepared worktree creation with real Git', () => { expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('one\n') } finally { spy.mockRestore() - await discardPreparedWorktree(repoPath, preparedPath) + await discardPreparedWorktree(repoPath, preparedPath, {}, lockReason) } expect(existsSync(preparedPath)).toBe(false) }) @@ -265,7 +520,16 @@ describe('prepared worktree creation with real Git', () => { expect(entry).toBeDefined() takePreparation(entry) const finalPath = join(root, 'fresh-worktree') - await finalizePreparedWorktree(repoPath, entry.preparedPath, finalPath, 'fresh', 'main') + await finalizePreparedWorktree( + repoPath, + entry.preparedPath, + finalPath, + 'fresh', + 'main', + false, + {}, + entry.lockReason + ) expect(git(finalPath, ['status', '--porcelain'])).toBe('') expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('fresh') expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('one\n') @@ -346,17 +610,13 @@ describe('prepared worktree creation with real Git', () => { const preparedPath = join(preparationRoot, `${process.pid}-canceled`) await mkdir(preparationRoot, { recursive: true }) - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('canceled-test') - ) + const lockReason = createWorktreePreparationLockReason('canceled-test') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason) const controller = new AbortController() controller.abort() await expect( - discardPreparedWorktree(repoPath, preparedPath, { signal: controller.signal }) + discardPreparedWorktree(repoPath, preparedPath, { signal: controller.signal }, lockReason) ).resolves.toBeUndefined() expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1) @@ -393,7 +653,16 @@ describe('prepared worktree creation with real Git', () => { ) expect(git(preparedPath, ['rev-parse', 'HEAD'])).not.toBe(localMainHead) - await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/retargeted', 'main') + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/retargeted', + 'main', + false, + {}, + createWorktreePreparationLockReason('retarget-test') + ) expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead) // A retarget that left stale files behind would be a wrong checkout, not just a slow one. @@ -452,7 +721,10 @@ describe('prepared worktree creation with real Git', () => { preparedPath, finalPath, 'feature/overlap', - 'refs/remotes/origin/main' + 'refs/remotes/origin/main', + false, + {}, + createWorktreePreparationLockReason('fetch-overlap') ) expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(refreshed) expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('refreshed\n') @@ -497,7 +769,9 @@ describe('prepared worktree creation with real Git', () => { finalPath, 'feature/prepared', 'main', - false + false, + {}, + createWorktreePreparationLockReason('real-git-test') ) expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(latestHead) diff --git a/src/main/git/worktree-create-preparation-real-wsl.test.ts b/src/main/git/worktree-create-preparation-real-wsl.test.ts index 8ccaf3c23bf..50f46342502 100644 --- a/src/main/git/worktree-create-preparation-real-wsl.test.ts +++ b/src/main/git/worktree-create-preparation-real-wsl.test.ts @@ -4,10 +4,10 @@ import { expect, it } from 'vitest' import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' import { gitExecFileAsync } from './runner' import { - discardPreparedWorktree, finalizePreparedWorktree, prepareWorktreeCreateCheckout } from './worktree-create-preparation' +import { removeWorktree } from './worktree-removal' // Opt in on Windows with a running distro; all Git commands use the production WSL router. const wslDistro = process.env.ORCA_TEST_WSL_DISTRO @@ -34,13 +34,8 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)( await writeFile(join(repoPath, 'version.txt'), 'one\n') await git(repoPath, ['add', 'version.txt']) await git(repoPath, ['commit', '--quiet', '-m', 'initial']) - await prepareWorktreeCreateCheckout( - repoPath, - preparedPath, - 'main', - createWorktreePreparationLockReason('real-wsl-test'), - options - ) + const lockReason = createWorktreePreparationLockReason('real-wsl-test') + await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason, options) expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).toContain( 'locked orca-create-preparation:v1:' ) @@ -55,7 +50,8 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)( 'feature/routed', 'main', false, - options + options, + lockReason ) expect(await git(finalPath, ['rev-parse', 'HEAD'])).toBe(target) expect(await git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/routed') @@ -65,7 +61,7 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)( 'refs/heads/main' ) expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain('locked ') - await discardPreparedWorktree(repoPath, finalPath, options) + await removeWorktree(repoPath, finalPath, true, options) expect( (await git(repoPath, ['worktree', 'list', '--porcelain'])).match(/^worktree /gm) ).toHaveLength(1) diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index 5a4f65314af..cb51e1ca8ae 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -1,7 +1,8 @@ import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' import type { AddWorktreeOptions, AddWorktreeResult, GitWorktreeExecOptions } from './worktree' -import { gitExecOptions, type GitExecOptionsForWorktree } from './worktree-operation-options' +import { gitExecOptions } from './worktree-operation-options' import { configurePushAutoSetupRemote, notifyPreparedWorktreeMutation, @@ -10,93 +11,77 @@ import { resolveWorktreeAddTimeoutMs, WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' +import { + gitCleanupOptions, + performDiscardPreparedWorktree, + removeFailedFinalization +} from './worktree-preparation-discard' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' - -function gitCleanupOptions( - cwd: string, - options: GitWorktreeExecOptions -): GitExecOptionsForWorktree { - // Why: cancellation must not strand a partially moved worktree; cleanup is bounded separately. - return gitExecOptions(cwd, { ...options, signal: undefined }) -} - -async function performDiscardPreparedWorktree( - repoPath: string, - worktreePath: string, - options: GitWorktreeExecOptions -): Promise { - const cleanupGitOptions = { - ...gitCleanupOptions(repoPath, options), - timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS - } - try { - // Preserve the ownership lock if removal cannot start; Git 2.25 supports locked removal. - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'remove', - '--force', - '--force', - worktreePath - ], - cleanupGitOptions - ) - } finally { - invalidateWslLinkedWorktreeGitRouting(worktreePath) - } -} +import { + unlockWorktreePreparation, + unlockWorktreePreparationAtPath, + verifyWorktreePreparationLock, + verifyWorktreePreparationLockAtPath, + WorktreePreparationLockOwnershipError +} from './worktree-preparation-lock' +import { addLockedWorktreePreparation } from './worktree-preparation-add' export async function prepareWorktreeCreateCheckout( repoPath: string, worktreePath: string, baseBranch: string, lockReason: string, - options: GitWorktreeExecOptions = {} + options: GitWorktreeExecOptions = {}, + beforeMaterialization?: Promise ): Promise { + // Observe early rejection while registration runs; awaiting still reports the original error. + void beforeMaterialization?.catch(() => {}) try { await withRepoRefMaintenancePaused('worktree-prepare', () => runWithGitReadCacheInvalidation(async () => { const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) ) + let lockPath: string | undefined try { - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'add', - '--detach', - '--no-checkout', - worktreePath, - effectiveBase - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + lockPath = await addLockedWorktreePreparation( + repoPath, + worktreePath, + effectiveBase, + lockReason, + { + ...options, + timeout: resolveWorktreeAddTimeoutMs() + } ) - // The add just wrote the marker; drop any pre-create route before the reset routes Git. - invalidateWslLinkedWorktreeGitRouting(worktreePath) + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal) + let materializationBase = effectiveBase + if (beforeMaterialization) { + await waitForPromiseWithSignal(beforeMaterialization, options.signal) + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal) + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--verify', `${effectiveBase}^{commit}`], + gitExecOptions(repoPath, options) + ) + materializationBase = stdout.trim() + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal) + } // Why: reset materializes files without running user post-checkout hooks before submit. await gitExecFileAsync( - [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], + [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', materializationBase], { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } ) - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'lock', - '--reason', - lockReason, - worktreePath - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } - ) + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal) } catch (error) { - await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) + if (lockPath !== undefined && !(error instanceof WorktreePreparationLockOwnershipError)) { + await performDiscardPreparedWorktree(repoPath, worktreePath, options, lockReason).catch( + () => {} + ) + } throw error } }) @@ -109,11 +94,12 @@ export async function prepareWorktreeCreateCheckout( export async function discardPreparedWorktree( repoPath: string, worktreePath: string, - options: GitWorktreeExecOptions = {} + options: GitWorktreeExecOptions = {}, + expectedLockReason: string ): Promise { try { await runWithGitReadCacheInvalidation(() => - performDiscardPreparedWorktree(repoPath, worktreePath, options) + performDiscardPreparedWorktree(repoPath, worktreePath, options, expectedLockReason) ) } finally { notifyPreparedWorktreeMutation(repoPath) @@ -123,52 +109,22 @@ export async function discardPreparedWorktree( export async function unlockPreparedWorktree( repoPath: string, worktreePath: string, - options: GitWorktreeExecOptions = {} + options: GitWorktreeExecOptions = {}, + expectedLockReason: string ): Promise { const cleanupGitOptions = { ...gitCleanupOptions(repoPath, options), timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } try { - await runWithGitReadCacheInvalidation(() => - gitExecFileAsync( - [...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath], - cleanupGitOptions - ) - ) + await runWithGitReadCacheInvalidation(async () => { + await unlockWorktreePreparation(worktreePath, expectedLockReason, cleanupGitOptions) + }) } finally { notifyPreparedWorktreeMutation(repoPath) } } -async function removeFailedFinalization( - repoPath: string, - cleanupPath: string, - branch: string, - moved: boolean, - options: GitWorktreeExecOptions -): Promise { - let branchAttached = false - if (moved) { - try { - const { stdout } = await gitExecFileAsync( - ['symbolic-ref', '--short', 'HEAD'], - gitCleanupOptions(cleanupPath, options) - ) - branchAttached = stdout.trim() === branch - } catch { - // Detached or no longer readable. - } - } - await performDiscardPreparedWorktree(repoPath, cleanupPath, options).catch(() => {}) - if (branchAttached) { - await gitExecFileAsync( - ['branch', '-D', '--', branch], - gitCleanupOptions(repoPath, options) - ).catch(() => {}) - } -} - export async function finalizePreparedWorktree( repoPath: string, preparedPath: string, @@ -176,7 +132,8 @@ export async function finalizePreparedWorktree( branch: string, baseBranch: string, refreshLocalBaseRef = false, - options: AddWorktreeOptions = {} + options: AddWorktreeOptions = {}, + expectedLockReason: string ): Promise { const finalizeGitOptions: AddWorktreeOptions = { ...options, @@ -184,6 +141,13 @@ export async function finalizePreparedWorktree( } try { return await runWithGitReadCacheInvalidation(async () => { + const lockPath = await verifyWorktreePreparationLock( + preparedPath, + expectedLockReason, + finalizeGitOptions + ) + const verifyOwnership = (): Promise => + verifyWorktreePreparationLockAtPath(lockPath, expectedLockReason, finalizeGitOptions.signal) const [targetResult, preparedResult] = await Promise.allSettled([ (async () => { const baseContext = await resolveWorktreeAddBaseContext( @@ -219,6 +183,7 @@ export async function finalizePreparedWorktree( } const preparedHeadOutput = preparedResult.value.stdout if (preparedHeadOutput.trim() !== targetHead) { + await verifyOwnership() await gitExecFileAsync( [...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead], gitExecOptions(preparedPath, finalizeGitOptions) @@ -228,6 +193,7 @@ export async function finalizePreparedWorktree( let moved = false try { try { + await verifyOwnership() // Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25). await gitExecFileAsync( [ @@ -247,6 +213,7 @@ export async function finalizePreparedWorktree( invalidateWslLinkedWorktreeGitRouting(preparedPath) invalidateWslLinkedWorktreeGitRouting(worktreePath) } + await verifyOwnership() await gitExecFileAsync( [ ...windowsLongPathGitArgs(worktreePath), @@ -258,6 +225,7 @@ export async function finalizePreparedWorktree( ], gitExecOptions(worktreePath, finalizeGitOptions) ) + await verifyOwnership() await persistWorktreeCreationBase( worktreePath, branch, @@ -265,18 +233,22 @@ export async function finalizePreparedWorktree( finalizeGitOptions ) await configurePushAutoSetupRemote(worktreePath, finalizeGitOptions) - await gitExecFileAsync( - [...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath], - gitExecOptions(repoPath, finalizeGitOptions) + await unlockWorktreePreparationAtPath( + lockPath, + expectedLockReason, + finalizeGitOptions.signal ) } catch (error) { - await removeFailedFinalization( - repoPath, - moved ? worktreePath : preparedPath, - branch, - moved, - finalizeGitOptions - ) + if (!(error instanceof WorktreePreparationLockOwnershipError)) { + await removeFailedFinalization( + repoPath, + moved ? worktreePath : preparedPath, + branch, + moved, + finalizeGitOptions, + expectedLockReason + ) + } await baseContext.pendingLocalBaseRefRefresh throw error } diff --git a/src/main/git/worktree-mutation-route-invalidation.test.ts b/src/main/git/worktree-mutation-route-invalidation.test.ts index 785ed3d1391..48db9133114 100644 --- a/src/main/git/worktree-mutation-route-invalidation.test.ts +++ b/src/main/git/worktree-mutation-route-invalidation.test.ts @@ -1,6 +1,7 @@ // Worktree add/move/remove/rollback rewrite the `.git` marker the WSL Git route was derived from. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as WorktreeModule from './worktree' +import type * as WorktreePreparationLock from './worktree-preparation-lock' const { gitExecFileAsyncMock, @@ -38,6 +39,14 @@ vi.mock('./worktree-scan-cache', () => ({ listWorktrees: listWorktreesMock })) +vi.mock('./worktree-preparation-lock', async (importOriginal) => ({ + ...(await importOriginal()), + lockWorktreePreparation: vi.fn(async () => '/owned-lock'), + verifyWorktreePreparationLock: vi.fn(async () => '/owned-lock'), + verifyWorktreePreparationLockAtPath: vi.fn(), + unlockWorktreePreparationAtPath: vi.fn() +})) + import { addWorktree } from './worktree-add' import { discardPreparedWorktree, @@ -120,7 +129,7 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () = it('drops the route after a prepared worktree is discarded', async () => { seedWslLinkedWorktreeGitRoutingForTests(LINKED) - await discardPreparedWorktree(REPO, LINKED) + await discardPreparedWorktree(REPO, LINKED, {}, 'owner') expect(hasCachedHostRoute(LINKED)).toBe(false) }) @@ -133,11 +142,41 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () = expect(hasCachedHostRoute(PREPARED)).toBe(false) }) + it('reads and materializes the barrier tip on the preparation Git host', async () => { + const head = 'b'.repeat(40) + gitExecFileAsyncMock.mockResolvedValue({ stdout: `${head}\n`, stderr: '' }) + await prepareWorktreeCreateCheckout( + REPO, + PREPARED, + 'refs/remotes/origin/main', + 'owner', + { wslDistro: 'Ubuntu' }, + Promise.resolve() + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'], + { cwd: REPO, wslDistro: 'Ubuntu' } + ) + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['reset', '--hard', head], + expect.objectContaining({ cwd: PREPARED, wslDistro: 'Ubuntu' }) + ) + }) + it('drops both routes after the prepared checkout is moved into place', async () => { seedWslLinkedWorktreeGitRoutingForTests(PREPARED) seedWslLinkedWorktreeGitRoutingForTests(LINKED) - await finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main') + await finalizePreparedWorktree( + REPO, + PREPARED, + LINKED, + 'feature', + 'origin/main', + false, + {}, + 'owner' + ) expect(hasCachedHostRoute(PREPARED)).toBe(false) expect(hasCachedHostRoute(LINKED)).toBe(false) @@ -153,7 +192,7 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () = ) await expect( - finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main') + finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main', false, {}, 'owner') ).rejects.toThrow('destination exists') expect(hasCachedHostRoute(PREPARED)).toBe(false) diff --git a/src/main/git/worktree-preparation-add.test.ts b/src/main/git/worktree-preparation-add.test.ts new file mode 100644 index 00000000000..f96db71aebb --- /dev/null +++ b/src/main/git/worktree-preparation-add.test.ts @@ -0,0 +1,332 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type * as FilePromises from 'node:fs/promises' +import type * as PreparationLock from './worktree-preparation-lock' +import { isUnsupportedWorktreeAddLockReasonError } from '../../shared/git-worktree-command-capabilities' + +const mocks = vi.hoisted(() => ({ + git: vi.fn(), + lstat: vi.fn(), + lock: vi.fn(), + verify: vi.fn(), + discard: vi.fn() +})) +vi.mock('./runner', () => ({ gitExecFileAsync: mocks.git })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + lstat: mocks.lstat +})) +vi.mock('./worktree-preparation-lock', async (importOriginal) => ({ + ...(await importOriginal()), + lockWorktreePreparation: mocks.lock, + verifyWorktreePreparationLock: mocks.verify +})) +vi.mock('./worktree-preparation-discard', () => ({ performDiscardPreparedWorktree: mocks.discard })) + +import { addLockedWorktreePreparation } from './worktree-preparation-add' +import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache } from './git-capability-state' +import { WorktreePreparationLockOwnershipError } from './worktree-preparation-lock' +import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree-operation-options' +import { + resetWslLinkedWorktreeGitRoutingForTests, + seedWslLinkedWorktreeGitRoutingForTests +} from './wsl-linked-worktree-git-routing' + +const reason = 'orca-create-preparation:v1:123:atomic' +const unsupported = Object.assign(new Error("error: unknown option 'reason'"), { code: 129 }) +const prepare = (path = '/prepared', options = {}) => + addLockedWorktreePreparation('/repo', path, 'refs/heads/main', reason, options) + +beforeEach(() => { + clearGitCapabilityStateForTests() + resetWslLinkedWorktreeGitRoutingForTests() + mocks.git.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + mocks.lstat.mockReset().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + mocks.lock.mockReset().mockResolvedValue('/fallback-lock') + mocks.verify.mockReset().mockResolvedValue('/atomic-lock') + mocks.discard.mockReset().mockResolvedValue(undefined) +}) + +it('asks Git to create the exact marker atomically and only verifies its ownership', async () => { + const controller = new AbortController() + const options = { signal: controller.signal, timeout: 6000, admissionTier: 'background' as const } + await expect(prepare('/prepared', options)).resolves.toBe('/atomic-lock') + expect(mocks.git).toHaveBeenCalledExactlyOnceWith( + [ + 'worktree', + 'add', + '--detach', + '--no-checkout', + '--lock', + '--reason', + reason, + '/prepared', + 'refs/heads/main' + ], + { cwd: '/repo', ...options } + ) + expect(mocks.verify).toHaveBeenCalledExactlyOnceWith('/prepared', reason, options) + expect(mocks.lock).not.toHaveBeenCalled() +}) + +it('falls back once on the old-Git reason rejection and caches the absence', async () => { + mocks.git.mockRejectedValueOnce(unsupported) + await expect(prepare()).resolves.toBe('/fallback-lock') + await expect(prepare('/second')).resolves.toBe('/fallback-lock') + expect(mocks.git.mock.calls.map(([args]) => args.includes('--reason'))).toEqual([ + true, + false, + false + ]) + expect(mocks.lock.mock.calls.map(([path]) => path)).toEqual(['/prepared', '/second']) + expect(mocks.verify).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() +}) + +it.each([ + ['first', 'cancellation'], + ['cached', 'cancellation'], + ['first', 'path probe'], + ['cached', 'path probe'], + ['first', 'marker write'], + ['cached', 'marker write'] +])('cleans its successful %s fallback add after a %s failure', async (fallback, failureKind) => { + const controller = new AbortController() + const options = { + signal: controller.signal, + timeout: 180_000, + wslDistro: 'Ubuntu', + admissionTier: 'background' as const + } + if (fallback === 'cached') { + getLocalGitCapabilityCache(options).rememberUnsupported('worktree-add-lock-reason') + } else { + mocks.git.mockRejectedValueOnce(unsupported) + } + const failure = new Error(`${failureKind} failed after registration`) + mocks.lock.mockImplementationOnce(async () => { + if (failureKind === 'cancellation') { + controller.abort(failure) + } + throw failure + }) + await expect(prepare('/prepared', options)).rejects.toBe(failure) + expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', { + ...options, + signal: undefined, + timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + }) + expect(mocks.lstat).toHaveBeenCalled() + expect(options.timeout).toBe(180_000) + expect(options.signal).toBe(controller.signal) +}) + +it('preserves the original lock failure when bounded fallback cleanup also fails', async () => { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + const failure = new Error('lock path unavailable') + mocks.lock.mockRejectedValueOnce(failure) + mocks.discard.mockRejectedValueOnce(new Error('cleanup unavailable')) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.discard).toHaveBeenCalledOnce() +}) + +it.each(['first', 'cached'])( + 'preserves a pre-existing target after %s fallback locking fails', + async (fallback) => { + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } else { + mocks.git.mockRejectedValueOnce(unsupported) + } + mocks.lstat.mockResolvedValue({}) + const failure = new Error('marker write denied') + mocks.lock.mockRejectedValueOnce(failure) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.discard).not.toHaveBeenCalled() + } +) + +it.each(['first', 'cached'])( + 'preserves a competing marker during %s fallback locking', + async (fallback) => { + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } else { + mocks.git.mockRejectedValueOnce(unsupported) + } + mocks.lock.mockRejectedValueOnce(new WorktreePreparationLockOwnershipError()) + await expect(prepare()).rejects.toThrow('lock owner changed') + expect(mocks.discard).not.toHaveBeenCalled() + } +) + +it.each(['first', 'cached'])( + 'preserves an incomplete or rejected %s fallback add', + async (fallback) => { + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } else { + mocks.git.mockRejectedValueOnce(unsupported) + } + const failure = new Error('add did not complete') + mocks.git.mockRejectedValueOnce(failure) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.lock).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() + } +) + +it('fails closed before a cached fallback add if the target cannot be inspected', async () => { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + const failure = Object.assign(new Error('target unavailable'), { code: 'EACCES' }) + mocks.lstat.mockRejectedValueOnce(failure) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.git).not.toHaveBeenCalled() + expect(mocks.lock).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() +}) + +it('coalesces concurrent unsupported probes while creating each checkout separately', async () => { + let reject!: (error: unknown) => void + mocks.git.mockImplementationOnce( + () => + new Promise((_resolve, rejectProbe) => { + reject = rejectProbe + }) + ) + const first = prepare('/first') + await vi.waitFor(() => expect(mocks.git).toHaveBeenCalledTimes(1)) + const second = prepare('/second') + await Promise.resolve() + expect(mocks.git).toHaveBeenCalledTimes(1) + reject(unsupported) + await expect(Promise.all([first, second])).resolves.toEqual(['/fallback-lock', '/fallback-lock']) + expect(mocks.git.mock.calls.filter(([args]) => args.includes('--reason'))).toHaveLength(1) + expect(new Set(mocks.lock.mock.calls.map(([path]) => path))).toEqual( + new Set(['/first', '/second']) + ) +}) + +it('runs each concurrent supported add rather than sharing the first checkout result', async () => { + let resolve!: (value: { stdout: string }) => void + mocks.git.mockImplementationOnce( + () => + new Promise((resolveProbe) => { + resolve = resolveProbe + }) + ) + mocks.verify.mockImplementation(async (path: string) => `${path}/owned-lock`) + const first = prepare('/first') + await vi.waitFor(() => expect(mocks.git).toHaveBeenCalledTimes(1)) + const second = prepare('/second') + resolve({ stdout: '' }) + await expect(Promise.all([first, second])).resolves.toEqual([ + '/first/owned-lock', + '/second/owned-lock' + ]) + expect(mocks.git.mock.calls.every(([args]) => args.includes('--reason'))).toBe(true) + expect(mocks.git).toHaveBeenCalledTimes(2) +}) + +it('isolates native rejection from individual WSL distros', async () => { + mocks.git.mockRejectedValueOnce(unsupported) + await prepare() + await prepare('/ubuntu', { wslDistro: 'Ubuntu' }) + await prepare('/debian', { wslDistro: 'Debian' }) + await prepare('/native-again') + expect(mocks.git.mock.calls.map(([args]) => args.includes('--reason'))).toEqual([ + true, + false, + true, + true, + false + ]) +}) + +it('uses native cached rejection when WSL routing executes the host Git binary', async () => { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + const repoPath = String.raw`C:\repo\linked` + seedWslLinkedWorktreeGitRoutingForTests(repoPath) + await addLockedWorktreePreparation(repoPath, String.raw`C:\prepared`, 'main', reason, { + wslDistro: 'Ubuntu' + }) + expect(mocks.git).toHaveBeenCalledTimes(1) + expect(mocks.git.mock.calls[0][0]).not.toContain('--reason') + expect( + getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' }).shouldTry('worktree-add-lock-reason') + ).toBe(true) + } finally { + platform.mockRestore() + } +}) + +it('checks a guest directory through its execution distro rather than the Windows root', async () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + await prepare('/home/prepared', { wslDistro: 'Ubuntu' }) + expect(mocks.lstat).toHaveBeenCalledExactlyOnceWith( + String.raw`\\wsl.localhost\Ubuntu\home\prepared` + ) + } finally { + platform.mockRestore() + } +}) + +it('fails closed when the pre-existing target cannot be inspected', async () => { + const failure = Object.assign(new Error('path access denied'), { code: 'EACCES' }) + mocks.lstat.mockRejectedValueOnce(failure) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.git).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() +}) + +it('keeps a general add failure visible without poisoning the capability or claiming a marker', async () => { + const failure = new Error('permission denied') + mocks.git.mockRejectedValueOnce(failure) + await expect(prepare()).rejects.toBe(failure) + expect(mocks.lock).not.toHaveBeenCalled() + expect(getLocalGitCapabilityCache().shouldTry('worktree-add-lock-reason')).toBe(true) + expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', {}, reason) + await prepare('/next') + expect(mocks.git.mock.calls.every(([args]) => args.includes('--reason'))).toBe(true) +}) + +it('never removes a pre-existing checkout after add fails', async () => { + mocks.lstat.mockResolvedValueOnce({}) + mocks.git.mockRejectedValueOnce(new Error('path already exists')) + await expect(prepare()).rejects.toThrow('path already exists') + expect(mocks.discard).not.toHaveBeenCalled() + expect(mocks.lock).not.toHaveBeenCalled() +}) + +it('does not rewrite or discard a generic or competing marker returned after add', async () => { + mocks.verify.mockRejectedValueOnce(new WorktreePreparationLockOwnershipError()) + await expect(prepare()).rejects.toThrow('lock owner changed') + expect(mocks.lock).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() +}) + +it('attempts ownership-checked cleanup if cancellation interrupts the newly registered add', async () => { + const controller = new AbortController() + const failure = new Error('add canceled') + mocks.git.mockImplementationOnce(async () => { + controller.abort() + throw failure + }) + const options = { signal: controller.signal } + await expect(prepare('/prepared', options)).rejects.toBe(failure) + expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', options, reason) + expect(mocks.lock).not.toHaveBeenCalled() +}) + +it.each([ + [new Error("unknown option 'reason'"), true], + [{ stderr: "error: unrecognized option '--reason'" }, true], + [{ stdout: 'invalid switch --reason' }, true], + [{ code: 129, stderr: "unknown option 'detach'" }, false], + [{ code: 129 }, false], + [new Error('permission denied while writing lock reason'), false] +])('recognizes only an unsupported lock-reason option: %j', (error, expected) => { + expect(isUnsupportedWorktreeAddLockReasonError(error)).toBe(expected) +}) diff --git a/src/main/git/worktree-preparation-add.ts b/src/main/git/worktree-preparation-add.ts new file mode 100644 index 00000000000..bcbeb4dae8f --- /dev/null +++ b/src/main/git/worktree-preparation-add.ts @@ -0,0 +1,119 @@ +import { lstat } from 'node:fs/promises' +import { isUnsupportedWorktreeAddLockReasonError } from '../../shared/git-worktree-command-capabilities' +import { resolveWorktreeHostPath } from '../../shared/git-metadata-path' +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { toHostFilesystemPath } from '../host-tree-removal' +import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { gitExecFileAsync } from './runner' +import { + getErrorCode, + gitExecOptions, + WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS, + type GitWorktreeExecOptions +} from './worktree-operation-options' +import { performDiscardPreparedWorktree } from './worktree-preparation-discard' +import { + lockWorktreePreparation, + verifyWorktreePreparationLock, + WorktreePreparationLockOwnershipError +} from './worktree-preparation-lock' +import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' + +async function isAbsent(worktreePath: string, options: GitWorktreeExecOptions): Promise { + const hostPath = resolveWorktreeHostPath(worktreePath, options) + if (!hostPath) { + throw new Error('The prepared worktree path is empty') + } + try { + await lstat(toHostFilesystemPath(hostPath)) + return false + } catch (error) { + if (getErrorCode(error) === 'ENOENT') { + return true + } + throw error + } +} + +export function addLockedWorktreePreparation( + repoPath: string, + worktreePath: string, + baseRef: string, + lockReason: string, + options: GitWorktreeExecOptions +): Promise { + const add = async (lockArgs: string[]): Promise => { + try { + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'add', + '--detach', + '--no-checkout', + ...lockArgs, + worktreePath, + baseRef + ], + gitExecOptions(repoPath, options) + ) + } finally { + invalidateWslLinkedWorktreeGitRouting(worktreePath) + } + } + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + async (capabilities) => { + const initiallyAbsent = await isAbsent(worktreePath, options) + return capabilities.runWithFallback( + 'worktree-add-lock-reason', + async () => { + let added = false + try { + await add(['--lock', '--reason', lockReason]) + added = true + return await verifyWorktreePreparationLock(worktreePath, lockReason, options) + } catch (error) { + // A canceled add can leave its marker; a pre-existing checkout is never ours to remove. + if ( + (added || initiallyAbsent) && + !isUnsupportedWorktreeAddLockReasonError(error) && + !(error instanceof WorktreePreparationLockOwnershipError) + ) { + await performDiscardPreparedWorktree( + repoPath, + worktreePath, + options, + lockReason + ).catch(() => {}) + } + throw error + } + }, + async () => { + let added = false + try { + await add([]) + added = true + return await lockWorktreePreparation(worktreePath, lockReason, options) + } catch (error) { + if ( + added && + initiallyAbsent && + !(error instanceof WorktreePreparationLockOwnershipError) + ) { + // Single force reclaims our unlocked add while preserving any competing lock. + await performDiscardPreparedWorktree(repoPath, worktreePath, { + ...options, + signal: undefined, + timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + }).catch(() => {}) + } + throw error + } + }, + isUnsupportedWorktreeAddLockReasonError + ) + } + ) +} diff --git a/src/main/git/worktree-preparation-base-oid.test.ts b/src/main/git/worktree-preparation-base-oid.test.ts index ecd6f4eab19..508707dbd9e 100644 --- a/src/main/git/worktree-preparation-base-oid.test.ts +++ b/src/main/git/worktree-preparation-base-oid.test.ts @@ -1,8 +1,15 @@ import { beforeEach, expect, it, vi } from 'vitest' import type * as WorktreeBaseRefresh from './worktree-base-refresh' +import type * as WorktreePreparationLock from './worktree-preparation-lock' const gitExec = vi.hoisted(() => vi.fn()) vi.mock('./runner', () => ({ gitExecFileAsync: gitExec })) +vi.mock('./worktree-preparation-lock', async (importOriginal) => ({ + ...(await importOriginal()), + verifyWorktreePreparationLock: vi.fn(async () => '/owned-lock'), + verifyWorktreePreparationLockAtPath: vi.fn(), + unlockWorktreePreparationAtPath: vi.fn() +})) vi.mock('./worktree-base-refresh', async (importOriginal) => ({ ...(await importOriginal()), refreshLocalBaseRefForWorktreeCreate: vi.fn(async () => undefined), @@ -30,10 +37,16 @@ beforeEach(() => { }) it('reuses the current base-resolution oid and preserves WSL routing', async () => { - await finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main', false, { - wslDistro: 'Ubuntu', - timeout: 8000 - }) + await finalizePreparedWorktree( + '/repo', + '/prepared', + '/final', + 'feature', + 'main', + false, + { wslDistro: 'Ubuntu', timeout: 8000 }, + 'owner' + ) const revisions = gitExec.mock.calls.filter(([args]) => args[0] === 'rev-parse') expect(revisions.map(([args]) => args)).toEqual([ ['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'], @@ -58,7 +71,8 @@ it.each([ 'feature', test.base, test.refresh, - test.options + test.options, + 'owner' ) expect(gitExec).toHaveBeenCalledWith( ['rev-parse', '--verify', 'refs/heads/main^{commit}'], @@ -84,7 +98,16 @@ it('starts both independent probes before either resolves and settles them befor }) let settled = false const error = new Error('prepared HEAD unreadable') - const result = finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main') + const result = finalizePreparedWorktree( + '/repo', + '/prepared', + '/final', + 'feature', + 'main', + false, + {}, + 'owner' + ) const checked = expect(result).rejects.toBe(error) void result.then( () => (settled = true), diff --git a/src/main/git/worktree-preparation-cancel-latency.bench.test.ts b/src/main/git/worktree-preparation-cancel-latency.bench.test.ts index e2750df839d..c5f30b4788b 100644 --- a/src/main/git/worktree-preparation-cancel-latency.bench.test.ts +++ b/src/main/git/worktree-preparation-cancel-latency.bench.test.ts @@ -43,16 +43,28 @@ function nextPreparedPath(label: string): string { return join(preparationRoot, `${process.pid}-${label}-${sequence}`) } +const lockReasons = new Map() + function checkout(preparedPath: string, signal?: AbortSignal): Promise { + const lockReason = createWorktreePreparationLockReason(`bench-${sequence}`) + lockReasons.set(preparedPath, lockReason) return prepareWorktreeCreateCheckout( repoPath, preparedPath, 'main', - createWorktreePreparationLockReason(`bench-${sequence}`), + lockReason, signal ? { signal } : {} ) } +function discard(preparedPath: string): Promise { + const lockReason = lockReasons.get(preparedPath) + if (!lockReason) { + throw new Error('The benchmark checkout has no lock reason') + } + return discardPreparedWorktree(repoPath, preparedPath, {}, lockReason) +} + async function runTrial(variant: Variant, obsolete: number): Promise { const controllers = Array.from({ length: obsolete }, () => new AbortController()) const obsoletePaths = controllers.map(() => nextPreparedPath('obsolete')) @@ -69,11 +81,7 @@ async function runTrial(variant: Variant, obsolete: number): Promise { await checkout(freshPath) const freshCheckoutMs = performance.now() - started await Promise.all(obsoleteWork) - await Promise.all( - [...obsoletePaths, freshPath].map((path) => - discardPreparedWorktree(repoPath, path).catch(() => {}) - ) - ) + await Promise.all([...obsoletePaths, freshPath].map((path) => discard(path).catch(() => {}))) return { variant, obsolete, freshCheckoutMs } } @@ -118,7 +126,7 @@ describeBench('obsolete preparation cancellation latency', () => { // Warm the object store and page cache once so the first variant is not penalised. const warm = nextPreparedPath('warm') await checkout(warm) - await discardPreparedWorktree(repoPath, warm) + await discard(warm) const samples: Sample[] = [] for (const obsolete of OBSOLETE_COUNTS) { diff --git a/src/main/git/worktree-preparation-discard.ts b/src/main/git/worktree-preparation-discard.ts new file mode 100644 index 00000000000..bca815e8dc3 --- /dev/null +++ b/src/main/git/worktree-preparation-discard.ts @@ -0,0 +1,89 @@ +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { gitExecFileAsync } from './runner' +import { + gitExecOptions, + WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS, + type GitExecOptionsForWorktree, + type GitWorktreeExecOptions +} from './worktree-operation-options' +import { verifyWorktreePreparationLock } from './worktree-preparation-lock' +import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' + +export function gitCleanupOptions( + cwd: string, + options: GitWorktreeExecOptions +): GitExecOptionsForWorktree { + // Why: cancellation must not strand a partially moved worktree; cleanup is bounded separately. + return gitExecOptions(cwd, { ...options, signal: undefined }) +} + +export async function performDiscardPreparedWorktree( + repoPath: string, + worktreePath: string, + options: GitWorktreeExecOptions, + expectedLockReason?: string +): Promise { + const cleanupGitOptions = { + ...gitCleanupOptions(repoPath, options), + timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + } + try { + if (expectedLockReason !== undefined) { + await verifyWorktreePreparationLock(worktreePath, expectedLockReason, cleanupGitOptions) + } + // Double force requires a freshly verified ownership marker. + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'remove', + '--force', + ...(expectedLockReason === undefined ? [] : ['--force']), + worktreePath + ], + cleanupGitOptions + ) + } finally { + invalidateWslLinkedWorktreeGitRouting(worktreePath) + } +} + +export async function removeFailedFinalization( + repoPath: string, + cleanupPath: string, + branch: string, + moved: boolean, + options: GitWorktreeExecOptions, + expectedLockReason?: string +): Promise { + let branchAttached = false + if (moved) { + try { + const { stdout } = await gitExecFileAsync( + ['symbolic-ref', '--short', 'HEAD'], + gitCleanupOptions(cleanupPath, options) + ) + branchAttached = stdout.trim() === branch + } catch { + // Detached or no longer readable. + } + } + const removed = await performDiscardPreparedWorktree( + repoPath, + cleanupPath, + options, + expectedLockReason + ).then( + () => true, + () => false + ) + if (!removed) { + return + } + if (branchAttached) { + await gitExecFileAsync( + ['branch', '-D', '--', branch], + gitCleanupOptions(repoPath, options) + ).catch(() => {}) + } +} diff --git a/src/main/git/worktree-preparation-fallback-cleanup-real-git.test.ts b/src/main/git/worktree-preparation-fallback-cleanup-real-git.test.ts new file mode 100644 index 00000000000..2f2e7b05590 --- /dev/null +++ b/src/main/git/worktree-preparation-fallback-cleanup-real-git.test.ts @@ -0,0 +1,207 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' +import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache } from './git-capability-state' +import * as runner from './runner' +import { prepareWorktreeCreateCheckout } from './worktree-create-preparation' +import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree-operation-options' + +const roots: string[] = [] +const unsupported = Object.assign(new Error("error: unknown option 'reason'"), { code: 129 }) + +beforeEach(() => { + clearGitCapabilityStateForTests() +}) + +afterEach(async () => { + vi.restoreAllMocks() + clearGitCapabilityStateForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function git(cwd: string, args: string[]): Promise { + return (await runner.gitExecFileAsync(args, { cwd })).stdout.trim() +} + +async function fixture() { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-fallback-cleanup-'))) + roots.push(root) + const repo = join(root, 'repo') + const prepared = join(root, 'prepared') + await git(root, ['init', '--quiet', repo]) + await git(repo, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + await writeFile(join(repo, 'tracked.txt'), 'original\n') + await git(repo, ['add', 'tracked.txt']) + await git(repo, [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.invalid', + 'commit', + '--quiet', + '-m', + 'initial' + ]) + return { repo, prepared } +} + +it.each([ + ['first', 'cancellation'], + ['cached', 'cancellation'], + ['first', 'path probe'], + ['cached', 'path probe'] +])('reclaims its registered %s fallback after %s before locking', async (fallback, failureKind) => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('fallback-cleanup') + const controller = new AbortController() + const failure = new Error(`${failureKind} after completed add`) + const run = runner.gitExecFileAsync + let registered = false + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (args.includes('--reason')) { + throw unsupported + } + const result = await run(args, options) + if (args.includes('--no-checkout')) { + registered = true + expect(existsSync(join(prepared, '.git'))).toBe(true) + } + if (registered && options?.cwd === prepared && args.includes('--git-path')) { + expect(existsSync(join(prepared, 'tracked.txt'))).toBe(false) + if (failureKind === 'cancellation') { + controller.abort(failure) + } + throw failure + } + return result + }) + await expect( + prepareWorktreeCreateCheckout(repo, prepared, 'main', reason, { + signal: controller.signal, + timeout: 180_000 + }) + ).rejects.toBe(failure) + const removals = spy.mock.calls.filter(([args]) => args.includes('remove')) + expect(removals).toHaveLength(1) + const [removeArgs, removeOptions] = removals[0]! + expect(removeArgs.filter((arg) => arg === '--force')).toHaveLength(1) + expect(removeOptions).toMatchObject({ + cwd: repo, + timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + }) + expect(removeOptions).not.toHaveProperty('signal') + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + expect(existsSync(prepared)).toBe(false) + expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).not.toContain( + prepared + ) +}) + +it.each(['first', 'cached'])( + 'preserves another marker after a successful %s fallback add', + async (fallback) => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('competing-fallback') + const run = runner.gitExecFileAsync + let lock = '' + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (args.includes('--reason')) { + throw unsupported + } + const result = await run(args, options) + if (args.includes('--no-checkout')) { + lock = (await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared })).stdout.trim() + await writeFile(lock, 'manual competing owner\n') + await writeFile(join(prepared, 'user.txt'), 'preserve this file\n') + } + return result + }) + await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toThrow( + 'lock owner changed' + ) + expect(await readFile(lock, 'utf8')).toBe('manual competing owner\n') + expect(await readFile(join(prepared, 'user.txt'), 'utf8')).toBe('preserve this file\n') + expect(spy.mock.calls.some(([args]) => args.includes('remove') || args.includes('reset'))).toBe( + false + ) + expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).toContain( + prepared + ) + } +) + +it.each(['first', 'cached'])( + 'lets Git protect a competing lock from %s fallback cleanup after a generic probe failure', + async (fallback) => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('protected-fallback-cleanup') + const failure = new Error('lock path became unreadable') + const run = runner.gitExecFileAsync + let lock = '' + if (fallback === 'cached') { + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + } + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (args.includes('--reason')) { + throw unsupported + } + if (lock && options?.cwd === prepared && args.includes('--git-path')) { + throw failure + } + const result = await run(args, options) + if (args.includes('--no-checkout')) { + lock = (await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared })).stdout.trim() + await writeFile(lock, 'manual protected owner\n') + await writeFile(join(prepared, 'user.txt'), 'preserve after cleanup attempt\n') + } + return result + }) + await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toBe( + failure + ) + const removals = spy.mock.calls.filter(([args]) => args.includes('remove')) + expect(removals).toHaveLength(1) + expect(removals[0]![0].filter((arg) => arg === '--force')).toHaveLength(1) + expect(removals[0]![1]).not.toHaveProperty('signal') + expect(removals[0]![1]).toMatchObject({ timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS }) + expect(await readFile(lock, 'utf8')).toBe('manual protected owner\n') + expect(await readFile(join(prepared, 'user.txt'), 'utf8')).toBe( + 'preserve after cleanup attempt\n' + ) + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).toContain( + prepared + ) + } +) + +it('preserves a pre-existing empty target if its cached fallback lock-path probe fails', async () => { + const { repo, prepared } = await fixture() + await mkdir(prepared) + getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason') + const failure = new Error('pre-existing target lock path unavailable') + const run = runner.gitExecFileAsync + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (options?.cwd === prepared && args.includes('--git-path')) { + throw failure + } + return run(args, options) + }) + await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', 'reason')).rejects.toBe( + failure + ) + expect(existsSync(join(prepared, '.git'))).toBe(true) + expect(spy.mock.calls.some(([args]) => args.includes('remove') || args.includes('reset'))).toBe( + false + ) + expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).toContain(prepared) +}) diff --git a/src/main/git/worktree-preparation-lock-real-git.test.ts b/src/main/git/worktree-preparation-lock-real-git.test.ts new file mode 100644 index 00000000000..55c31fdc44d --- /dev/null +++ b/src/main/git/worktree-preparation-lock-real-git.test.ts @@ -0,0 +1,336 @@ +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' +import * as runner from './runner' +import { + discardPreparedWorktree, + finalizePreparedWorktree, + prepareWorktreeCreateCheckout +} from './worktree-create-preparation' +import { unlockWorktreePreparation } from './worktree-preparation-lock' +import { + _resetPreparationPoolForTests, + listPreparations, + startPreparation, + WORKTREE_CREATE_PREPARATION_TTL_MS +} from '../worktree-create-preparation-pool' + +const roots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await _resetPreparationPoolForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function git(cwd: string, args: string[]): Promise { + return (await runner.gitExecFileAsync(args, { cwd })).stdout.trim() +} + +async function fixture(): Promise<{ root: string; repo: string; prepared: string; final: string }> { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-preparation-lock-'))) + roots.push(root) + const repo = join(root, 'repo') + await git(root, ['init', '--quiet', repo]) + await git(repo, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + await writeFile(join(repo, 'tracked.txt'), 'original\n') + await git(repo, ['add', 'tracked.txt']) + await git(repo, [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--quiet', + '-m', + 'initial' + ]) + return { root, repo, prepared: join(root, 'prepared'), final: join(root, 'final') } +} + +it('creates and consumes its marker without worktree lock or unlock inventory scans', async () => { + const { repo, prepared, final } = await fixture() + const reason = createWorktreePreparationLockReason('targeted') + const spy = vi.spyOn(runner, 'gitExecFileAsync') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`) + spy.mockClear() + await finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) + expect(spy.mock.calls.filter(([args]) => args.includes('--git-path'))).toHaveLength(1) + expect(spy.mock.calls.filter(([args]) => args.includes('--git-common-dir'))).toHaveLength(1) + expect(await git(final, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature') + expect(await git(final, ['status', '--porcelain'])).toBe('') + expect(await readFile(join(final, 'tracked.txt'), 'utf8')).toBe('original\n') + await expect(readFile(lock, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(spy.mock.calls.some(([args]) => args.includes('lock') || args.includes('unlock'))).toBe( + false + ) +}) + +it('has its exact ownership marker before the atomic add returns', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('atomic-add') + const run = runner.gitExecFileAsync + let observed = false + vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('--reason')) { + const lock = ( + await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared }) + ).stdout.trim() + expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`) + await expect(readFile(join(prepared, 'tracked.txt'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + observed = true + } + return result + }) + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const version = await git(repo, ['--version']) + const minor = Number(version.match(/git version 2\.(\d+)/)?.[1]) + expect(observed).toBe(minor >= 33) +}) + +it('cleans only its newly registered marker when cancellation follows atomic add', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('atomic-cancellation') + const controller = new AbortController() + const run = runner.gitExecFileAsync + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('--no-checkout') && args.includes('--reason')) { + controller.abort() + throw new Error('canceled after atomic add') + } + return result + }) + await expect( + prepareWorktreeCreateCheckout(repo, prepared, 'main', reason, { signal: controller.signal }) + ).rejects.toThrow('canceled after atomic add') + expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false) + await expect(readFile(join(prepared, '.git'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await git(repo, ['worktree', 'list', '--porcelain'])).not.toContain(prepared) +}) + +it('preserves an existing owned checkout when another add fails at its path', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('existing-add') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + const spy = vi.spyOn(runner, 'gitExecFileAsync') + await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toThrow() + expect(spy.mock.calls.some(([args]) => args.includes('remove'))).toBe(false) + expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`) + expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('original\n') +}) + +it('resolves a relative gitfile and retains a competing unlock marker', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('relative') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const adminDir = await git(prepared, ['rev-parse', '--git-dir']) + await writeFile(join(prepared, '.git'), `gitdir: ${relative(prepared, adminDir)}\n`) + const lock = join(adminDir, 'locked') + await writeFile(lock, 'manual user lock\n') + await expect(unlockWorktreePreparation(prepared, reason, {})).rejects.toThrow( + 'lock owner changed' + ) + expect(await readFile(lock, 'utf8')).toBe('manual user lock\n') + expect(await git(prepared, ['rev-parse', '--verify', 'HEAD'])).toBe( + await git(repo, ['rev-parse', 'HEAD']) + ) +}) + +it('preserves a competing marker and registration through preparation failure and pool reset', async () => { + const { root, repo } = await fixture() + let prepared = '' + let lock = '' + const run = runner.gitExecFileAsync + vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('reset') && options?.cwd) { + prepared = options.cwd + lock = (await run(['rev-parse', '--git-path', 'locked'], options)).stdout.trim() + await writeFile(lock, 'manual competing preparation\n') + } + return result + }) + await expect( + startPreparation({ + repoPath: repo, + workspaceRoot: root, + baseBranch: 'main', + canonicalBase: 'refs/heads/main', + options: {} + }) + ).rejects.toThrow('lock owner changed') + await _resetPreparationPoolForTests() + expect(await readFile(lock, 'utf8')).toBe('manual competing preparation\n') + expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('original\n') + expect(await git(repo, ['worktree', 'list', '--porcelain'])).toContain( + 'manual competing preparation' + ) +}) + +it('claims the marker before materialization and preserves a competing owner after add', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('before-materialization') + const run = runner.gitExecFileAsync + let lock = '' + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('--no-checkout')) { + lock = (await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared })).stdout.trim() + await writeFile(lock, 'manual before materialization\n') + await writeFile(join(prepared, 'tracked.txt'), 'user checkout content\n') + } + return result + }) + await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toThrow( + 'lock owner changed' + ) + expect(spy.mock.calls.some(([args]) => args.includes('reset') || args.includes('remove'))).toBe( + false + ) + expect(await readFile(lock, 'utf8')).toBe('manual before materialization\n') + expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('user checkout content\n') +}) + +it.each(['checkout', 'push.autoSetupRemote'])( + 'preserves the finalized checkout when its marker is replaced during %s', + async (command) => { + const { repo, prepared, final } = await fixture() + const reason = createWorktreePreparationLockReason('replacement') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + const run = runner.gitExecFileAsync + vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + if (args.includes(command)) { + await writeFile(lock, 'manual finalized lock\n') + } + return run(args, options) + }) + await expect( + finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) + ).rejects.toThrow('lock owner changed') + expect(await readFile(lock, 'utf8')).toBe('manual finalized lock\n') + expect(await readFile(join(final, 'tracked.txt'), 'utf8')).toBe('original\n') + expect(await git(final, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature') + } +) + +it('leaves a replacement owner untouched before any reset, move, or branch attachment', async () => { + const { repo, prepared, final } = await fixture() + const reason = createWorktreePreparationLockReason('replaced-before-finalize') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + await writeFile(lock, 'manual replacement\n') + await writeFile(join(prepared, 'tracked.txt'), 'user edits\n') + const spy = vi.spyOn(runner, 'gitExecFileAsync') + await expect( + finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) + ).rejects.toThrow('lock owner changed') + expect( + spy.mock.calls.some( + ([args]) => args.includes('reset') || args.includes('move') || args.includes('checkout') + ) + ).toBe(false) + expect(await readFile(lock, 'utf8')).toBe('manual replacement\n') + expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('user edits\n') + expect(await git(repo, ['branch', '--list', 'feature'])).toBe('') +}) + +it('checks replacement ownership after move before attaching a branch', async () => { + const { repo, prepared, final } = await fixture() + const reason = createWorktreePreparationLockReason('replaced-after-move') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + const run = runner.gitExecFileAsync + const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('move')) { + await writeFile(lock, 'manual moved lock\n') + } + return result + }) + await expect( + finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) + ).rejects.toThrow('lock owner changed') + expect( + spy.mock.calls.some(([args]) => args.includes('checkout') || args.includes('remove')) + ).toBe(false) + expect(await readFile(lock, 'utf8')).toBe('manual moved lock\n') + expect(await git(final, ['symbolic-ref', '--quiet', 'HEAD']).catch(() => 'detached')).toBe( + 'detached' + ) + expect(await git(repo, ['branch', '--list', 'feature'])).toBe('') +}) + +it.each(['replacement', 'missing'])( + 'preserves checkout and branch if failure cleanup finds a %s marker', + async (marker) => { + const { repo, prepared, final } = await fixture() + const reason = createWorktreePreparationLockReason('failure-cleanup') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + const run = runner.gitExecFileAsync + vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => { + const result = await run(args, options) + if (args.includes('checkout')) { + await (marker === 'replacement' ? writeFile(lock, 'manual failed lock\n') : rm(lock)) + throw new Error('injected checkout failure') + } + return result + }) + await expect( + finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason) + ).rejects.toThrow('injected checkout failure') + expect(await readFile(join(final, 'tracked.txt'), 'utf8')).toBe('original\n') + expect(await git(final, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature') + expect(await git(repo, ['branch', '--list', 'feature'])).toContain('feature') + if (marker === 'replacement') { + expect(await readFile(lock, 'utf8')).toBe('manual failed lock\n') + } + } +) + +it('refuses a force discard after the marker was replaced', async () => { + const { repo, prepared } = await fixture() + const reason = createWorktreePreparationLockReason('discard-replacement') + await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason) + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + await writeFile(lock, 'manual discard lock\n') + await expect(discardPreparedWorktree(repo, prepared, {}, reason)).rejects.toThrow( + 'lock owner changed' + ) + expect(await readFile(lock, 'utf8')).toBe('manual discard lock\n') + expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('original\n') +}) + +it.each(['expiry', 'pool reset'])('preserves a replacement marker during %s', async (kind) => { + const { root, repo } = await fixture() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + await startPreparation({ + repoPath: repo, + workspaceRoot: root, + baseBranch: 'main', + canonicalBase: 'refs/heads/main', + options: {} + }) + const entry = listPreparations()[0] + const lock = await git(entry.preparedPath, ['rev-parse', '--git-path', 'locked']) + await writeFile(lock, 'manual expired lock\n') + if (kind === 'expiry') { + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_PREPARATION_TTL_MS) + } + await _resetPreparationPoolForTests() + expect(await readFile(lock, 'utf8')).toBe('manual expired lock\n') + expect(await readFile(join(entry.preparedPath, 'tracked.txt'), 'utf8')).toBe('original\n') + } finally { + vi.useRealTimers() + } +}) diff --git a/src/main/git/worktree-preparation-lock.test.ts b/src/main/git/worktree-preparation-lock.test.ts new file mode 100644 index 00000000000..8120514a695 --- /dev/null +++ b/src/main/git/worktree-preparation-lock.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { toHostFilesystemPath } from '../host-tree-removal' +import type * as FilePromises from 'node:fs/promises' + +const mocks = vi.hoisted(() => ({ + git: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn() +})) +vi.mock('./runner', () => ({ gitExecFileAsync: mocks.git })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + readFile: mocks.readFile, + writeFile: mocks.writeFile, + unlink: mocks.unlink +})) + +import { + lockWorktreePreparation, + resolveWorktreePreparationLockPath, + unlockWorktreePreparation, + unlockWorktreePreparationAtPath +} from './worktree-preparation-lock' + +const lockReason = 'orca-create-preparation:v1:123:exact-session' +const commonDir = join(tmpdir(), 'repo', '.git') +const gitLockPath = join(commonDir, 'worktrees', 'prepared', 'locked') +const lockPath = toHostFilesystemPath(gitLockPath) + +beforeEach(() => { + mocks.git.mockReset().mockImplementation(async (args: string[]) => ({ + stdout: `${args.includes('--git-path') ? gitLockPath : commonDir}\n` + })) + mocks.readFile.mockReset().mockResolvedValue(`${lockReason}\n`) + mocks.writeFile.mockReset().mockResolvedValue(undefined) + mocks.unlink.mockReset().mockResolvedValue(undefined) +}) + +describe('targeted preparation lock ownership', () => { + it('creates Git’s reason marker exclusively without enumerating worktrees', async () => { + const options = { wslDistro: 'Ubuntu', timeout: 8000, admissionTier: 'interactive' as const } + await lockWorktreePreparation('/prepared', lockReason, options) + expect(mocks.git).toHaveBeenCalledTimes(2) + expect(mocks.git).toHaveBeenCalledWith(['rev-parse', '--git-path', 'locked'], { + cwd: '/prepared', + ...options + }) + expect(mocks.git).toHaveBeenCalledWith(['rev-parse', '--git-common-dir'], { + cwd: '/prepared', + ...options + }) + expect(mocks.writeFile).toHaveBeenCalledExactlyOnceWith(lockPath, `${lockReason}\n`, { + flag: 'wx' + }) + }) + + it('preserves an existing lock if exclusive creation fails', async () => { + const error = Object.assign(new Error('lock exists'), { code: 'EEXIST' }) + mocks.writeFile.mockRejectedValueOnce(error) + await expect(lockWorktreePreparation('/prepared', lockReason, {})).rejects.toThrow( + 'lock owner changed' + ) + expect(mocks.unlink).not.toHaveBeenCalled() + }) + + it('unlinks only the exact reason minted for this checkout', async () => { + await unlockWorktreePreparation('/final', lockReason, {}) + expect(mocks.readFile).toHaveBeenCalledExactlyOnceWith(lockPath, 'utf8') + expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith(lockPath) + }) + + it('reuses a verified administrative path and reads the owner again before unlinking', async () => { + await unlockWorktreePreparationAtPath(lockPath, lockReason) + expect(mocks.git).not.toHaveBeenCalled() + expect(mocks.readFile).toHaveBeenCalledExactlyOnceWith(lockPath, 'utf8') + expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith(lockPath) + }) + + it.each([ + 'user lock\n', + 'orca-create-preparation:v1:123:another-session\n', + lockReason, + `${lockReason}\n\n` + ])('preserves a replacement owner: %s', async (reason) => { + mocks.readFile.mockResolvedValueOnce(reason) + await expect(unlockWorktreePreparation('/final', lockReason, {})).rejects.toThrow( + 'lock owner changed' + ) + expect(mocks.unlink).not.toHaveBeenCalled() + }) + + it.each(['ENOENT', 'EACCES'])( + 'preserves the checkout when marker ownership cannot be read: %s', + async (code) => { + mocks.readFile.mockRejectedValueOnce(Object.assign(new Error('marker unavailable'), { code })) + await expect(unlockWorktreePreparation('/final', lockReason, {})).rejects.toThrow( + 'lock owner changed' + ) + expect(mocks.unlink).not.toHaveBeenCalled() + } + ) + + it('honors cancellation after the path probe before writing a lock', async () => { + const controller = new AbortController() + mocks.git.mockImplementationOnce(async () => { + controller.abort() + return { stdout: `${gitLockPath}\n` } + }) + await expect( + lockWorktreePreparation('/prepared', lockReason, { signal: controller.signal }) + ).rejects.toThrow() + expect(mocks.writeFile).not.toHaveBeenCalled() + }) + + it('honors cancellation during the ownership read before unlinking', async () => { + const controller = new AbortController() + const cancellation = new Error('unlock canceled') + mocks.readFile.mockImplementationOnce(async () => { + controller.abort(cancellation) + return `${lockReason}\n` + }) + await expect( + unlockWorktreePreparationAtPath(lockPath, lockReason, controller.signal) + ).rejects.toBe(cancellation) + expect(mocks.unlink).not.toHaveBeenCalled() + }) +}) + +describe('Git preparation lock path resolution', () => { + it.each([ + { + path: '/prepared', + lock: '/repo/.git/worktrees/prepared/locked\n', + common: '/repo/.git\n', + expected: '/repo/.git/worktrees/prepared/locked', + options: { platform: 'linux' as const } + }, + { + path: '/workspace/prepared', + lock: '../../repo/.git/worktrees/prepared/locked\n', + common: '../../repo/.git\n', + expected: '/repo/.git/worktrees/prepared/locked', + options: { platform: 'linux' as const } + }, + { + path: String.raw`C:\workspace\prepared`, + lock: 'C:/repo/.git/worktrees/prepared/locked\n', + common: 'C:/repo/.git\n', + expected: 'C:/repo/.git/worktrees/prepared/locked', + options: { platform: 'win32' as const, wslDistro: 'Ubuntu' } + }, + { + path: String.raw`\\wsl.localhost\Ubuntu\home\workspace\prepared`, + lock: '/home/repo/.git/worktrees/prepared/locked\n', + common: '/home/repo/.git\n', + expected: String.raw`\\wsl.localhost\Ubuntu\home\repo\.git\worktrees\prepared\locked`, + options: { platform: 'win32' as const, wslDistro: 'Ubuntu' } + }, + { + path: String.raw`C:\workspace\prepared`, + lock: '/mnt/c/repo/.git/worktrees/prepared/locked\n', + common: '/mnt/c/repo/.git\n', + expected: String.raw`C:\repo\.git\worktrees\prepared\locked`, + options: { platform: 'win32' as const, wslDistro: 'Ubuntu' } + }, + { + path: '/workspace/new\nline/prepared', + lock: '/repo/new\nline/.git/worktrees/prepared/locked\n', + common: '/repo/new\nline/.git\n', + expected: '/repo/new\nline/.git/worktrees/prepared/locked', + options: { platform: 'linux' as const } + } + ])( + 'resolves $path in the filesystem namespace that owns Git', + ({ path, lock, common, expected, options }) => { + expect(resolveWorktreePreparationLockPath(path, lock, common, options)).toBe(expected) + } + ) + + it.each([ + { lock: '/repo/.git/locked\n', common: '/repo/.git\n' }, + { lock: '/other/.git/worktrees/prepared/locked\n', common: '/repo/.git\n' }, + { lock: '/repo/.git/worktrees/prepared/nested/locked\n', common: '/repo/.git\n' }, + { lock: '/repo/.git/worktrees/prepared/locked\n', common: '' } + ])('rejects a path without one linked administration entry: $lock', ({ lock, common }) => { + expect(() => resolveWorktreePreparationLockPath('/prepared', lock, common)).toThrow( + 'linked worktree lock path' + ) + }) +}) diff --git a/src/main/git/worktree-preparation-lock.ts b/src/main/git/worktree-preparation-lock.ts new file mode 100644 index 00000000000..ba60280b90e --- /dev/null +++ b/src/main/git/worktree-preparation-lock.ts @@ -0,0 +1,130 @@ +import { readFile, unlink, writeFile } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { resolveGitMetadataPath, type GitMetadataPathOptions } from '../../shared/git-metadata-path' +import { toHostFilesystemPath } from '../host-tree-removal' +import { gitExecFileAsync } from './runner' +import { + getErrorCode, + gitExecOptions, + type GitWorktreeExecOptions +} from './worktree-operation-options' + +export class WorktreePreparationLockOwnershipError extends Error { + constructor(cause?: unknown) { + super('The prepared worktree lock owner changed', { cause }) + } +} + +export function resolveWorktreePreparationLockPath( + worktreePath: string, + rawLockPath: string, + rawCommonDir: string, + options: GitMetadataPathOptions = {} +): string { + const lockPath = resolveGitMetadataPath(worktreePath, rawLockPath, options) + const commonDir = resolveGitMetadataPath(worktreePath, rawCommonDir, options) + if (!lockPath || !commonDir) { + throw new Error('Git did not return a linked worktree lock path') + } + const paths = isWindowsAbsolutePathLike(lockPath) ? win32 : posix + const segments = paths.relative(commonDir, lockPath).split(paths.sep) + if ( + segments.length !== 3 || + segments[0] !== 'worktrees' || + !segments[1] || + segments[2] !== 'locked' + ) { + throw new Error('Git did not return a linked worktree lock path') + } + return lockPath +} + +async function readPreparationLockPath( + worktreePath: string, + options: GitWorktreeExecOptions +): Promise { + const results = await Promise.allSettled([ + gitExecFileAsync(['rev-parse', '--git-path', 'locked'], gitExecOptions(worktreePath, options)), + gitExecFileAsync(['rev-parse', '--git-common-dir'], gitExecOptions(worktreePath, options)) + ]) + const [lock, common] = results + if (lock.status === 'rejected') { + throw lock.reason + } + if (common.status === 'rejected') { + throw common.reason + } + return toHostFilesystemPath( + resolveWorktreePreparationLockPath( + worktreePath, + lock.value.stdout, + common.value.stdout, + options + ) + ) +} + +export async function lockWorktreePreparation( + worktreePath: string, + lockReason: string, + options: GitWorktreeExecOptions +): Promise { + const lockPath = await readPreparationLockPath(worktreePath, options) + options.signal?.throwIfAborted() + // Git's own lock marker is a reason plus newline; exclusive creation preserves another owner. + try { + await writeFile(lockPath, `${lockReason}\n`, { flag: 'wx' }) + } catch (error) { + if (getErrorCode(error) === 'EEXIST') { + throw new WorktreePreparationLockOwnershipError() + } + throw error + } + return lockPath +} + +export async function unlockWorktreePreparation( + worktreePath: string, + expectedLockReason: string, + options: GitWorktreeExecOptions +): Promise { + const lockPath = await readPreparationLockPath(worktreePath, options) + await unlockWorktreePreparationAtPath(lockPath, expectedLockReason, options.signal) +} + +/** A move preserves the linked administration directory already verified by finalization. */ +export async function unlockWorktreePreparationAtPath( + lockPath: string, + expectedLockReason: string, + signal?: AbortSignal +): Promise { + await verifyWorktreePreparationLockAtPath(lockPath, expectedLockReason, signal) + await unlink(lockPath).catch((error: unknown) => { + throw new WorktreePreparationLockOwnershipError(error) + }) +} + +export async function verifyWorktreePreparationLock( + worktreePath: string, + expectedLockReason: string, + options: GitWorktreeExecOptions +): Promise { + const lockPath = await readPreparationLockPath(worktreePath, options) + await verifyWorktreePreparationLockAtPath(lockPath, expectedLockReason, options.signal) + return lockPath +} + +export async function verifyWorktreePreparationLockAtPath( + lockPath: string, + expectedLockReason: string, + signal?: AbortSignal +): Promise { + const lockReason = await readFile(lockPath, 'utf8').catch((error: unknown) => { + throw new WorktreePreparationLockOwnershipError(error) + }) + if (lockReason !== `${expectedLockReason}\n`) { + throw new WorktreePreparationLockOwnershipError() + } + signal?.throwIfAborted() +} diff --git a/src/main/git/worktree-preparation-tip-refresh-real-git.test.ts b/src/main/git/worktree-preparation-tip-refresh-real-git.test.ts new file mode 100644 index 00000000000..a1bc9082f57 --- /dev/null +++ b/src/main/git/worktree-preparation-tip-refresh-real-git.test.ts @@ -0,0 +1,156 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' +import * as runner from './runner' +import { + finalizePreparedWorktree, + prepareWorktreeCreateCheckout +} from './worktree-create-preparation' +import { refreshPreparedWorktreeTip } from './worktree-preparation-tip-refresh' +import { + _resetPreparationPoolForTests, + listPreparations, + releasePreparationClaim, + startPreparation, + takePreparation +} from '../worktree-create-preparation-pool' + +const roots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await _resetPreparationPoolForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function git(cwd: string, args: string[]): Promise { + return (await runner.gitExecFileAsync(args, { cwd })).stdout.trim() +} + +async function fixture() { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-fetched-preparation-'))) + roots.push(root) + const repo = join(root, 'repo') + const prepared = join(root, 'prepared') + const final = join(root, 'final') + await git(root, ['init', '--quiet', repo]) + await git(repo, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + await git(repo, ['config', 'user.name', 'Test']) + await git(repo, ['config', 'user.email', 'test@example.com']) + await writeFile(join(repo, 'version.txt'), 'original\n') + await git(repo, ['add', 'version.txt']) + await git(repo, ['commit', '--quiet', '-m', 'initial']) + const hooks = join(root, 'hooks') + await mkdir(hooks) + await writeFile(join(hooks, 'post-checkout'), '#!/bin/sh\necho checkout >> checkout-hook.txt\n', { + mode: 0o755 + }) + await git(repo, ['config', 'core.hooksPath', hooks]) + const base = 'refs/remotes/origin/main' + await git(repo, ['update-ref', base, 'HEAD']) + const reason = createWorktreePreparationLockReason('fetched-tip') + await prepareWorktreeCreateCheckout(repo, prepared, base, reason) + return { root, repo, prepared, final, base, reason } +} + +async function advance(repo: string, base: string, text: string): Promise { + await writeFile(join(repo, 'version.txt'), text) + await git(repo, ['commit', '--quiet', '-am', text.trim()]) + const head = await git(repo, ['rev-parse', 'HEAD']) + await git(repo, ['update-ref', base, head]) + return head +} + +it('moves changed tip work into prefetch while submit still runs exactly one checkout hook', async () => { + const { repo, prepared, final, base, reason } = await fixture() + const target = await advance(repo, base, 'fetched\n') + const spy = vi.spyOn(runner, 'gitExecFileAsync') + await refreshPreparedWorktreeTip(repo, prepared, base, reason) + expect(await readFile(join(prepared, 'version.txt'), 'utf8')).toBe('fetched\n') + expect(await git(prepared, ['rev-parse', 'HEAD'])).toBe(target) + expect(existsSync(join(prepared, 'checkout-hook.txt'))).toBe(false) + expect(spy.mock.calls.filter(([args]) => args.includes('reset'))).toHaveLength(1) + spy.mockClear() + await refreshPreparedWorktreeTip(repo, prepared, base, reason) + await finalizePreparedWorktree(repo, prepared, final, 'feature', base, false, {}, reason) + expect(spy.mock.calls.filter(([args]) => args.includes('reset'))).toHaveLength(0) + expect(await readFile(join(final, 'checkout-hook.txt'), 'utf8')).toBe('checkout\n') + expect(await git(final, ['rev-parse', 'HEAD'])).toBe(target) + expect(await git(final, ['status', '--porcelain', '--untracked-files=no'])).toBe('') +}) + +it('revalidates a newer fetched tip that arrives after the background refresh', async () => { + const { repo, prepared, final, base, reason } = await fixture() + await advance(repo, base, 'first fetch\n') + await refreshPreparedWorktreeTip(repo, prepared, base, reason) + const newest = await advance(repo, base, 'second fetch\n') + await finalizePreparedWorktree(repo, prepared, final, 'feature', base, false, {}, reason) + expect(await git(final, ['rev-parse', 'HEAD'])).toBe(newest) + expect(await readFile(join(final, 'version.txt'), 'utf8')).toBe('second fetch\n') + expect(await readFile(join(final, 'checkout-hook.txt'), 'utf8')).toBe('checkout\n') +}) + +it('preserves a competing lock and files when ownership changes before refresh', async () => { + const { repo, prepared, base, reason } = await fixture() + await advance(repo, base, 'new fetch\n') + const lock = await git(prepared, ['rev-parse', '--git-path', 'locked']) + await writeFile(lock, 'manual owner\n') + await expect(refreshPreparedWorktreeTip(repo, prepared, base, reason)).rejects.toThrow( + 'lock owner changed' + ) + expect(await readFile(lock, 'utf8')).toBe('manual owner\n') + expect(await readFile(join(prepared, 'version.txt'), 'utf8')).toBe('original\n') + expect(await git(repo, ['worktree', 'list', '--porcelain'])).toContain('locked manual owner') +}) + +it('makes a racing claim wait for one fetched-tip reset on an already ready checkout', async () => { + const { root, repo, final, base } = await fixture() + const args = { + repoPath: repo, + workspaceRoot: root, + baseBranch: base, + canonicalBase: base, + options: {} + } + await startPreparation(args) + const entry = listPreparations()[0]! + let settle!: () => void + const beforeMaterialization = new Promise((resolve) => { + settle = resolve + }) + const spy = vi.spyOn(runner, 'gitExecFileAsync') + const refreshing = startPreparation({ ...args, beforeMaterialization }) + const claim = takePreparation(entry) + let finalized = false + const create = entry.ready.then(async () => { + await finalizePreparedWorktree( + repo, + entry.preparedPath, + final, + 'feature', + base, + false, + {}, + entry.lockReason + ) + finalized = true + }) + try { + await Promise.resolve() + expect(finalized).toBe(false) + expect(spy.mock.calls.filter(([argv]) => argv.includes('reset'))).toHaveLength(0) + const target = await advance(repo, base, 'fetched ready tip\n') + expect(finalized).toBe(false) + settle() + await Promise.all([refreshing, create]) + expect(spy.mock.calls.filter(([argv]) => argv.includes('reset'))).toHaveLength(1) + expect(await git(final, ['rev-parse', 'HEAD'])).toBe(target) + expect(await readFile(join(final, 'version.txt'), 'utf8')).toBe('fetched ready tip\n') + expect(await readFile(join(final, 'checkout-hook.txt'), 'utf8')).toBe('checkout\n') + } finally { + settle() + releasePreparationClaim(claim) + } +}) diff --git a/src/main/git/worktree-preparation-tip-refresh.test.ts b/src/main/git/worktree-preparation-tip-refresh.test.ts new file mode 100644 index 00000000000..0e377e1bc77 --- /dev/null +++ b/src/main/git/worktree-preparation-tip-refresh.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + git: vi.fn(), + verify: vi.fn(), + verifyAt: vi.fn(), + mutation: vi.fn(), + invalidation: vi.fn() +})) +vi.mock('./runner', () => ({ gitExecFileAsync: mocks.git })) +vi.mock('./worktree', () => ({ notifyPreparedWorktreeMutation: mocks.mutation })) +vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: mocks.invalidation })) +vi.mock('./local-repo-ref-maintenance', () => ({ + withRepoRefMaintenancePaused: (_reason: string, run: () => Promise) => run() +})) +vi.mock('./worktree-preparation-lock', () => ({ + verifyWorktreePreparationLock: mocks.verify, + verifyWorktreePreparationLockAtPath: mocks.verifyAt +})) + +import { refreshPreparedWorktreeTip } from './worktree-preparation-tip-refresh' + +const OLD = 'a'.repeat(40) +const NEW = 'b'.repeat(40) +const BASE = 'refs/remotes/origin/main' + +beforeEach(() => { + mocks.git.mockReset().mockImplementation(async (args: string[], options: { cwd?: string }) => ({ + stdout: args[0] === 'rev-parse' && options.cwd === '/repo' ? `${NEW}\n` : `${OLD}\n` + })) + mocks.verify.mockReset().mockResolvedValue('/repo/.git/worktrees/prepared/locked') + mocks.verifyAt.mockReset().mockImplementation(async (_lock, _reason, signal?: AbortSignal) => { + signal?.throwIfAborted() + }) + mocks.mutation.mockReset() + mocks.invalidation.mockReset().mockImplementation((run: () => Promise) => run()) +}) + +describe('prepared checkout fetched tip materialization', () => { + it('does no index or file mutation when the fetched tip is unchanged', async () => { + mocks.git.mockResolvedValue({ stdout: `${OLD}\n` }) + await refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner') + expect(mocks.git.mock.calls.map(([args]) => args)).toEqual([ + ['rev-parse', '--verify', `${BASE}^{commit}`], + ['rev-parse', '--verify', 'HEAD'] + ]) + expect(mocks.invalidation).not.toHaveBeenCalled() + expect(mocks.mutation).not.toHaveBeenCalled() + }) + + it('resets only to the fetched commit on the owning WSL host without checkout hooks', async () => { + const signal = new AbortController().signal + await refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner', { + wslDistro: 'Ubuntu', + admissionTier: 'status', + signal + }) + expect(mocks.git).toHaveBeenLastCalledWith( + ['reset', '--hard', NEW], + expect.objectContaining({ + cwd: '/prepared', + wslDistro: 'Ubuntu', + admissionTier: 'status', + signal + }) + ) + expect(mocks.git.mock.calls.some(([args]) => args.includes('checkout'))).toBe(false) + expect(mocks.verifyAt).toHaveBeenCalledTimes(2) + expect(mocks.mutation).toHaveBeenCalledOnce() + }) + + it('stops before any probe when the stored lock belongs to another owner', async () => { + mocks.verify.mockRejectedValueOnce(new Error('ownership lost')) + await expect(refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner')).rejects.toThrow( + 'ownership lost' + ) + expect(mocks.git).not.toHaveBeenCalled() + }) + + it('rechecks ownership after both reads before touching files', async () => { + mocks.verifyAt.mockRejectedValueOnce(new Error('ownership changed during probes')) + await expect(refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner')).rejects.toThrow( + 'ownership changed' + ) + expect(mocks.git).toHaveBeenCalledTimes(2) + expect(mocks.invalidation).not.toHaveBeenCalled() + }) + + it('settles both probes before reporting a failed ref read', async () => { + let release!: () => void + mocks.git + .mockRejectedValueOnce(new Error('base unavailable')) + .mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve({ stdout: OLD }))) + ) + let settled = false + const refresh = refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner').finally(() => { + settled = true + }) + const assertion = expect(refresh).rejects.toThrow('base unavailable') + await vi.waitFor(() => expect(mocks.git).toHaveBeenCalledTimes(2)) + expect(settled).toBe(false) + release() + await assertion + expect(mocks.invalidation).not.toHaveBeenCalled() + }) + + it('honors cancellation between probes and materialization', async () => { + const controller = new AbortController() + mocks.git.mockImplementation(async (_args: string[], options: { cwd?: string }) => { + controller.abort() + return { stdout: options.cwd === '/repo' ? NEW : OLD } + }) + await expect( + refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner', { signal: controller.signal }) + ).rejects.toThrow() + expect(mocks.git).toHaveBeenCalledTimes(2) + expect(mocks.invalidation).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/git/worktree-preparation-tip-refresh.ts b/src/main/git/worktree-preparation-tip-refresh.ts new file mode 100644 index 00000000000..fd8bde49aeb --- /dev/null +++ b/src/main/git/worktree-preparation-tip-refresh.ts @@ -0,0 +1,60 @@ +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' +import { gitExecFileAsync } from './runner' +import { runWithGitReadCacheInvalidation } from './status' +import { notifyPreparedWorktreeMutation } from './worktree' +import { + gitExecOptions, + resolveWorktreeAddTimeoutMs, + type GitWorktreeExecOptions +} from './worktree-operation-options' +import { + verifyWorktreePreparationLock, + verifyWorktreePreparationLockAtPath +} from './worktree-preparation-lock' + +export async function refreshPreparedWorktreeTip( + repoPath: string, + preparedPath: string, + canonicalBase: string, + lockReason: string, + options: GitWorktreeExecOptions = {} +): Promise { + const refreshOptions = { ...options, timeout: options.timeout ?? resolveWorktreeAddTimeoutMs() } + await withRepoRefMaintenancePaused('worktree-prepare', async () => { + const lockPath = await verifyWorktreePreparationLock(preparedPath, lockReason, refreshOptions) + const [target, prepared] = await Promise.allSettled([ + gitExecFileAsync( + ['rev-parse', '--verify', `${canonicalBase}^{commit}`], + gitExecOptions(repoPath, refreshOptions) + ), + gitExecFileAsync( + ['rev-parse', '--verify', 'HEAD'], + gitExecOptions(preparedPath, refreshOptions) + ) + ]) + if (target.status === 'rejected') { + throw target.reason + } + if (prepared.status === 'rejected') { + throw prepared.reason + } + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, refreshOptions.signal) + const targetHead = target.value.stdout.trim() + if (prepared.value.stdout.trim() === targetHead) { + return + } + try { + // Reset preserves detached HEAD and does not run post-checkout hooks before submit. + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync( + [...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead], + gitExecOptions(preparedPath, refreshOptions) + ) + ) + await verifyWorktreePreparationLockAtPath(lockPath, lockReason, refreshOptions.signal) + } finally { + notifyPreparedWorktreeMutation(repoPath) + } + }) +} diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index e52668e1ce9..aba55bee6e5 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -275,44 +275,53 @@ describe('registerAppHandlers', () => { expect(appExitMock).not.toHaveBeenCalled() }) - it('returns the selected macOS input mode before the keyboard layout fallback', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - spawnMock.mockImplementation(() => - createFakeSpawnChild({ - stdout: JSON.stringify([ - { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' }, - { - 'Bundle ID': 'com.apple.inputmethod.SCIM', - 'Input Mode': 'com.apple.inputmethod.SCIM.ITABC', - InputSourceKind: 'Input Mode' - } - ]) - }) - ) - registerAppHandlers({} as never) + it.each([true, false])( + 'prioritizes the selected input mode regardless of record order (%s)', + async (modeLast) => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + const inputMode = { + 'Bundle ID': 'com.apple.inputmethod.SCIM', + 'Input Mode': 'com.apple.inputmethod.SCIM.ITABC', + InputSourceKind: 'Input Mode' + } + const keyboardLayout = { + InputSourceKind: 'Keyboard Layout', + 'KeyboardLayout Name': 'ABC', + 'KeyboardLayout ID': 252 + } + spawnMock.mockImplementation(() => + createFakeSpawnChild({ + stdout: JSON.stringify([ + { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' }, + ...(modeLast ? [keyboardLayout, inputMode] : [inputMode, keyboardLayout]) + ]) + }) + ) + registerAppHandlers({} as never) - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.inputmethod.SCIM.ITABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(1) - // Why: macOS 15's `plutil -extract json` aborts on the input-source - // array, so the probe reads live cfprefsd via `defaults export` and dodges - // the bug with an xml1 extract before converting the clean subtree to JSON. - // Pin the exact pipeline (absolute paths, stdin markers) so dropping any - // stage silently regressing CJK detection to the fallback fails the test. - expect(spawnMock).toHaveBeenCalledWith( - '/bin/sh', - [ - '-c', - '/usr/bin/defaults export com.apple.HIToolbox - | ' + - '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' + - '/usr/bin/plutil -convert json -o - -' - ], - expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) - ) - }) + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( + 'com.apple.inputmethod.SCIM.ITABC' + ) + expect(spawnMock).toHaveBeenCalledTimes(1) + // Why: macOS 15's `plutil -extract json` aborts on the input-source + // array, so the probe reads live cfprefsd via `defaults export` and dodges + // the bug with an xml1 extract before converting the clean subtree to JSON. + // Pin the exact pipeline (absolute paths, stdin markers) so dropping any + // stage silently regressing CJK detection to the fallback fails the test. + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + [ + '-c', + '/usr/bin/defaults export com.apple.HIToolbox - | ' + + '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' + + '/usr/bin/plutil -convert json -o - -' + ], + expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) + ) + } + ) - it('falls back to the keyboard layout when no keyboard input mode is selected', async () => { + it('reads the layout ID only after a selected keyboard layout without a bundle ID is proved', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) spawnMock .mockImplementationOnce(() => @@ -321,6 +330,11 @@ describe('registerAppHandlers', () => { { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' + }, + { + InputSourceKind: 'Keyboard Layout', + 'KeyboardLayout Name': 'ABC', + 'KeyboardLayout ID': 252 } ]) }) @@ -339,42 +353,34 @@ describe('registerAppHandlers', () => { ) }) - it('falls back to the keyboard layout when the selected input source probe exits non-zero', async () => { + it.each([ + { name: 'nonzero exit', result: { code: 1 } }, + { name: 'spawn failure', result: { error: new Error('spawn ENOENT') } }, + { name: 'invalid JSON', result: { stdout: '{' } }, + { name: 'non-array JSON', result: { stdout: '{}' } }, + { name: 'empty records', result: { stdout: '[]' } }, + { name: 'unknown record', result: { stdout: '[{"InputSourceKind":"Unknown"}]' } }, + { + name: 'unidentified input mode', + result: { stdout: '[{"InputSourceKind":"Keyboard Layout"},{"InputSourceKind":"Input Mode"}]' } + }, + { + name: 'non-keyboard record', + result: { + stdout: + '[{"InputSourceKind":"Non Keyboard Input Method","Bundle ID":"com.apple.PressAndHold"}]' + } + } + ])('does not infer the backing layout after $name', async ({ result }) => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - // Why: reproduces macOS 15's `plutil` abort — the pipeline exits non-zero, so - // the probe rejects on the `close` branch and the handler falls back. - spawnMock - .mockImplementationOnce(() => createFakeSpawnChild({ code: 1 })) - .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) + spawnMock.mockImplementation(() => createFakeSpawnChild(result)) registerAppHandlers({} as never) - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.keylayout.ABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(2) - expect(spawnMock).toHaveBeenLastCalledWith( - '/usr/bin/defaults', - ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], - expect.objectContaining({ detached: true }) - ) + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBeNull() + expect(spawnMock).toHaveBeenCalledTimes(1) }) - it('falls back to the keyboard layout when the selected input source probe fails to spawn', async () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - // Why: a spawn-level failure (ENOENT/EACCES) emits 'error'; the handler must - // still fall back rather than reject out of the IPC call. - spawnMock - .mockImplementationOnce(() => createFakeSpawnChild({ error: new Error('spawn ENOENT') })) - .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) - registerAppHandlers({} as never) - - await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( - 'com.apple.keylayout.ABC' - ) - expect(spawnMock).toHaveBeenCalledTimes(2) - }) - - it('falls back when macOS keyboard input source probes never report completion', async () => { + it('returns unknown and cleans up when the selected-source probe times out', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true })) registerAppHandlers({} as never) @@ -391,9 +397,8 @@ describe('registerAppHandlers', () => { expect(settled).toBe(true) await expect(resultPromise).resolves.toBeNull() - // Why: both wedged probes get a process-group SIGKILL (negative pid) so the - // shell and any orphaned `defaults`/`plutil` stages are reaped on timeout. - expect(processKillSpy).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(processKillSpy).toHaveBeenCalledTimes(1) expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL') }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 17b7663e11b..ea74a240c38 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -183,7 +183,9 @@ function readCommandStdout( }) } -function readSelectedInputSourceIdFromJson(stdout: string): string | null { +type SelectedKeyboardInputSource = { kind: 'inputSource'; id: string } | { kind: 'keyboardLayout' } + +function readSelectedInputSourceFromJson(stdout: string): SelectedKeyboardInputSource | null { let records: unknown try { records = JSON.parse(stdout) @@ -194,54 +196,62 @@ function readSelectedInputSourceIdFromJson(stdout: string): string | null { return null } + let hasSelectedKeyboardLayout = false for (const record of records.slice().toReversed()) { if (!record || typeof record !== 'object') { continue } - const fields = record as Record - const kind = typeof fields.InputSourceKind === 'string' ? fields.InputSourceKind : '' - if (kind.toLowerCase().includes('non keyboard')) { + const kind = + 'InputSourceKind' in record && typeof record.InputSourceKind === 'string' + ? record.InputSourceKind.trim().toLowerCase() + : '' + if (kind === 'keyboard layout') { + hasSelectedKeyboardLayout = true continue } - const inputMode = fields['Input Mode'] - if (typeof inputMode === 'string' && inputMode.trim()) { - return inputMode.trim() + if (kind.includes('non keyboard')) { + continue } - const bundleId = fields['Bundle ID'] - if (typeof bundleId === 'string' && bundleId.trim()) { - return bundleId.trim() + if (kind !== 'input mode' && kind !== 'keyboard input method') { + return null } + const inputMode = 'Input Mode' in record ? record['Input Mode'] : undefined + const bundleId = 'Bundle ID' in record ? record['Bundle ID'] : undefined + const id = typeof inputMode === 'string' && inputMode.trim() ? inputMode : bundleId + if (typeof id === 'string' && id.trim()) { + return { kind: 'inputSource', id: id.trim() } + } + return null } - return null + return hasSelectedKeyboardLayout ? { kind: 'keyboardLayout' } : null } -async function readSelectedKeyboardInputSourceId(): Promise { +async function readSelectedKeyboardInputSource(): Promise { try { const stdout = await readCommandStdout( '/bin/sh', ['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND], 'Selected keyboard input source probe timed out' ) - return readSelectedInputSourceIdFromJson(stdout) + return readSelectedInputSourceFromJson(stdout) } catch { return null } } -function readKeyboardLayoutInputSourceId(): Promise { - return readCommandStdout( - '/usr/bin/defaults', - ['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'], - 'Keyboard layout input source probe timed out' - ) -} - async function readKeyboardInputSourceId(): Promise { - const selectedInputSourceId = await readSelectedKeyboardInputSourceId() - if (selectedInputSourceId) { - return selectedInputSourceId + const selectedInputSource = await readSelectedKeyboardInputSource() + if (selectedInputSource?.kind === 'inputSource') { + return selectedInputSource.id } - return readKeyboardLayoutInputSourceId() + // An IME can use ABC underneath; the backing layout alone cannot identify the selected source. + return selectedInputSource?.kind === 'keyboardLayout' + ? readCommandStdout( + '/usr/bin/defaults', + ['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'], + 'Keyboard layout input source probe timed out' + ) + : null } export function registerAppHandlers(store: Store, options: RegisterAppHandlersOptions = {}): void { @@ -270,7 +280,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ipcMain.handle('pwsh:isAvailable', (): Promise => isPwshAvailableAsync()) ipcMain.handle('gitBash:isAvailable', (): boolean => isGitBashAvailable()) - // Why: renderer layout fingerprint tags ABC/CJK-Roman as 'us', breaking Option+letter (#1205); HIToolbox prefs override it. + // The selected IME identity must win over its US-shaped backing keyboard layout. ipcMain.handle('app:getKeyboardInputSourceId', async (): Promise => { if (process.platform !== 'darwin') { return null @@ -281,7 +291,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp const trimmed = stdout?.trim() ?? '' return trimmed.length > 0 ? trimmed : null } catch { - // Why: probe can fail (missing keys on first boot, sandbox) — treat as "no signal" and fall back to the fingerprint. + // A failed probe must not promote an IME's backing layout into an Alt default. return null } }) diff --git a/src/main/ipc/created-worktree-reconciliation.test.ts b/src/main/ipc/created-worktree-reconciliation.test.ts index b62088ef105..8d72742920e 100644 --- a/src/main/ipc/created-worktree-reconciliation.test.ts +++ b/src/main/ipc/created-worktree-reconciliation.test.ts @@ -109,25 +109,26 @@ describe('resolveCreatedWorktree', () => { await expect( resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature') ).resolves.toEqual({ created: CREATED, worktrees: [MAIN, CREATED], listingComplete: true }) - expect(describeCreatedWorktree).not.toHaveBeenCalled() + expect(describeCreatedWorktree).toHaveBeenCalledOnce() }) - it('completes the create from the direct read when the listing fails', async () => { - vi.mocked(listWorktreesSharedStrict).mockRejectedValue(new Error('git timed out.')) + it('verifies only the new checkout without listing or probing every existing worktree', async () => { vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED) await expect( resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature') ).resolves.toEqual({ created: CREATED, worktrees: [], listingComplete: false }) + expect(describeCreatedWorktree).toHaveBeenCalledOnce() + expect(listWorktreesSharedStrict).not.toHaveBeenCalled() }) - it('completes the create from the direct read when the listing omits the row', async () => { - vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN]) - vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED) + it('uses the whole listing when the direct checkout witness cannot be read', async () => { + vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN, CREATED]) + vi.mocked(describeCreatedWorktree).mockRejectedValue(new Error('rev-parse exploded')) await expect( resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature') - ).resolves.toMatchObject({ created: CREATED, listingComplete: false }) + ).resolves.toEqual({ created: CREATED, worktrees: [MAIN, CREATED], listingComplete: true }) }) it("surfaces the listing's own failure rather than an opaque message", async () => { @@ -186,35 +187,50 @@ describe('resolveCreatedWorktree', () => { }) }) - it('charges the recovery what the listing left of the budget, not a fresh one', async () => { - vi.mocked(listWorktreesSharedStrict).mockImplementation(async () => { + it('charges the listing fallback what the direct read left of the budget', async () => { + vi.mocked(describeCreatedWorktree).mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 60)) - throw new Error('git worktree list timed out.') + return undefined }) - vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED) + vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED]) await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature') - const options = vi.mocked(describeCreatedWorktree).mock.lastCall?.[3] + const options = vi.mocked(listWorktreesSharedStrict).mock.lastCall?.[1] expect(options?.timeout).toBeGreaterThanOrEqual(5_000) expect(options?.timeout).toBeLessThan(30_000) }) it("keeps the caller's own deadline instead of the shared budget", async () => { - vi.mocked(listWorktreesSharedStrict).mockRejectedValue(new Error('git worktree list failed.')) - vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED) + vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED]) await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature', { timeout: 1_234 }) expect(vi.mocked(describeCreatedWorktree).mock.lastCall?.[3]).toMatchObject({ timeout: 1_234 }) + expect(vi.mocked(listWorktreesSharedStrict).mock.lastCall?.[1]).toMatchObject({ + timeout: 1_234 + }) }) - it('forwards exec options only when the caller supplied them', async () => { + it('forwards execution-host options to both checkout verification and the fallback', async () => { vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED]) await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature') - expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith('/repo') + expect(describeCreatedWorktree).toHaveBeenLastCalledWith( + '/repo', + '/workspaces/feature', + 'feature' + ) await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature', { wslDistro: 'Ubuntu' }) - expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith('/repo', { wslDistro: 'Ubuntu' }) + expect(describeCreatedWorktree).toHaveBeenLastCalledWith( + '/repo', + '/workspaces/feature', + 'feature', + { wslDistro: 'Ubuntu' } + ) + expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith( + '/repo', + expect.objectContaining({ wslDistro: 'Ubuntu' }) + ) }) }) diff --git a/src/main/ipc/created-worktree-reconciliation.ts b/src/main/ipc/created-worktree-reconciliation.ts index ac5b79954c2..3369ae6f646 100644 --- a/src/main/ipc/created-worktree-reconciliation.ts +++ b/src/main/ipc/created-worktree-reconciliation.ts @@ -23,7 +23,7 @@ export function findCreatedWorktree export type CreatedWorktreeResolution = { created: GitWorktreeInfo - /** Rows `git worktree list` returned; empty when only the direct read found the worktree. */ + /** Rows `git worktree list` returned; empty when the direct read found the worktree. */ worktrees: readonly GitWorktreeInfo[] /** Whether `worktrees` is the repo's whole listing, and so usable as its authorized-root set. */ listingComplete: boolean @@ -36,16 +36,10 @@ export function createdWorktreeNotFoundError(worktreePath: string, branchName: s ) } -/** - * Find the row for a worktree `git worktree add` just created, preferring the repo listing and - * falling back to asking Git about the worktree itself. - * - * Why the fallback: the listing was the only witness the old code had, so any Git-level listing - * failure failed a create whose worktree and branch were already on disk, orphaning both (#16520). - */ -/** A listing that burned the whole budget still leaves the direct read a chance to answer. */ -const MIN_CREATED_WORKTREE_RECOVERY_MS = 5_000 +/** A failed direct read still leaves the listing a chance to verify the create. */ +const MIN_CREATED_WORKTREE_LIST_FALLBACK_MS = 5_000 +/** Verify the new checkout directly; listing every existing worktree is only a fallback. */ export async function resolveCreatedWorktree( repoPath: string, worktreePath: string, @@ -53,11 +47,28 @@ export async function resolveCreatedWorktree( options?: GitWorktreeExecOptions ): Promise { const startedAt = Date.now() + let directReadError: Error | undefined + try { + const created = options + ? await describeCreatedWorktree(repoPath, worktreePath, branchName, options) + : await describeCreatedWorktree(repoPath, worktreePath, branchName) + if (created) { + return { created, worktrees: [], listingComplete: false } + } + } catch (err) { + directReadError = err instanceof Error ? err : new Error(String(err)) + } + + const remainingMs = Math.max( + (options?.timeout ?? WORKTREE_LIST_TIMEOUT_MS) - (Date.now() - startedAt), + MIN_CREATED_WORKTREE_LIST_FALLBACK_MS + ) let listingError: Error | undefined try { - const worktrees = options - ? await listWorktreesSharedStrict(repoPath, options) - : await listWorktreesSharedStrict(repoPath) + const worktrees = await listWorktreesSharedStrict(repoPath, { + ...options, + timeout: options?.timeout ?? remainingMs + }) const created = findCreatedWorktree(worktrees, worktreePath, branchName) if (created) { return { created, worktrees, listingComplete: true } @@ -65,39 +76,18 @@ export async function resolveCreatedWorktree( } catch (err) { listingError = err instanceof Error ? err : new Error(String(err)) } - - try { - // One budget for verifying the create, not one per attempt: a hung Git already spent the - // listing's deadline, and charging the recovery a fresh one doubles the wait before the error. - const remainingMs = Math.max( - WORKTREE_LIST_TIMEOUT_MS - (Date.now() - startedAt), - MIN_CREATED_WORKTREE_RECOVERY_MS - ) - const described = await describeCreatedWorktree(repoPath, worktreePath, branchName, { - ...options, - timeout: options?.timeout ?? remainingMs - }) - if (described) { - return { created: described, worktrees: [], listingComplete: false } - } - } catch (err) { - if (listingError) { - // The listing's failure stays the thrown one, but the recovery's reason -- often - // `repo common dir unverifiable: ...` -- would otherwise vanish from the record entirely. + if (listingError) { + if (directReadError) { console.warn('[worktrees:create] created-worktree recovery also failed', { - err, + err: directReadError, worktreePath }) - throw listingError } - // The listing simply omitted the row, so the direct read holds the only actionable failure. - const notFound = createdWorktreeNotFoundError(worktreePath, branchName) - throw new Error(`${notFound.message}: ${err instanceof Error ? err.message : String(err)}`, { - cause: err - }) - } - if (listingError) { throw listingError } - throw createdWorktreeNotFoundError(worktreePath, branchName) + const notFound = createdWorktreeNotFoundError(worktreePath, branchName) + if (directReadError) { + throw new Error(`${notFound.message}: ${directReadError.message}`, { cause: directReadError }) + } + throw notFound } diff --git a/src/main/ipc/filesystem-import-ssh-remote-existence.ts b/src/main/ipc/filesystem-import-ssh-remote-existence.ts deleted file mode 100644 index e853f291f80..00000000000 --- a/src/main/ipc/filesystem-import-ssh-remote-existence.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { IFilesystemProvider } from '../providers/types' - -/** Whether a remote path exists; only a definite "missing" answer reads as false. */ -export async function remotePathExists( - provider: IFilesystemProvider, - remotePath: string -): Promise { - try { - await provider.stat(remotePath) - return true - } catch (error) { - if (isRemoteMissingError(error)) { - return false - } - throw error - } -} - -function isRemoteMissingError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - return ( - ('code' in error && error.code === 'ENOENT') || - /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( - error.message - ) - ) -} diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index 39b2350bb44..9268c6ece24 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -8,8 +8,6 @@ import type { FileUploadSession, IFilesystemProvider } from '../providers/types' import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { runSshProviderContinuation } from '../ssh/ssh-provider-continuations' -import { remotePathExists } from './filesystem-import-ssh-remote-existence' import { captureLocalUploadRoot, preScanSshImportDirectory, @@ -19,12 +17,6 @@ import { // Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so // system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows. export async function importExternalPathsSsh( - ...args: Parameters -): Promise<{ results: ImportItemResult[] }> { - return runSshProviderContinuation(args[2], () => importExternalPathsSshTracked(...args)) -} - -async function importExternalPathsSshTracked( sourcePaths: string[], destDir: string, connectionId: string, @@ -277,3 +269,31 @@ async function ensureDropStagingDir( assertCurrent?.() await provider.createDir(destDir) } + +async function remotePathExists( + provider: IFilesystemProvider, + remotePath: string +): Promise { + try { + await provider.stat(remotePath) + return true + } catch (error) { + if (isRemoteMissingError(error)) { + return false + } + throw error + } +} + +function isRemoteMissingError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const code = (error as NodeJS.ErrnoException).code + return ( + code === 'ENOENT' || + /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( + error.message + ) + ) +} diff --git a/src/main/ipc/filesystem-provider-continuations.test.ts b/src/main/ipc/filesystem-provider-continuations.test.ts deleted file mode 100644 index c585d7ac6f6..00000000000 --- a/src/main/ipc/filesystem-provider-continuations.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { IFilesystemProvider } from '../providers/types' -import type { FilesystemHandlerContext } from './filesystem/filesystem-handler-context' -import { hasSshProviderContinuations } from '../ssh/ssh-provider-continuations' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { - advanceSshConnectionGeneration, - resetSshConnectionGenerations -} from '../ssh/ssh-connection-generation' -import { importExternalPathsSsh } from './filesystem-import-ssh' -import { captureLocalUploadRoot, uploadSshImportDirectory } from './filesystem-import-ssh-directory' -import { registerFilesystemWriteHandlers } from './filesystem/filesystem-write-handlers' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - lstat: vi.fn(), - writeFile: vi.fn(), - trashItem: vi.fn(), - resolveAuthorizedPath: vi.fn(), - tryDeleteWslUncPath: vi.fn() -})) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle }, shell: mocks })) -vi.mock('node:fs/promises', () => ({ lstat: mocks.lstat, writeFile: mocks.writeFile })) -vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: vi.fn(), - resolveAuthorizedPath: mocks.resolveAuthorizedPath -})) -vi.mock('./filesystem-mutations', () => ({ registerFilesystemMutationHandlers: vi.fn() })) -vi.mock('../wsl-unc-delete', () => ({ tryDeleteWslUncPath: mocks.tryDeleteWslUncPath })) -vi.mock('./ssh', () => ({ - getSshConnectionManager: () => ({ - getConnection: () => ({ getState: () => ({ status: 'connected' }) }) - }) -})) -vi.mock('./filesystem-import-ssh-directory', () => ({ - captureLocalUploadRoot: vi.fn(), - preScanSshImportDirectory: vi.fn(), - uploadSshImportDirectory: vi.fn() -})) - -const targetId = 'filesystem-continuation-target' -// The IPC payload fields these handlers read; each test passes the subset its channel needs. -type FilesystemHandlerArgs = Record -const handlers = new Map< - string, - (_event: unknown, args: FilesystemHandlerArgs) => Promise ->() -const fileStat = { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } - -beforeEach(() => { - vi.resetAllMocks() - resetSshConnectionGenerations() - unregisterSshFilesystemProvider(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(false) - handlers.clear() - mocks.handle.mockImplementation((channel, handler) => handlers.set(channel, handler)) - mocks.lstat.mockResolvedValue(fileStat) - mocks.resolveAuthorizedPath.mockImplementation(async (path) => path) - mocks.tryDeleteWslUncPath.mockResolvedValue(false) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write handlers read no store field on the SSH branch under test. - registerFilesystemWriteHandlers({ store: {} } as FilesystemHandlerContext) -}) - -describe('SSH filesystem mutation continuation settlement', () => { - it.each(['fs:writeFile', 'fs:deletePath'])( - '%s retains removed providers until settlement', - async (channel) => { - const pending = Promise.withResolvers() - const operation = vi.fn(() => { - expect(hasSshProviderContinuations(targetId)).toBe(true) - return pending.promise - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - writeFile: operation, - deletePath: operation - } as unknown as IFilesystemProvider) - const result = handlers.get(channel)!(null, { - connectionId: targetId, - expectedSshTargetId: targetId, - expectedSshConnectionGeneration: 0, - filePath: '/remote/file', - content: 'text', - targetPath: '/remote/file', - recursive: true - }) - unregisterSshFilesystemProvider(targetId) - advanceSshConnectionGeneration(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(hasSshProviderContinuations('other-target')).toBe(false) - pending.resolve() - await result - expect(hasSshProviderContinuations(targetId)).toBe(false) - expect(operation).toHaveBeenCalledWith( - ...(channel === 'fs:writeFile' ? ['/remote/file', 'text'] : ['/remote/file', true]) - ) - } - ) - - it.each(['fs:writeFile', 'fs:deletePath'])( - '%s releases after provider rejection', - async (channel) => { - const pending = Promise.withResolvers() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - writeFile: () => pending.promise, - deletePath: () => pending.promise - } as unknown as IFilesystemProvider) - const result = handlers.get(channel)!(null, { - connectionId: targetId, - expectedSshTargetId: targetId, - expectedSshConnectionGeneration: 0, - filePath: '/remote/file', - targetPath: '/remote/file', - content: '' - }) - const failure = expect(result).rejects.toThrow('late failure') - expect(hasSshProviderContinuations(targetId)).toBe(true) - pending.reject(new Error('late failure')) - await failure - expect(hasSshProviderContinuations(targetId)).toBe(false) - } - ) - - it.each(['fs:writeFile', 'fs:deletePath'])( - '%s leaves local handling untracked', - async (channel) => { - const pending = Promise.withResolvers() - mocks.writeFile.mockReturnValue(pending.promise) - mocks.trashItem.mockReturnValue(pending.promise) - const result = handlers.get(channel)!(null, { - filePath: '/local/file', - targetPath: '/local/file', - content: 'text' - }) - expect(hasSshProviderContinuations(targetId)).toBe(false) - pending.resolve() - await result - expect(channel === 'fs:writeFile' ? mocks.writeFile : mocks.trashItem).toHaveBeenCalled() - expect(hasSshProviderContinuations(targetId)).toBe(false) - } - ) -}) - -describe('complete SSH import continuation settlement', () => { - it('retains a failed directory import through pending rollback', async () => { - const rollback = Promise.withResolvers() - const rollingBack = Promise.withResolvers() - mocks.lstat.mockResolvedValue({ ...fileStat, isDirectory: () => true, isFile: () => false }) - vi.mocked(captureLocalUploadRoot).mockResolvedValue('/source/directory') - vi.mocked(uploadSshImportDirectory).mockRejectedValue(new Error('upload failed')) - const close = vi.fn(() => expect(hasSshProviderContinuations(targetId)).toBe(true)) - const deletePath = vi.fn(() => { - rollingBack.resolve() - return rollback.promise - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - openFileUploadSession: async () => ({ close }), - stat: async () => { - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }, - createDirNoClobber: async () => {}, - deletePath - } as unknown as IFilesystemProvider) - const result = importExternalPathsSsh(['/source/directory'], '/remote', targetId) - await rollingBack.promise - unregisterSshFilesystemProvider(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(close).not.toHaveBeenCalled() - rollback.resolve() - expect((await result).results[0]).toMatchObject({ status: 'failed', reason: 'upload failed' }) - expect(deletePath).toHaveBeenCalledWith('/remote/directory', true) - expect(close).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) - - it('retains tracking across local inspection, provider removal and session close', async () => { - const inspection = Promise.withResolvers() - const inspected = Promise.withResolvers() - const uploaded = Promise.withResolvers() - const upload = Promise.withResolvers() - mocks.lstat.mockImplementation(() => { - inspected.resolve() - return inspection.promise - }) - const close = vi.fn(() => expect(hasSshProviderContinuations(targetId)).toBe(true)) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - openFileUploadSession: async () => ({ - close, - uploadFile: () => { - uploaded.resolve() - return upload.promise - } - }), - stat: async () => { - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - } - } as unknown as IFilesystemProvider) - const result = importExternalPathsSsh(['/source/file'], '/remote', targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - await inspected.promise - unregisterSshFilesystemProvider(targetId) - advanceSshConnectionGeneration(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - inspection.resolve(fileStat) - await uploaded.promise - expect(hasSshProviderContinuations(targetId)).toBe(true) - upload.resolve() - expect((await result).results[0].status).toBe('imported') - expect(close).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) - - it('includes staging preparation and releases on its failure', async () => { - const staging = Promise.withResolvers() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call. - registerSshFilesystemProvider(targetId, { - createDir: () => { - expect(hasSshProviderContinuations(targetId)).toBe(true) - return staging.promise - } - } as unknown as IFilesystemProvider) - const result = importExternalPathsSsh(['/source/file'], '/remote/.orca/drops', targetId, { - ensureDir: true - }) - const failure = expect(result).rejects.toThrow('staging failed') - unregisterSshFilesystemProvider(targetId) - expect(hasSshProviderContinuations(targetId)).toBe(true) - staging.reject(new Error('staging failed')) - await failure - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) -}) diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 0cc5a921b45..07d4de5b56a 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -2,7 +2,6 @@ import { ipcMain, shell } from 'electron' import { lstat, writeFile } from 'node:fs/promises' import type { SshMutationExpectation } from '../../../shared/ssh-types' import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation' -import { runSshProviderContinuation } from '../../ssh/ssh-provider-continuations' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' @@ -27,9 +26,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) - return runSshProviderContinuation(args.connectionId, () => - provider.writeFile(args.filePath, args.content) - ) + return provider.writeFile(args.filePath, args.content) } const filePath = await resolveAuthorizedPath(args.filePath, store) try { @@ -64,9 +61,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) - return runSshProviderContinuation(args.connectionId, () => - provider.deletePath(args.targetPath, args.recursive) - ) + return provider.deletePath(args.targetPath, args.recursive) } // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). const targetPath = await resolveAuthorizedPath(args.targetPath, store, { diff --git a/src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts b/src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts deleted file mode 100644 index cc15058c7f8..00000000000 --- a/src/main/ipc/orcad-runtime-lifecycle-handlers.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - deploy: vi.fn(), - status: vi.fn(), - registerProvisioning: vi.fn() -})) - -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/orcad-runtime-lifecycle', () => ({ - createManagedOrcadEnvironment: mocks.deploy, - getManagedOrcadRuntimeStatus: mocks.status -})) -vi.mock('./orcad-ssh-provisioning-handlers', () => ({ - registerOrcadSshProvisioningHandlers: mocks.registerProvisioning -})) - -const { registerOrcadRuntimeLifecycleHandlers } = await import('./orcad-runtime-lifecycle-handlers') - -function handler(channel: string): (_event: unknown, args: unknown) => unknown { - const registration = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registration) { - throw new Error(`${channel} handler was not registered`) - } - return registration[1] -} - -describe('managed orcad lifecycle IPC', () => { - beforeEach(() => { - vi.clearAllMocks() - registerOrcadRuntimeLifecycleHandlers({ getUserDataPath: () => '/profile' }) - }) - - it('registers only deploy, status and provisioning; maintenance and stop are not exposed', () => { - expect(mocks.handle.mock.calls.map(([channel]) => channel)).toEqual([ - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus' - ]) - expect(mocks.registerProvisioning).toHaveBeenCalledOnce() - }) - - it('trims deploy input and treats only a literal true as force', async () => { - await handler('runtimeEnvironments:deployOrcad')(null, { - name: ' Managed ', - sshTargetId: ' ssh-1 ', - force: 'yes' - }) - expect(mocks.deploy).toHaveBeenCalledWith('/profile', { - name: 'Managed', - sshTargetId: 'ssh-1', - force: false - }) - }) - - it('rejects missing selectors before touching SSH', async () => { - await expect(handler('runtimeEnvironments:deployOrcad')(null, { name: 'x' })).rejects.toThrow( - 'SSH target is required' - ) - expect(() => handler('runtimeEnvironments:getOrcadStatus')(null, undefined)).toThrow( - 'Server is required' - ) - expect(mocks.deploy).not.toHaveBeenCalled() - expect(mocks.status).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/orcad-runtime-lifecycle-handlers.ts b/src/main/ipc/orcad-runtime-lifecycle-handlers.ts deleted file mode 100644 index 4d6e23833b1..00000000000 --- a/src/main/ipc/orcad-runtime-lifecycle-handlers.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ipcMain } from 'electron' -import type { OrcadManagedRuntimeStatus } from '../../shared/orcad-managed-runtime' -import { - createManagedOrcadEnvironment, - getManagedOrcadRuntimeStatus -} from '../ssh/orcad-runtime-lifecycle' -import { registerOrcadSshProvisioningHandlers } from './orcad-ssh-provisioning-handlers' - -export function registerOrcadRuntimeLifecycleHandlers(options: { - getUserDataPath: () => string -}): void { - registerOrcadSshProvisioningHandlers(options.getUserDataPath) - ipcMain.handle( - 'runtimeEnvironments:deployOrcad', - async (_event, args: { name: string; sshTargetId: string; force?: boolean }) => - createManagedOrcadEnvironment(options.getUserDataPath(), { - name: requiredString(args?.name, 'Server name'), - sshTargetId: requiredString(args?.sshTargetId, 'SSH target'), - force: args?.force === true - }) - ) - ipcMain.handle( - 'runtimeEnvironments:getOrcadStatus', - (_event, args: { selector: string }): Promise => - getManagedOrcadRuntimeStatus( - options.getUserDataPath(), - requiredString(args?.selector, 'Server') - ) - ) -} - -export function requiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`${label} is required.`) - } - return value.trim() -} diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts deleted file mode 100644 index ad87a398cc7..00000000000 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - update: vi.fn(), - rollback: vi.fn(), - recover: vi.fn(), - stop: vi.fn(), - cancel: vi.fn(), - retire: vi.fn() -})) - -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/orcad-runtime-lifecycle', () => ({ - updateManagedOrcadEnvironment: mocks.update, - rollbackManagedOrcadEnvironment: mocks.rollback, - recoverManagedOrcadEnvironment: mocks.recover, - stopManagedOrcadEnvironment: mocks.stop, - cancelManagedOrcadStop: mocks.cancel -})) -vi.mock('./runtime-environment-removal-cleanup', () => ({ - retireRemovedRuntimeEnvironment: mocks.retire -})) - -const { registerOrcadRuntimeMaintenanceHandlers } = - await import('./orcad-runtime-maintenance-handlers') - -const invalidateTransport = vi.fn() - -function handler(channel: string): (_event: unknown, args: unknown) => Promise { - const registration = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registration) { - throw new Error(`${channel} handler was not registered`) - } - return registration[1] -} - -describe('managed orcad maintenance IPC', () => { - beforeEach(() => { - vi.clearAllMocks() - registerOrcadRuntimeMaintenanceHandlers({ - getUserDataPath: () => '/profile', - getActiveEnvironmentId: () => 'active-environment', - invalidateTransport - }) - }) - - it('reconnects after an update restarts orcad, but not after a deferral', async () => { - mocks.update.mockResolvedValueOnce({ outcome: 'deferred', code: 'busy' }) - await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: 'yes' }) - expect(mocks.update).toHaveBeenCalledWith('/profile', { selector: 'Managed', force: false }) - expect(invalidateTransport).not.toHaveBeenCalled() - mocks.update.mockResolvedValueOnce({ outcome: 'updated', environment: { id: 'e-1' } }) - await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: true }) - expect(invalidateTransport).toHaveBeenCalledWith('e-1') - }) - - it('reconnects after rollback and after recovery restores a serving slot', async () => { - mocks.rollback.mockResolvedValueOnce({ outcome: 'rolled-back', environment: { id: 'e-1' } }) - await handler('runtimeEnvironments:rollbackOrcad')(null, { selector: 'Managed' }) - mocks.recover.mockResolvedValueOnce({ - outcome: 'recovered', - activeVersion: null, - environment: { id: 'e-1' } - }) - await handler('runtimeEnvironments:recoverOrcad')(null, { selector: 'Managed' }) - expect(invalidateTransport).toHaveBeenCalledTimes(1) - }) - - it('stops with the Active Server guard and the shared removal cleanup', async () => { - mocks.stop.mockResolvedValueOnce({ outcome: 'unlinked' }) - await handler('runtimeEnvironments:stopOrcad')(null, { selector: ' Managed ' }) - const [, args, policy] = mocks.stop.mock.calls[0] ?? [] - expect(args).toEqual({ selector: 'Managed' }) - expect(policy.isActiveEnvironment('active-environment')).toBe(true) - expect(policy.isActiveEnvironment('e-1')).toBe(false) - policy.retireLocalState('e-1') - expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport) - }) - - it('rejects a missing selector before touching SSH', async () => { - await expect(handler('runtimeEnvironments:cancelOrcadStop')(null, {})).rejects.toThrow( - 'Server is required' - ) - expect(mocks.cancel).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.ts deleted file mode 100644 index 0f16da4de07..00000000000 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ipcMain } from 'electron' -import type { - OrcadManagedCancelStopResult, - OrcadManagedDeployResult, - OrcadManagedRecoveryResult, - OrcadManagedRollbackResult, - OrcadManagedStopResult -} from '../../shared/orcad-managed-runtime' -import { - cancelManagedOrcadStop, - recoverManagedOrcadEnvironment, - rollbackManagedOrcadEnvironment, - stopManagedOrcadEnvironment, - updateManagedOrcadEnvironment -} from '../ssh/orcad-runtime-lifecycle' -import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' -import { requiredString } from './orcad-runtime-lifecycle-handlers' - -export function registerOrcadRuntimeMaintenanceHandlers(options: { - getUserDataPath: () => string - getActiveEnvironmentId: () => string | null | undefined - invalidateTransport: (environmentId: string) => Promise | void -}): void { - ipcMain.handle( - 'runtimeEnvironments:updateOrcad', - async ( - _event, - args: { selector: string; force?: boolean } - ): Promise => { - const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server'), - force: args?.force === true - }) - // Why: a restarted orcad drops the old connection; reconnect on the new one. - if (result.outcome === 'updated') { - await options.invalidateTransport(result.environment.id) - } - return result - } - ) - ipcMain.handle( - 'runtimeEnvironments:rollbackOrcad', - async (_event, args: { selector: string }): Promise => { - const result = await rollbackManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - if (result.outcome === 'rolled-back') { - await options.invalidateTransport(result.environment.id) - } - return result - } - ) - ipcMain.handle( - 'runtimeEnvironments:recoverOrcad', - async (_event, args: { selector: string }): Promise => { - const result = await recoverManagedOrcadEnvironment(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - if (result.outcome === 'recovered' && result.activeVersion) { - await options.invalidateTransport(result.environment.id) - } - return result - } - ) - ipcMain.handle( - 'runtimeEnvironments:stopOrcad', - async (_event, args: { selector: string }): Promise => - stopManagedOrcadEnvironment( - options.getUserDataPath(), - { selector: requiredString(args?.selector, 'Server') }, - { - isActiveEnvironment: (environmentId) => - options.getActiveEnvironmentId() === environmentId, - retireLocalState: (environmentId) => - retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport) - } - ) - ) - ipcMain.handle( - 'runtimeEnvironments:cancelOrcadStop', - async (_event, args: { selector: string }): Promise => - cancelManagedOrcadStop(options.getUserDataPath(), { - selector: requiredString(args?.selector, 'Server') - }) - ) -} diff --git a/src/main/ipc/orcad-ssh-provisioning-handlers.test.ts b/src/main/ipc/orcad-ssh-provisioning-handlers.test.ts deleted file mode 100644 index 6d5fa72da34..00000000000 --- a/src/main/ipc/orcad-ssh-provisioning-handlers.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - create: vi.fn(), - resume: vi.fn(), - list: vi.fn() -})) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/orcad-ssh-provisioning', () => ({ - createOrcadSshHost: mocks.create, - resumeOrcadSshHost: mocks.resume, - listPendingOrcadSshProvisioning: mocks.list -})) -import { registerOrcadSshProvisioningHandlers } from './orcad-ssh-provisioning-handlers' - -describe('managed SSH provisioning IPC', () => { - beforeEach(() => vi.clearAllMocks()) - - it('registers typed create, resume and pending discovery without changing legacy SSH channels', async () => { - registerOrcadSshProvisioningHandlers(() => '/active-profile') - const handlers = new Map(mocks.handle.mock.calls.map(([name, handler]) => [name, handler])) - expect([...handlers.keys()]).toEqual([ - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning' - ]) - const request = { requestId: 'request-1', name: 'host', target: { host: 'builder' } } - const pending = { result: { outcome: 'pending', reason: 'unverifiable' } } - mocks.create.mockResolvedValue(pending) - expect(await handlers.get('runtimeEnvironments:createOrcadSshHost')!(null, request)).toBe( - pending - ) - expect(mocks.create).toHaveBeenCalledWith('/active-profile', request) - await handlers.get('runtimeEnvironments:resumeOrcadSshHost')!(null, { requestId: 'request-1' }) - expect(mocks.resume).toHaveBeenCalledWith('/active-profile', 'request-1') - handlers.get('runtimeEnvironments:listPendingOrcadSshProvisioning')!() - expect(mocks.list).toHaveBeenCalledWith('/active-profile') - }) -}) diff --git a/src/main/ipc/orcad-ssh-provisioning-handlers.ts b/src/main/ipc/orcad-ssh-provisioning-handlers.ts deleted file mode 100644 index 31cdaeed6c2..00000000000 --- a/src/main/ipc/orcad-ssh-provisioning-handlers.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ipcMain } from 'electron' -import type { OrcadSshProvisioningRequest } from '../../shared/orcad-ssh-provisioning' -import { - createOrcadSshHost, - listPendingOrcadSshProvisioning, - resumeOrcadSshHost -} from '../ssh/orcad-ssh-provisioning' - -export function registerOrcadSshProvisioningHandlers(getUserDataPath: () => string): void { - ipcMain.handle( - 'runtimeEnvironments:createOrcadSshHost', - (_event, args: OrcadSshProvisioningRequest) => createOrcadSshHost(getUserDataPath(), args) - ) - ipcMain.handle('runtimeEnvironments:resumeOrcadSshHost', (_event, args: { requestId: string }) => - resumeOrcadSshHost(getUserDataPath(), args?.requestId) - ) - ipcMain.handle('runtimeEnvironments:listPendingOrcadSshProvisioning', () => - listPendingOrcadSshProvisioning(getUserDataPath()) - ) -} diff --git a/src/main/ipc/parcel-watcher-child-termination.ts b/src/main/ipc/parcel-watcher-child-termination.ts index e7067dc32d6..fc498410438 100644 --- a/src/main/ipc/parcel-watcher-child-termination.ts +++ b/src/main/ipc/parcel-watcher-child-termination.ts @@ -10,13 +10,6 @@ export const WATCHER_PROCESS_HARD_KILL_DELAY_MS = 5_000 export const WATCHER_PROCESS_EXIT_DEADLINE_MS = RUNTIME_FILE_WATCH_EXIT_DEADLINE_MS const physicalExitPromises = new WeakMap>() -const signalledChildren = new WeakSet() - -/** Sends the graceful signal once; a later awaited termination only escalates and waits. */ -export function signalWatcherChild(child: ChildProcess): void { - signalledChildren.add(child) - child.kill() -} export function registerWatcherChildPhysicalExit(child: ChildProcess): () => void { let resolveExit: () => void = () => undefined @@ -93,10 +86,6 @@ export function terminateWatcherChild(child: ChildProcess): Promise { hardKillTimer.unref?.() const exitDeadlineTimer = setTimeout(() => finish(false), WATCHER_PROCESS_EXIT_DEADLINE_MS) exitDeadlineTimer.unref?.() - if (signalledChildren.has(child)) { - return - } - signalledChildren.add(child) try { child.kill() } catch { @@ -105,10 +94,11 @@ export function terminateWatcherChild(child: ChildProcess): Promise { }) } -export function watcherChildPhysicalExit(child: ChildProcess): Promise { - return child.exitCode !== null || child.signalCode !== null - ? Promise.resolve() - : (physicalExitPromises.get(child) ?? +export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure { + const physicalExit = + child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : (physicalExitPromises.get(child) ?? new Promise((resolve) => { const finish = (): void => { child.removeListener('exit', finish) @@ -118,14 +108,11 @@ export function watcherChildPhysicalExit(child: ChildProcess): Promise { child.once('exit', finish) child.once('close', finish) })) -} - -export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure { return new WatcherProcessFailure( 'file watcher process did not exit after termination deadline', 'supervisor', 'process_unavailable', - watcherChildPhysicalExit(child) + physicalExit ) } @@ -140,12 +127,10 @@ export async function terminateIdleWatcherChild( pendingUnsubscribes: Map, onFinished: (exited: boolean) => void ): Promise { - // Windows directory handles require physical exit, not merely an accepted signal. try { await requireWatcherChildTermination(child) onFinished(true) } catch (error) { - // Idle children retain capacity but cannot double-watch; the owner may remain reusable. onFinished(false) resolvePendingWatcherUnsubscribes( pendingUnsubscribes, diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts index 4633739fe15..229dcdc65e2 100644 --- a/src/main/ipc/parcel-watcher-entry-path.ts +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -4,14 +4,6 @@ import { join } from 'node:path' type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean } -export function watcherProcessEntryExists(entryPath: string): boolean { - if (existsSync(entryPath)) { - return true - } - console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`) - return false -} - // Why the port and not require('electron'): this module is reachable from plain-Node // fork entries, where the literal text require("electron") fails the build guard even // inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer. diff --git a/src/main/ipc/parcel-watcher-owned-children.test.ts b/src/main/ipc/parcel-watcher-owned-children.test.ts deleted file mode 100644 index cbb369cf3e2..00000000000 --- a/src/main/ipc/parcel-watcher-owned-children.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { ChildProcess } from 'node:child_process' -import { afterEach, expect, it, vi } from 'vitest' -import { WatcherOwnedChildren } from './parcel-watcher-owned-children' -import { - registerWatcherChildPhysicalExit, - signalWatcherChild, - WATCHER_PROCESS_EXIT_DEADLINE_MS, - WATCHER_PROCESS_HARD_KILL_DELAY_MS -} from './parcel-watcher-child-termination' - -afterEach(() => vi.useRealTimers()) - -function child() { - const exitState: { exitCode: number | null; signalCode: NodeJS.Signals | null } = { - exitCode: null, - signalCode: null - } - const events = Object.assign(new EventEmitter(), exitState, { kill: vi.fn(() => true) }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: termination reads only the exit fields, kill and events stubbed here. - const process = events as unknown as ChildProcess - const physicalExit = registerWatcherChildPhysicalExit(process) - events.on('exit', physicalExit) - events.on('close', physicalExit) - events.on('error', () => {}) - return { process, events, close: () => events.emit('close') } -} - -it('joins disposal and does not confuse disconnect/error with physical exit', async () => { - const owner = new WatcherOwnedChildren() - const c = child() - owner.track(c.process) - const logical = vi.fn() - const disposed = vi.fn() - const first = owner.disposeAndWait(logical) - expect(owner.disposeAndWait(logical)).toBe(first) - const result = first.then(disposed) - c.events.emit('disconnect') - c.events.emit('error', new Error('spawn or IPC failure')) - await Promise.resolve() - expect(disposed).not.toHaveBeenCalled() - expect(logical).toHaveBeenCalledOnce() - c.close() - await result - expect(disposed).toHaveBeenCalledOnce() -}) - -it('includes children already signaled by synchronous or retired-owner disposal', async () => { - const owner = new WatcherOwnedChildren() - const old = child() - const replacement = child() - owner.track(old.process) - old.process.kill() - owner.track(replacement.process) - const disposed = vi.fn() - const result = owner.disposeAndWait(() => {}).then(disposed) - replacement.close() - await Promise.resolve() - expect(disposed).not.toHaveBeenCalled() - old.close() - await result -}) - -it('retains a child after termination deadline failure and supports explicit retry', async () => { - vi.useFakeTimers() - const owner = new WatcherOwnedChildren() - const c = child() - owner.track(c.process) - const result = owner.disposeAndWait(() => {}).catch((error: unknown) => error) - await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) - expect(await result).toMatchObject({ message: 'watcher_owned_children_shutdown_incomplete' }) - const completed = vi.fn() - const retry = owner.disposeAndWait(() => {}).then(completed) - await Promise.resolve() - expect(completed).not.toHaveBeenCalled() - c.close() - await retry -}) - -it('waits for other children even when logical disposal and one kill fail', async () => { - const owner = new WatcherOwnedChildren() - const failed = child() - const pending = child() - owner.track(failed.process) - owner.track(pending.process) - failed.events.kill.mockImplementationOnce(() => { - throw new Error('kill failed') - }) - const logicalFailure = new Error('logical cleanup failed') - const finished = vi.fn() - const result = owner - .disposeAndWait(() => { - throw logicalFailure - }) - .catch((error: unknown) => { - finished() - return error - }) - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - pending.close() - expect(await result).toMatchObject({ errors: [logicalFailure, expect.any(Error)] }) - const retry = owner.disposeAndWait(() => {}) - failed.close() - await retry -}) - -it('skips a second graceful signal after the supervisor sent one but still escalates', async () => { - vi.useFakeTimers() - const owner = new WatcherOwnedChildren() - const c = child() - owner.track(c.process) - const disposal = owner.disposeAndWait(() => signalWatcherChild(c.process)) - expect(c.events.kill).toHaveBeenCalledTimes(1) - expect(c.events.kill).toHaveBeenCalledWith() - await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_HARD_KILL_DELAY_MS) - expect(c.events.kill).toHaveBeenLastCalledWith('SIGKILL') - c.events.emit('exit', null, 'SIGKILL') - await expect(disposal).resolves.toBeUndefined() -}) diff --git a/src/main/ipc/parcel-watcher-owned-children.ts b/src/main/ipc/parcel-watcher-owned-children.ts deleted file mode 100644 index 7ac044ac906..00000000000 --- a/src/main/ipc/parcel-watcher-owned-children.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ChildProcessHandle } from '../../shared/child-process/process-spec' -import { - watcherChildPhysicalExit, - requireWatcherChildTermination -} from './parcel-watcher-child-termination' - -export class WatcherOwnedChildren { - private readonly children = new Set() - private disposal: Promise | null = null - - track(child: ChildProcessHandle): ChildProcessHandle { - this.children.add(child) - // Reuse launch-owned physical-exit evidence, including close without exit after spawn failure. - void watcherChildPhysicalExit(child).then(() => { - this.children.delete(child) - }) - return child - } - - disposeAndWait(disposeLogicalOwner: () => void): Promise { - if (this.disposal) { - return this.disposal - } - const failures: unknown[] = [] - try { - disposeLogicalOwner() - } catch (error) { - failures.push(error) - } - const cleanup = Promise.allSettled([...this.children].map(requireWatcherChildTermination)) - .then((results) => { - for (const result of results) { - if (result.status === 'rejected') { - failures.push(result.reason) - } - } - if (failures.length > 0) { - throw new AggregateError(failures, 'watcher_owned_children_shutdown_incomplete') - } - }) - .finally(() => { - this.disposal = null - }) - this.disposal = cleanup - return cleanup - } -} diff --git a/src/main/ipc/parcel-watcher-process-dispose.test.ts b/src/main/ipc/parcel-watcher-process-dispose.test.ts deleted file mode 100644 index fd022c16622..00000000000 --- a/src/main/ipc/parcel-watcher-process-dispose.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const { supervisorDispose, poolDispose } = vi.hoisted(() => ({ - supervisorDispose: vi.fn<() => Promise>(), - poolDispose: vi.fn<() => Promise>() -})) - -vi.mock('./parcel-watcher-process-supervisor', () => ({ - WatcherProcessSupervisor: class { - disposeAndWait = supervisorDispose - } -})) -vi.mock('./runtime-watcher-process-pool', () => ({ - RuntimeWatcherProcessPool: class { - disposeAndWait = poolDispose - } -})) - -import { disposeWatcherProcessAndWait } from './parcel-watcher-process' - -describe('disposeWatcherProcessAndWait', () => { - it('waits for both watcher hosts, and reports a failed one only after the other settles', async () => { - let finishPool!: () => void - supervisorDispose.mockRejectedValueOnce(new Error('child still running')) - poolDispose.mockReturnValueOnce( - new Promise((resolve) => { - finishPool = resolve - }) - ) - const settled = vi.fn() - const disposal = disposeWatcherProcessAndWait().catch((error: unknown) => { - settled() - throw error - }) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - finishPool() - await expect(disposal).rejects.toMatchObject({ message: 'watcher_process_shutdown_incomplete' }) - }) - - it('resolves once every owned child has exited', async () => { - supervisorDispose.mockResolvedValueOnce() - poolDispose.mockResolvedValueOnce() - await expect(disposeWatcherProcessAndWait()).resolves.toBeUndefined() - expect(supervisorDispose).toHaveBeenCalled() - expect(poolDispose).toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/parcel-watcher-process-entry.test.ts b/src/main/ipc/parcel-watcher-process-entry.test.ts index 513775acb29..6ebdb5385dd 100644 --- a/src/main/ipc/parcel-watcher-process-entry.test.ts +++ b/src/main/ipc/parcel-watcher-process-entry.test.ts @@ -86,13 +86,8 @@ describe('parcel watcher process canary', () => { await vi.advanceTimersByTimeAsync(0) expect(sendMock).toHaveBeenCalledWith( - expect.objectContaining({ op: 'subscribe-failed', id: 7 }), - expect.any(Function) + expect.objectContaining({ op: 'subscribe-failed', id: 7 }) ) - const failedSend = sendMock.mock.calls.find(([message]) => message.op === 'subscribe-failed') - expect(() => - failedSend![1](Object.assign(new Error('host disconnected'), { code: 'EPIPE' })) - ).not.toThrow() expect(watchMock).not.toHaveBeenCalledWith('/repo/.git', expect.anything(), expect.anything()) }) @@ -318,13 +313,10 @@ describe('parcel watcher process canary', () => { finishActiveCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 }, expect.any(Function)) + expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 }) expect(subscribeMock).toHaveBeenCalledTimes(2) - expect(sendMock).not.toHaveBeenCalledWith( - { op: 'subscribe-started', id: 2 }, - expect.any(Function) - ) - expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 2 }, expect.any(Function)) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribe-started', id: 2 }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 2 }) }) it('unsubscribes a late cancel after the crawl already finished', async () => { @@ -340,16 +332,13 @@ describe('parcel watcher process canary', () => { process.emit('message', { op: 'subscribe', id: 1, dir: '/finished', opts: {} }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 }, expect.any(Function)) + expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 }) process.emit('message', { op: 'cancel-subscribe', id: 1 }) await vi.advanceTimersByTimeAsync(0) expect(unsubscribe).toHaveBeenCalledTimes(1) - expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }, expect.any(Function)) - expect(sendMock).not.toHaveBeenCalledWith( - { op: 'cancel-requires-restart', id: 1 }, - expect.any(Function) - ) + expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 }) }) it('reports native unsubscribe rejection without acknowledging handle release', async () => { @@ -367,15 +356,12 @@ describe('parcel watcher process canary', () => { process.emit('message', { op: 'unsubscribe', id: 1 }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith( - { - op: 'unsubscribe-failed', - id: 1, - message: 'native handle still active' - }, - expect.any(Function) - ) - expect(sendMock).not.toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }, expect.any(Function)) + expect(sendMock).toHaveBeenCalledWith({ + op: 'unsubscribe-failed', + id: 1, + message: 'native handle still active' + }) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }) }) it('asks the host to restart when an active crawl is cancelled', async () => { @@ -394,13 +380,10 @@ describe('parcel watcher process canary', () => { await vi.advanceTimersByTimeAsync(0) process.emit('message', { op: 'cancel-subscribe', id: 1 }) - expect(sendMock).toHaveBeenCalledWith( - { op: 'cancel-requires-restart', id: 1 }, - expect.any(Function) - ) + expect(sendMock).toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 }) finishCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 1 }, expect.any(Function)) + expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 1 }) }) it('still restarts after consecutive missed events once every subscription is live', async () => { @@ -498,14 +481,11 @@ describe('parcel watcher process canary', () => { callback?.(null, [{ type: 'update', path: '/repo/after-overflow.txt' }]) await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith( - { - op: 'watch-error', - id: 1, - message: 'Events were dropped by the FSEvents client. File system must be re-scanned.' - }, - expect.any(Function) - ) + expect(sendMock).toHaveBeenCalledWith({ + op: 'watch-error', + id: 1, + message: 'Events were dropped by the FSEvents client. File system must be re-scanned.' + }) expect(sendMock).toHaveBeenCalledWith( { op: 'events', diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index d2328821ade..0de05010f47 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -118,7 +118,7 @@ async function startCanary(getStableActivityRevision: () => number | null): Prom function main(): void { const send = (message: WatcherToHostMessage): void => { try { - process.send?.(message, () => undefined) + process.send?.(message) } catch { // Host is gone; the disconnect handler below exits this process. } diff --git a/src/main/ipc/parcel-watcher-process-supervisor.ts b/src/main/ipc/parcel-watcher-process-supervisor.ts index 926ff036466..3f0a534c198 100644 --- a/src/main/ipc/parcel-watcher-process-supervisor.ts +++ b/src/main/ipc/parcel-watcher-process-supervisor.ts @@ -1,7 +1,8 @@ import type { ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' import { restartCancelledWatcherChild } from './parcel-watcher-cancellation-restart' import { WatcherCancellationTracker } from './parcel-watcher-cancellation-tracker' -import { getWatcherProcessEntryPath, watcherProcessEntryExists } from './parcel-watcher-entry-path' +import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path' import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory' import * as termination from './parcel-watcher-child-termination' import { launchWatcherChild } from './parcel-watcher-child-launch' @@ -38,7 +39,6 @@ import { } from './parcel-watcher-supervisor-subscribe' import { disposeWatcherSupervisor } from './parcel-watcher-supervisor-disposal' import { handleWatcherSupervisorMessage } from './parcel-watcher-supervisor-message' -import { WatcherOwnedChildren } from './parcel-watcher-owned-children' export class WatcherProcessSupervisor { private child: ChildProcess | null = null @@ -52,7 +52,6 @@ export class WatcherProcessSupervisor { private readonly pendingUnsubscribes = new Map() private readonly cancelledSubscribes = new WatcherCancellationTracker() private readonly capacityWait = new WatcherSupervisorCapacityWait() - private ownedChildren = new WatcherOwnedChildren() constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {} @@ -108,7 +107,6 @@ export class WatcherProcessSupervisor { resetForTest(): void { this.dispose() - this.ownedChildren = new WatcherOwnedChildren() this.shutdownRequested = false this.terminatingChild = null this.terminationQueue.resetForTest() @@ -116,8 +114,6 @@ export class WatcherProcessSupervisor { resetWatcherChildRegistryForTest() } - disposeAndWait = (): Promise => this.ownedChildren.disposeAndWait(() => this.dispose()) - private ensureWatcherProcess( entryPath = this.options.entryPath ?? getWatcherProcessEntryPath() ): ChildProcess | null { @@ -130,7 +126,8 @@ export class WatcherProcessSupervisor { if (this.crashFuse.isOpen()) { return null } - if (!watcherProcessEntryExists(entryPath)) { + if (!existsSync(entryPath)) { + console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`) return null } const launched = launchWatcherChild( @@ -148,7 +145,7 @@ export class WatcherProcessSupervisor { return null } this.canaryDir = launched.canaryDir - this.child = this.ownedChildren.track(launched.child) + this.child = launched.child return launched.child } @@ -254,9 +251,14 @@ export class WatcherProcessSupervisor { } this.child = null this.terminatingChild = proc + // Why: destructive Windows cleanup must await exit to release directory handles. this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir) return this.terminationQueue.track( termination.terminateIdleWatcherChild(proc, this.pendingUnsubscribes, () => { + // Why: an idle child owns zero records, so a missed exit deadline has no + // double-watch hazard; the child keeps its capacity reservation until + // physical exit, and poisoning this supervisor would permanently end + // local watching — the shared singleton has no retire-and-replace path. this.terminatingChild = null }) ) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 74e2c3ffa69..223fed564f8 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -62,20 +62,6 @@ export function disposeWatcherProcess(): void { } } -/** Dispose both watcher hosts and wait for every child they own to exit. */ -export async function disposeWatcherProcessAndWait(): Promise { - const results = await Promise.allSettled([ - sharedWatcherProcessSupervisor.disposeAndWait(), - runtimeWatcherProcessPool.disposeAndWait() - ]) - const failures = results.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] - ) - if (failures.length > 0) { - throw new AggregateError(failures, 'watcher_process_shutdown_incomplete') - } -} - export function resetWatcherProcessForTest(): void { sharedWatcherProcessSupervisor.resetForTest() } diff --git a/src/main/ipc/parcel-watcher-supervisor-disposal.test.ts b/src/main/ipc/parcel-watcher-supervisor-disposal.test.ts deleted file mode 100644 index d3aaa15770c..00000000000 --- a/src/main/ipc/parcel-watcher-supervisor-disposal.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { acknowledgeWatcherSubscribe, FakeWatcherChild } from './parcel-watcher-process-test-child' -import { resetWatcherChildRegistryForTest } from './parcel-watcher-child-registry' - -const { forkMock } = vi.hoisted(() => ({ forkMock: vi.fn() })) -vi.mock('node:child_process', () => ({ fork: forkMock })) -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => true), - mkdtempSync: vi.fn(() => '/tmp/orca-watcher-disposal-test'), - rmSync: vi.fn() -})) -import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' - -const children: FakeWatcherChild[] = [] -beforeEach(() => { - resetWatcherChildRegistryForTest() - forkMock.mockImplementation(() => { - const child = new FakeWatcherChild() - children.push(child) - return child - }) -}) -afterEach(() => { - for (const child of children.splice(0)) { - child.emit('close') - } - vi.clearAllMocks() - resetWatcherChildRegistryForTest() -}) - -async function subscribe(supervisor: WatcherProcessSupervisor): Promise { - const pending = supervisor.subscribe('/repo', vi.fn(), {}) - const child = children.at(-1)! - acknowledgeWatcherSubscribe(child) - await pending - return child -} - -it('awaited supervisor disposal retains a child after prior synchronous disposal', async () => { - const supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false }) - const child = await subscribe(supervisor) - supervisor.dispose() - const finished = vi.fn() - const shutdown = supervisor.disposeAndWait().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - await expect(supervisor.subscribe('/another', vi.fn(), {})).rejects.toThrow() - expect(forkMock).toHaveBeenCalledOnce() - child.emit('close') - await shutdown - expect(finished).toHaveBeenCalledOnce() -}) - -it('test reset isolates old physical-exit callbacks from the new lifetime owner', async () => { - const supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false }) - const old = await subscribe(supervisor) - const oldShutdown = supervisor.disposeAndWait() - supervisor.resetForTest() - const current = await subscribe(supervisor) - const finished = vi.fn() - const shutdown = supervisor.disposeAndWait().then(finished) - old.emit('close') - await oldShutdown - expect(finished).not.toHaveBeenCalled() - current.emit('close') - await shutdown -}) diff --git a/src/main/ipc/parcel-watcher-supervisor-disposal.ts b/src/main/ipc/parcel-watcher-supervisor-disposal.ts index 07bc73ef3ee..42dc918e05b 100644 --- a/src/main/ipc/parcel-watcher-supervisor-disposal.ts +++ b/src/main/ipc/parcel-watcher-supervisor-disposal.ts @@ -9,7 +9,6 @@ import { resetPendingSubscribeAttempt, takePendingSubscribe } from './parcel-watcher-pending-subscribe' -import { signalWatcherChild } from './parcel-watcher-child-termination' import { watcherHostFailure } from './parcel-watcher-process-failure' import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription' @@ -28,8 +27,6 @@ export function disposeWatcherSupervisor( resolvePendingWatcherUnsubscribes(pendingUnsubscribes) cancelledSubscribes.completeAll() records.clear() - if (child) { - signalWatcherChild(child) - } + child?.kill() return removeWatcherCanaryDirectory(canaryDir) } diff --git a/src/main/ipc/runtime-environment-capability-evidence.ts b/src/main/ipc/runtime-environment-capability-evidence.ts index 7dd47712fb2..197ec71f4f8 100644 --- a/src/main/ipc/runtime-environment-capability-evidence.ts +++ b/src/main/ipc/runtime-environment-capability-evidence.ts @@ -64,10 +64,6 @@ export function advanceRuntimeEnvironmentCapabilityIncarnation(environmentId: st state.accepted = null } -export function getRuntimeEnvironmentCapabilityIncarnation(environmentId: string): number { - return stateFor(environmentId).epoch -} - export function applyRuntimeEnvironmentCapabilityVerdict(args: { evidence: RuntimeEnvironmentCapabilityEvidence verdict: RuntimeEnvironmentCapabilityVerdict diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index 6513c0756b8..84d2754d556 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -14,8 +14,10 @@ import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call- import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' import type { Store } from '../persistence' -import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup' +import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime' +import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement' import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification' +import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { closeRemoteRuntimeRequestConnection, getRuntimeEnvironmentStatusOwner, @@ -104,8 +106,22 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ throw new Error('Choose another Active Server in Advanced before removing this server.') } const removed = removeEnvironment(getUserDataPath(), args.selector) - void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport) + clearRuntimeEnvironmentCapabilityEvidence(removed.id) + clearRuntimeEnvironmentManualDisconnect(removed.id) + const retiring = Promise.resolve(invalidateTransport(removed.id)) closeLegacySelectorTransport(args.selector, removed.id) + // Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes + // too -- but only once the client host releases its partitions, or every one refuses as live. + void retireBrowserRoutePartitionStorageForEnvironment({ + environmentId: removed.id, + whenClientHostClosed: retiring, + clearStorage: clearBrowserRoutePartitionStorageForEnvironment, + onError: (error) => { + console.warn('[runtime-environments] browser partition storage clear failed:', error) + } + }).catch((error) => { + console.warn('[runtime-environments] browser partition storage clear failed:', error) + }) return { removed: redactRuntimeEnvironment(removed) } } ) diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index 3af5e61a64c..23b40fe5183 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -12,17 +12,5 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', - 'runtimeEnvironments:unsubscribe', - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess', - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:updateOrcad', - 'runtimeEnvironments:rollbackOrcad', - 'runtimeEnvironments:recoverOrcad', - 'runtimeEnvironments:stopOrcad', - 'runtimeEnvironments:cancelOrcadStop', - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning' + 'runtimeEnvironments:unsubscribe' ] as const diff --git a/src/main/ipc/runtime-environment-managed-tunnel.ts b/src/main/ipc/runtime-environment-managed-tunnel.ts deleted file mode 100644 index d496dbd8250..00000000000 --- a/src/main/ipc/runtime-environment-managed-tunnel.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import { ensureOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel' - -export async function resolveManagedRuntimeEnvironment( - userDataPath: string, - selector: string -): Promise> { - const environment = resolveEnvironment(userDataPath, selector) - await ensureOrcadManagedTunnel(userDataPath, environment.id) - return resolveEnvironment(userDataPath, environment.id) -} diff --git a/src/main/ipc/runtime-environment-removal-cleanup.ts b/src/main/ipc/runtime-environment-removal-cleanup.ts deleted file mode 100644 index c4141c0e00e..00000000000 --- a/src/main/ipc/runtime-environment-removal-cleanup.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime' -import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement' -import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' -import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect' - -/** Retires a removed server's client-side state; resolves once its transport is invalidated. */ -export function retireRemovedRuntimeEnvironment( - environmentId: string, - invalidateTransport: (environmentId: string) => Promise | void -): Promise { - clearRuntimeEnvironmentCapabilityEvidence(environmentId) - clearRuntimeEnvironmentManualDisconnect(environmentId) - const retiring = Promise.resolve(invalidateTransport(environmentId)) - // Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes - // too -- but only once the client host releases its partitions, or every one refuses as live. - void retireBrowserRoutePartitionStorageForEnvironment({ - environmentId, - whenClientHostClosed: retiring, - clearStorage: clearBrowserRoutePartitionStorageForEnvironment, - onError: (error) => { - console.warn('[runtime-environments] browser partition storage clear failed:', error) - } - }).catch((error) => { - console.warn('[runtime-environments] browser partition storage clear failed:', error) - }) - return retiring -} diff --git a/src/main/ipc/runtime-environment-revision-guard.ts b/src/main/ipc/runtime-environment-revision-guard.ts index 03d6169675d..af70d556356 100644 --- a/src/main/ipc/runtime-environment-revision-guard.ts +++ b/src/main/ipc/runtime-environment-revision-guard.ts @@ -15,14 +15,6 @@ export function runtimeEnvironmentRevisionFailure( if (!pairingChanged && !runtimeChanged) { return null } - return runtimeEnvironmentChangedFailure(environment, method, pairingChanged) -} - -export function runtimeEnvironmentChangedFailure( - environment: Pick, - method: string, - pairingChanged = false -): RuntimeRpcResponse { return { id: method, ok: false, diff --git a/src/main/ipc/runtime-environment-status-owner.ts b/src/main/ipc/runtime-environment-status-owner.ts index 88586577905..f88741ca9a3 100644 --- a/src/main/ipc/runtime-environment-status-owner.ts +++ b/src/main/ipc/runtime-environment-status-owner.ts @@ -8,7 +8,6 @@ import { } from '../../shared/runtime-environments' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' import { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner' -import type { RuntimeStatus } from '../../shared/runtime-types' import { RUNTIME_HOST_STATUS_CHANNEL, type RuntimeHostStatusResponse @@ -16,12 +15,9 @@ import { import { applyRuntimeEnvironmentCapabilityVerdict, getAcceptedRuntimeEnvironmentCapabilityOutcome, - getRuntimeEnvironmentCapabilityIncarnation, captureRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' -import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard' -import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' export function createRuntimeEnvironmentStatusOwner( userDataPath: string, @@ -38,23 +34,13 @@ export function createRuntimeEnvironmentStatusOwner( return new RuntimeHostStatusOwner({ environmentId: environment.id, pairingRevision: environment.pairingRevision ?? environment.createdAt, - request: async (signal) => { - const incarnation = getRuntimeEnvironmentCapabilityIncarnation(environment.id) - const isCurrent = (): boolean => - getRuntimeEnvironmentCapabilityIncarnation(environment.id) === incarnation - if (environment.connectionDependency === 'ssh-tunnel') { - await resolveManagedRuntimeEnvironment(userDataPath, environment.id) - if (!isCurrent()) { - return runtimeEnvironmentChangedFailure(environment, 'status.get') - } - signal.throwIfAborted() - } + request: (signal) => { evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing) - const response = await (transport.isReady() && - getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind === - 'supported' + return transport.isReady() && + getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind === + 'supported' ? transport.request(signal) - : sendRemoteRuntimeRequest( + : sendRemoteRuntimeRequest( pairing, 'status.get', undefined, @@ -62,8 +48,7 @@ export function createRuntimeEnvironmentStatusOwner( undefined, signal, ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - )) - return isCurrent() ? response : runtimeEnvironmentChangedFailure(environment, 'status.get') + ) }, verified: (response, active) => { const capable = diff --git a/src/main/ipc/runtime-environment-status-probe.ts b/src/main/ipc/runtime-environment-status-probe.ts deleted file mode 100644 index 54a93822a9e..00000000000 --- a/src/main/ipc/runtime-environment-status-probe.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import { getPreferredPairingOffer } from '../../shared/runtime-environments' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' -import type { RuntimeStatus } from '../../shared/runtime-types' -import { getRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence' -import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' -import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' -import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard' -import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' -import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' - -export async function getRuntimeEnvironmentStatus( - userDataPath: string, - selector: string, - timeoutMs?: number, - options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true } -): Promise> { - const environment = resolveEnvironment(userDataPath, selector) - if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - return { - id: 'status.get', - ok: false, - error: { - code: 'runtime_manually_disconnected', - message: 'Runtime environment is manually disconnected.' - } - } - } - const incarnation = getRuntimeEnvironmentCapabilityIncarnation(environment.id) - const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ - timeoutMs, - ...options - }) - // A retired request must not publish old status or borrow its replacement's diagnostics. - if (getRuntimeEnvironmentCapabilityIncarnation(environment.id) !== incarnation) { - return runtimeEnvironmentChangedFailure(environment, 'status.get') - } - return attachRemoteControlDiagnostics( - withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint), - environment.id - ) -} diff --git a/src/main/ipc/runtime-environment-status-retirement.test.ts b/src/main/ipc/runtime-environment-status-retirement.test.ts deleted file mode 100644 index 37ebf690a60..00000000000 --- a/src/main/ipc/runtime-environment-status-retirement.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { - addEnvironmentFromPairingCode, - resolveEnvironment -} from '../../shared/runtime-environment-store' -import { pairingCode } from './runtime-environments-ipc-test-harness' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' -import { - advanceRuntimeEnvironmentCapabilityIncarnation, - resetRuntimeEnvironmentCapabilityEvidence -} from './runtime-environment-capability-evidence' - -const mocks = vi.hoisted(() => ({ - request: vi.fn(), - ensure: vi.fn(), - reconnect: vi.fn(), - diagnostics: vi.fn() -})) -vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: mocks.request })) -vi.mock('./runtime-environment-request-connections', async () => { - const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness') - return withRuntimeStatusOwners({ - ensureRemoteRuntimeSharedControlConnection: mocks.ensure, - reconnectRemoteRuntimeSharedControlConnection: mocks.reconnect, - getRemoteRuntimeSharedControlDiagnostics: mocks.diagnostics, - pauseRemoteRuntimeSharedControlRetry: vi.fn(), - closeRemoteRuntimeRequestConnection: vi.fn() - }) -}) -vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) - -import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' -import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections' - -let directory: string -let envId: string -beforeEach(() => { - vi.clearAllMocks() - resetRuntimeEnvironmentCapabilityEvidence() - directory = mkdtempSync(join(tmpdir(), 'orca-status-retirement-')) - envId = addEnvironmentFromPairingCode(directory, { - name: 'Host', - now: 1, - pairingCode: pairingCode(), - connectionDependency: 'ssh-tunnel' - }).id - mocks.diagnostics.mockReturnValue(null) -}) -afterEach(() => { - resetRuntimeEnvironmentStatusOwners() - rmSync(directory, { recursive: true, force: true }) -}) - -function response() { - return { - id: 'status', - ok: true as const, - result: { runtimeId: 'old-host', capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] }, - _meta: { runtimeId: 'old-host' } - } -} - -it.each(['success', 'failure', 'throw'] as const)( - 'fences a retired status probe settling with %s without publishing status or diagnostics', - async (settlement) => { - const pending = Promise.withResolvers() - mocks.request.mockReturnValue(pending.promise) - const probe = getRuntimeEnvironmentStatus(directory, envId) - await vi.waitFor(() => expect(mocks.request).toHaveBeenCalledOnce()) - mocks.diagnostics.mockClear() - advanceRuntimeEnvironmentCapabilityIncarnation(envId) - if (settlement === 'throw') { - pending.reject(new Error('old endpoint offline')) - } else { - pending.resolve( - settlement === 'success' - ? response() - : { - id: 'status', - ok: false, - error: { code: 'offline', message: 'old endpoint' }, - _meta: { runtimeId: 'old-host' } - } - ) - } - await expect(probe).resolves.toMatchObject({ - ok: false, - error: { code: 'runtime_environment_changed' } - }) - expect(resolveEnvironment(directory, envId).runtimeId).toBeNull() - expect(mocks.ensure).not.toHaveBeenCalled() - expect(mocks.reconnect).not.toHaveBeenCalled() - expect(mocks.diagnostics).not.toHaveBeenCalled() - } -) - -it('does not fence another environment or a fresh probe after retirement', async () => { - mocks.request.mockImplementation(async () => { - advanceRuntimeEnvironmentCapabilityIncarnation('other') - return response() - }) - await expect(getRuntimeEnvironmentStatus(directory, envId)).resolves.toMatchObject({ ok: true }) - advanceRuntimeEnvironmentCapabilityIncarnation(envId) - await expect(getRuntimeEnvironmentStatus(directory, envId)).resolves.toMatchObject({ ok: true }) - expect(resolveEnvironment(directory, envId).runtimeId).toBe('old-host') - expect(mocks.ensure).toHaveBeenCalledTimes(2) -}) diff --git a/src/main/ipc/runtime-environment-subscription-identity.test.ts b/src/main/ipc/runtime-environment-subscription-identity.test.ts deleted file mode 100644 index ce1a72d8945..00000000000 --- a/src/main/ipc/runtime-environment-subscription-identity.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' -import type { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' - -const mocks = vi.hoisted(() => ({ handle: vi.fn(), on: vi.fn(), subscribe: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle, on: mocks.on } })) -vi.mock('../../shared/runtime-environment-store', () => ({ - resolveEnvironment: () => ({ id: 'env', pairingRevision: 1, createdAt: 1 }) -})) -vi.mock('./runtime-environment-transport-routing', () => ({ - subscribeRuntimeEnvironment: mocks.subscribe -})) -import { - closeSubscriptionsForEnvironment, - registerRuntimeEnvironmentSubscriptions -} from './runtime-environment-subscriptions' - -type Callbacks = Parameters[5] -const sender = { - id: 1, - isDestroyed: () => false, - send: vi.fn(), - once: vi.fn(), - removeListener: vi.fn() -} -const args = { selector: 'env', method: 'terminal.multiplex', subscriptionId: 'reused-id' } -function handler(channel: string) { - return mocks.handle.mock.calls.find(([name]) => name === channel)![1] -} -const open = () => handler('runtimeEnvironments:subscribe')({ sender }, args) -const unsubscribe = () => - handler('runtimeEnvironments:unsubscribe')({ sender }, { subscriptionId: args.subscriptionId }) -const connection = (): RemoteRuntimeSubscription => ({ - requestId: 'request', - sendBinary: vi.fn(() => true), - close: vi.fn() -}) - -beforeEach(() => { - vi.clearAllMocks() - mocks.subscribe.mockReset() - registerRuntimeEnvironmentSubscriptions(() => '/profile') -}) -afterEach(() => closeSubscriptionsForEnvironment('env')) - -it('reserves an ID while setup is pending so a second open cannot take its ownership', async () => { - const gate = Promise.withResolvers() - mocks.subscribe.mockReturnValue(gate.promise) - const first = open() - await expect(open()).rejects.toThrow('already exists') - expect(mocks.subscribe).toHaveBeenCalledOnce() - gate.resolve(connection()) - await expect(first).resolves.toMatchObject({ subscriptionId: 'reused-id' }) -}) - -it('releases the reservation after failed setup so an explicit retry can open', async () => { - mocks.subscribe.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(connection()) - await expect(open()).rejects.toThrow('offline') - await expect(open()).resolves.toMatchObject({ subscriptionId: 'reused-id' }) -}) - -it('does not let callbacks from an old subscription publish to or remove its replacement', async () => { - const callbacks: Callbacks[] = [] - const currentChecks: (() => boolean)[] = [] - const connections: RemoteRuntimeSubscription[] = [] - mocks.subscribe.mockImplementation( - async (...parameters: Parameters) => { - callbacks.push(parameters[5]) - currentChecks.push(parameters[6]!) - const value = connection() - connections.push(value) - return value - } - ) - await open() - expect(unsubscribe()).toEqual({ unsubscribed: true }) - await open() - sender.send.mockClear() - expect(currentChecks[0]()).toBe(false) - expect(currentChecks[1]()).toBe(true) - const payload = { type: 'binary' as const, bytes: new Uint8Array([1]) } - callbacks[0].onEvent(payload) - callbacks[0].onEvent({ type: 'close' }) - callbacks[0].onClose() - expect(sender.send).not.toHaveBeenCalled() - callbacks[1].onEvent(payload) - expect(sender.send).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', { - subscriptionId: 'reused-id', - ...payload - }) - expect(unsubscribe()).toEqual({ unsubscribed: true }) - expect(connections[1].close).toHaveBeenCalledOnce() -}) - -it('does not retain a subscription that closes before setup resolves', async () => { - const value = connection() - mocks.subscribe.mockImplementationOnce( - async (...parameters: Parameters) => { - parameters[5].onEvent({ type: 'close' }) - parameters[5].onClose() - return value - } - ) - await expect(open()).rejects.toThrow('closed during setup') - expect(value.close).toHaveBeenCalledOnce() - mocks.subscribe.mockResolvedValueOnce(connection()) - await expect(open()).resolves.toMatchObject({ subscriptionId: 'reused-id' }) -}) diff --git a/src/main/ipc/runtime-environment-subscriptions.ts b/src/main/ipc/runtime-environment-subscriptions.ts deleted file mode 100644 index f1fdd9aaa3a..00000000000 --- a/src/main/ipc/runtime-environment-subscriptions.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { ipcMain } from 'electron' -import { randomUUID } from 'node:crypto' -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' -import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' -import { getRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' -import { subscribeRuntimeEnvironment } from './runtime-environment-transport-routing' - -type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { - token: symbol - environmentId: string - ownerWebContentsId: number - removeDestroyedListener: () => void - notifyClosed: () => void -} -const remoteRuntimeSubscriptions = new Map() -// Why: an id is claimed while its socket opens, so a duplicate or a late callback cannot cross over. -const pendingSubscriptions = new Map() - -export function closeSubscriptionsForEnvironment(environmentId: string): void { - // Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown. - for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { - if (subscription.environmentId !== environmentId) { - continue - } - remoteRuntimeSubscriptions.delete(subscriptionId) - // Why: one failing teardown must not abandon this environment's other - // sockets -- that strands exactly the dead handles this sweep exists to - // retire. Guard the two steps independently so neither can skip the other, - // and so the isolation stays structural rather than resting on a claim that - // nothing inside notifyClosed will ever throw. - try { - subscription.close() - } catch (error) { - console.warn('[runtime-environments] subscription close failed during retirement:', error) - } - try { - // Why: a shared-control logical close never calls back, so notify directly. - subscription.notifyClosed() - } catch (error) { - console.warn('[runtime-environments] subscription close notice failed:', error) - } - } -} -export function registerRuntimeEnvironmentSubscriptions(getUserDataPath: () => string): void { - ipcMain.handle( - 'runtimeEnvironments:subscribe', - async ( - event, - args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - subscriptionId?: string - expectedEnvironmentPairingRevision?: number - expectedEnvironmentRuntimeId?: string - } - ): Promise<{ subscriptionId: string; requestId: string }> => { - const subscriptionId = - typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 - ? args.subscriptionId - : randomUUID() - if ( - remoteRuntimeSubscriptions.has(subscriptionId) || - pendingSubscriptions.has(subscriptionId) - ) { - throw new Error('Runtime environment subscription id already exists') - } - const environment = resolveEnvironment(getUserDataPath(), args.selector) - if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { - throw new Error('runtime_manually_disconnected') - } - const pairingRevision = environment.pairingRevision ?? environment.createdAt - if ( - args.expectedEnvironmentPairingRevision !== undefined && - pairingRevision !== args.expectedEnvironmentPairingRevision - ) { - throw new Error('Runtime environment pairing changed; refresh and try again') - } - if ( - args.expectedEnvironmentRuntimeId !== undefined && - environment.runtimeId !== args.expectedEnvironmentRuntimeId - ) { - throw new Error('Runtime environment identity changed; refresh and try again') - } - const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id) - const token = Symbol(subscriptionId) - let closed = false - const ownsId = (): boolean => - pendingSubscriptions.get(subscriptionId) === token || - remoteRuntimeSubscriptions.get(subscriptionId)?.token === token - const releasePending = (): void => { - if (pendingSubscriptions.get(subscriptionId) === token) { - pendingSubscriptions.delete(subscriptionId) - } - } - const transportIsCurrent = (): boolean => - getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration - const sender = event.sender - const ownerWebContentsId = sender.id - let senderDestroyed = sender.isDestroyed() - let subscription: RemoteRuntimeSubscription | null = null - let destroyedListenerAttached = false - const removeDestroyedListener = (): void => { - if (!destroyedListenerAttached) { - return - } - destroyedListenerAttached = false - sender.removeListener('destroyed', closeSubscription) - } - const closeSubscription = (): void => { - senderDestroyed = true - closed = true - releasePending() - const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null - if (retained?.token === token) { - remoteRuntimeSubscriptions.delete(subscriptionId) - retained.close() - return - } - removeDestroyedListener() - subscription?.close() - } - // Why: the renderer treats close as terminal and drops its handle, so send it once. - // Latch before sending so a re-entrant call cannot duplicate it, and never - // throw: a dying renderer must not abort its siblings' retirement. - let closeNotified = false - const notifyClosed = (): void => { - closed = true - if (closeNotified || sender.isDestroyed()) { - return - } - closeNotified = true - try { - sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, type: 'close' }) - } catch { - // The renderer is gone; there is no one left to tell. - } - } - pendingSubscriptions.set(subscriptionId, token) - try { - sender.once('destroyed', closeSubscription) - destroyedListenerAttached = true - subscription = await subscribeRuntimeEnvironment( - getUserDataPath(), - environment.id, - args.method, - args.params, - args.timeoutMs, - { - onEvent: (payload) => { - if (payload.type === 'close') { - // Why: retirement advances the generation before closing, so gating - // close on it stranded the renderer with a dead subscription. - if (ownsId()) { - notifyClosed() - } - return - } - if (!closed && ownsId() && transportIsCurrent() && !sender.isDestroyed()) { - sender.send('runtimeEnvironments:subscriptionEvent', { - subscriptionId, - ...payload - }) - } - }, - onClose: () => { - closed = true - releasePending() - removeDestroyedListener() - const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null - if (retained?.token === token) { - remoteRuntimeSubscriptions.delete(subscriptionId) - } - } - }, - () => !closed && ownsId() && transportIsCurrent() - ) - } catch (error) { - closed = true - releasePending() - removeDestroyedListener() - throw error - } - releasePending() - let pairingIsCurrent = false - try { - const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) - pairingIsCurrent = - (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision - } catch { - pairingIsCurrent = false - } - if (!transportIsCurrent() || !pairingIsCurrent) { - removeDestroyedListener() - subscription.close() - throw new Error('Runtime environment pairing changed; refresh and try again') - } - if (senderDestroyed || sender.isDestroyed()) { - removeDestroyedListener() - subscription.close() - return { subscriptionId, requestId: subscription.requestId } - } - if (closed) { - removeDestroyedListener() - subscription.close() - throw new Error('Runtime environment subscription closed during setup') - } - remoteRuntimeSubscriptions.set(subscriptionId, { - token, - requestId: subscription.requestId, - environmentId: environment.id, - ownerWebContentsId, - removeDestroyedListener, - notifyClosed, - sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, - close: () => { - closed = true - removeDestroyedListener() - subscription?.close() - } - }) - return { subscriptionId, requestId: subscription.requestId } - } - ) - ipcMain.handle( - 'runtimeEnvironments:unsubscribe', - (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { - return { unsubscribed: false } - } - remoteRuntimeSubscriptions.delete(args.subscriptionId) - subscription.close() - return { unsubscribed: true } - } - ) - ipcMain.on( - 'runtimeEnvironments:subscriptionBinary', - (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { - if (typeof args.subscriptionId !== 'string') { - return - } - const bytes = toBinaryPayload(args.bytes) - if (!bytes) { - return - } - const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) - if (subscription?.ownerWebContentsId === event.sender.id) { - subscription.sendBinary(bytes) - } - } - ) -} - -function toBinaryPayload(value: unknown): Uint8Array | null { - if (value instanceof Uint8Array) { - return value - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value) - } - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) - } - return null -} diff --git a/src/main/ipc/runtime-environment-support-routing.ts b/src/main/ipc/runtime-environment-support-routing.ts index f1a846d988e..ae68b3d35f8 100644 --- a/src/main/ipc/runtime-environment-support-routing.ts +++ b/src/main/ipc/runtime-environment-support-routing.ts @@ -1,4 +1,3 @@ -import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { PairingOffer } from '../../shared/pairing' import type { @@ -19,10 +18,7 @@ import { isRuntimeEnvironmentCapabilityOutcomeCurrent, type RuntimeEnvironmentCapabilityOutcome } from './runtime-environment-capability-evidence' -import { - runtimeEnvironmentChangedFailure, - runtimeEnvironmentRevisionFailure -} from './runtime-environment-revision-guard' +import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' import { supportsSharedControl } from './runtime-environment-shared-control-support' import { sendRemoteRuntimeRequestAbortable, @@ -241,6 +237,21 @@ export async function routeRuntimeEnvironmentSubscriptionBySupport { + return { + id: method, + ok: false, + error: { + code: 'runtime_environment_changed', + message: 'Runtime environment pairing changed; refresh and try again' + }, + _meta: { runtimeId: environment.runtimeId } + } +} + function subscriptionCallbacks( args: Pick< Parameters[0], @@ -272,13 +283,3 @@ function subscriptionCallbacks( } } } - -export function shouldUseSharedControlEnvelope( - method: string, - params: unknown, - envelope: RuntimeOrchestrationEnvelope | undefined -): RuntimeOrchestrationEnvelope | undefined { - return envelope && method.startsWith('orchestration.') && !isOrchestrationMutation(method, params) - ? envelope - : undefined -} diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index f67ca33b467..fd60ef33fcd 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,28 +1,32 @@ -import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' -import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel' import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record' +import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract' import type { RuntimeOrchestrationEnvelope, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../shared/runtime-types' import { subscribeRemoteRuntimeRequest, type RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tailscale-hint' import { enqueueRuntimeCall } from './runtime-environment-call-queue' +import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' import { sendRemoteRuntimeConnectionRequestAbortable, sendRemoteRuntimeRequestAbortable } from './runtime-environment-abortable-requests' +import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' + +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' +import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' import { resetSharedControlSupport } from './runtime-environment-shared-control-support' import { executeSupportRoutedCall, - shouldUseSharedControlEnvelope, shouldRouteCallBySupport, shouldRouteSubscriptionBySupport, subscribeSupportRoutedRuntimeEnvironment @@ -32,7 +36,32 @@ const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 export { resetSharedControlSupport } -export { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe' +export async function getRuntimeEnvironmentStatus( + userDataPath: string, + selector: string, + timeoutMs?: number, + options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true } +): Promise> { + const environment = resolveEnvironment(userDataPath, selector) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + return { + id: 'status.get', + ok: false, + error: { + code: 'runtime_manually_disconnected', + message: 'Runtime environment is manually disconnected.' + } + } + } + const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({ + timeoutMs, + ...options + }) + return attachRemoteControlDiagnostics( + withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint), + environment.id + ) +} export async function callRuntimeEnvironment( userDataPath: string, @@ -65,10 +94,7 @@ export async function callRuntimeEnvironment( environment.id, method, async () => { - const currentEnvironment = await resolveManagedRuntimeEnvironment( - userDataPath, - environment.id - ) + const currentEnvironment = resolveEnvironment(userDataPath, environment.id) const revisionFailure = runtimeEnvironmentRevisionFailure( currentEnvironment, expectedEnvironmentPairingRevision, @@ -161,7 +187,7 @@ export async function subscribeRuntimeEnvironment( }, isCurrent: () => boolean = () => true ): Promise { - const environment = await resolveManagedRuntimeEnvironment(userDataPath, selector) + const environment = resolveEnvironment(userDataPath, selector) const pairing = getPreferredPairingOffer(environment) const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS let markedUsed = false @@ -235,3 +261,13 @@ function markEnvironmentUsedFromResponse( function shouldUseCachedRequestConnection(method: string): boolean { return method === 'terminal.send' || method === 'terminal.updateViewport' } + +function shouldUseSharedControlEnvelope( + method: string, + params: unknown, + envelope: RuntimeOrchestrationEnvelope | undefined +): RuntimeOrchestrationEnvelope | undefined { + return envelope && method.startsWith('orchestration.') && !isOrchestrationMutation(method, params) + ? envelope + : undefined +} diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index b1dc936acf8..6919c68f198 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -152,18 +152,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:retryConnectionsNow', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess', - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning', - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:updateOrcad', - 'runtimeEnvironments:rollbackOrcad', - 'runtimeEnvironments:recoverOrcad', - 'runtimeEnvironments:stopOrcad', - 'runtimeEnvironments:cancelOrcadStop', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe' ]) @@ -190,18 +178,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', 'runtimeEnvironments:unsubscribe', - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess', - 'runtimeEnvironments:deployOrcad', - 'runtimeEnvironments:getOrcadStatus', - 'runtimeEnvironments:updateOrcad', - 'runtimeEnvironments:rollbackOrcad', - 'runtimeEnvironments:recoverOrcad', - 'runtimeEnvironments:stopOrcad', - 'runtimeEnvironments:cancelOrcadStop', - 'runtimeEnvironments:createOrcadSshHost', - 'runtimeEnvironments:resumeOrcadSshHost', - 'runtimeEnvironments:listPendingOrcadSshProvisioning', 'runtimeEnvironments:retryConnectionsNow' ]) expect(removeAllListenersMock).toHaveBeenCalledWith('runtimeEnvironments:subscriptionBinary') diff --git a/src/main/ipc/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments-subscription-teardown.test.ts index 4d10253c8e8..afb1adf457a 100644 --- a/src/main/ipc/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments-subscription-teardown.test.ts @@ -485,7 +485,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { expect(deliveredCloses).toEqual([]) }) - it('fences late payloads and duplicate close after full retirement', async () => { + it('suppresses stale payloads from a retired transport but never re-sends its close', async () => { registerRuntimeEnvironmentHandlers(store as never) let transportCallbacks: { onResponse: (response: Record) => void @@ -533,16 +533,11 @@ describe('registerRuntimeEnvironmentHandlers', () => { } ) - await invalidateRuntimeEnvironmentTransport(added.environment.id) + invalidateRuntimeEnvironmentTransport(added.environment.id) expect(retirePairedRuntimeBrowserClientHostEnvironmentMock).toHaveBeenCalledWith( added.environment.id, expect.objectContaining({ message: 'Runtime environment transport was invalidated' }) ) - expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id) - expect(senderSend).toHaveBeenCalledWith('runtimeEnvironments:subscriptionEvent', { - subscriptionId: 'multiplex-stale', - type: 'close' - }) senderSend.mockClear() // A late frame from the retired socket must not reach the renderer... transportCallbacks!.onResponse({ diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 3117a81a067..6d16315b3a7 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -1,5 +1,7 @@ import { app, ipcMain } from 'electron' -import { listEnvironments } from '../../shared/runtime-environment-store' +import { randomUUID } from 'node:crypto' +import { listEnvironments, resolveEnvironment } from '../../shared/runtime-environment-store' +import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import type { Store } from '../persistence' import { isRuntimeEnvironmentManuallyDisconnected, @@ -11,22 +13,53 @@ import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections' import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler' -import { advanceRuntimeEnvironmentTransportGeneration } from './runtime-environment-transport-generation' -import { resetSharedControlSupport } from './runtime-environment-transport-routing' import { - closeSubscriptionsForEnvironment, - registerRuntimeEnvironmentSubscriptions -} from './runtime-environment-subscriptions' + advanceRuntimeEnvironmentTransportGeneration, + getRuntimeEnvironmentTransportGeneration +} from './runtime-environment-transport-generation' +import { + resetSharedControlSupport, + subscribeRuntimeEnvironment +} from './runtime-environment-transport-routing' import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels' -import { registerOrcadRuntimeLifecycleHandlers } from './orcad-runtime-lifecycle-handlers' -import { registerOrcadRuntimeMaintenanceHandlers } from './orcad-runtime-maintenance-handlers' -import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers' import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime' import { registerRuntimeEnvironmentBrowserClientHostHandler } from './runtime-environment-browser-client-host-handler' import { advanceRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence' +type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & { + environmentId: string + ownerWebContentsId: number + removeDestroyedListener: () => void + notifyClosed: () => void +} +const remoteRuntimeSubscriptions = new Map() const getUserDataPath = (): string => app.getPath('userData') +function closeSubscriptionsForEnvironment(environmentId: string): void { + // Why: removed runtimes must not retain terminal/browser WebSockets until renderer teardown. + for (const [subscriptionId, subscription] of remoteRuntimeSubscriptions) { + if (subscription.environmentId !== environmentId) { + continue + } + remoteRuntimeSubscriptions.delete(subscriptionId) + // Why: one failing teardown must not abandon this environment's other + // sockets -- that strands exactly the dead handles this sweep exists to + // retire. Guard the two steps independently so neither can skip the other, + // and so the isolation stays structural rather than resting on a claim that + // nothing inside notifyClosed will ever throw. + try { + subscription.close() + } catch (error) { + console.warn('[runtime-environments] subscription close failed during retirement:', error) + } + try { + // Why: a shared-control logical close never calls back, so notify directly. + subscription.notifyClosed() + } catch (error) { + console.warn('[runtime-environments] subscription close notice failed:', error) + } + } +} /** Returns once the environment's client-hosted browser pages have been released. */ export function invalidateRuntimeEnvironmentTransport(environmentId: string): Promise { // Why: a same-id re-pair must retire every transport that still authenticates as the old peer. @@ -70,15 +103,193 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id).activate() } } - registerRuntimeSshAccessHandlers({ - getUserDataPath, - invalidateTransport: invalidateRuntimeEnvironmentTransport - }) - registerOrcadRuntimeLifecycleHandlers({ getUserDataPath }) - registerOrcadRuntimeMaintenanceHandlers({ - getUserDataPath, - getActiveEnvironmentId: () => store.getSettings().activeRuntimeEnvironmentId, - invalidateTransport: invalidateRuntimeEnvironmentTransport - }) - registerRuntimeEnvironmentSubscriptions(getUserDataPath) + ipcMain.handle( + 'runtimeEnvironments:subscribe', + async ( + event, + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + subscriptionId?: string + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId?: string + } + ): Promise<{ subscriptionId: string; requestId: string }> => { + const subscriptionId = + typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0 + ? args.subscriptionId + : randomUUID() + if (remoteRuntimeSubscriptions.has(subscriptionId)) { + throw new Error('Runtime environment subscription id already exists') + } + const environment = resolveEnvironment(getUserDataPath(), args.selector) + if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + throw new Error('runtime_manually_disconnected') + } + const pairingRevision = environment.pairingRevision ?? environment.createdAt + if ( + args.expectedEnvironmentPairingRevision !== undefined && + pairingRevision !== args.expectedEnvironmentPairingRevision + ) { + throw new Error('Runtime environment pairing changed; refresh and try again') + } + if ( + args.expectedEnvironmentRuntimeId !== undefined && + environment.runtimeId !== args.expectedEnvironmentRuntimeId + ) { + throw new Error('Runtime environment identity changed; refresh and try again') + } + const transportGeneration = getRuntimeEnvironmentTransportGeneration(environment.id) + const transportIsCurrent = (): boolean => + getRuntimeEnvironmentTransportGeneration(environment.id) === transportGeneration + const sender = event.sender + const ownerWebContentsId = sender.id + let senderDestroyed = sender.isDestroyed() + let subscription: RemoteRuntimeSubscription | null = null + let destroyedListenerAttached = false + const removeDestroyedListener = (): void => { + if (!destroyedListenerAttached) { + return + } + destroyedListenerAttached = false + sender.removeListener('destroyed', closeSubscription) + } + const closeSubscription = (): void => { + senderDestroyed = true + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + remoteRuntimeSubscriptions.delete(subscriptionId) + if (retained) { + retained.close() + return + } + removeDestroyedListener() + subscription?.close() + } + // Why: the renderer treats close as terminal and drops its handle, so send it once. + // Latch before sending so a re-entrant call cannot duplicate it, and never + // throw: a dying renderer must not abort its siblings' retirement. + let closeNotified = false + const notifyClosed = (): void => { + if (closeNotified || sender.isDestroyed()) { + return + } + closeNotified = true + try { + sender.send('runtimeEnvironments:subscriptionEvent', { subscriptionId, type: 'close' }) + } catch { + // The renderer is gone; there is no one left to tell. + } + } + sender.once('destroyed', closeSubscription) + destroyedListenerAttached = true + try { + subscription = await subscribeRuntimeEnvironment( + getUserDataPath(), + environment.id, + args.method, + args.params, + args.timeoutMs, + { + onEvent: (payload) => { + if (payload.type === 'close') { + // Why: retirement advances the generation before closing, so gating + // close on it stranded the renderer with a dead subscription. + notifyClosed() + return + } + if (transportIsCurrent() && !sender.isDestroyed()) { + sender.send('runtimeEnvironments:subscriptionEvent', { + subscriptionId, + ...payload + }) + } + }, + onClose: () => { + const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null + retained?.removeDestroyedListener() + remoteRuntimeSubscriptions.delete(subscriptionId) + } + }, + transportIsCurrent + ) + } catch (error) { + removeDestroyedListener() + throw error + } + let pairingIsCurrent = false + try { + const currentEnvironment = resolveEnvironment(getUserDataPath(), environment.id) + pairingIsCurrent = + (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === pairingRevision + } catch { + pairingIsCurrent = false + } + if (!transportIsCurrent() || !pairingIsCurrent) { + removeDestroyedListener() + subscription.close() + throw new Error('Runtime environment pairing changed; refresh and try again') + } + if (senderDestroyed || sender.isDestroyed()) { + removeDestroyedListener() + subscription.close() + return { subscriptionId, requestId: subscription.requestId } + } + remoteRuntimeSubscriptions.set(subscriptionId, { + requestId: subscription.requestId, + environmentId: environment.id, + ownerWebContentsId, + removeDestroyedListener, + notifyClosed, + sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false, + close: () => { + removeDestroyedListener() + subscription?.close() + } + }) + return { subscriptionId, requestId: subscription.requestId } + } + ) + ipcMain.handle( + 'runtimeEnvironments:unsubscribe', + (event, args: { subscriptionId: string }): { unsubscribed: boolean } => { + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (!subscription || subscription.ownerWebContentsId !== event.sender.id) { + return { unsubscribed: false } + } + remoteRuntimeSubscriptions.delete(args.subscriptionId) + subscription.close() + return { unsubscribed: true } + } + ) + ipcMain.on( + 'runtimeEnvironments:subscriptionBinary', + (event, args: { subscriptionId?: unknown; bytes?: unknown }) => { + if (typeof args.subscriptionId !== 'string') { + return + } + const bytes = toBinaryPayload(args.bytes) + if (!bytes) { + return + } + const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId) + if (subscription?.ownerWebContentsId === event.sender.id) { + subscription.sendBinary(bytes) + } + } + ) +} + +function toBinaryPayload(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) { + return value + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value) + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + } + return null } diff --git a/src/main/ipc/runtime-ssh-access-handlers.test.ts b/src/main/ipc/runtime-ssh-access-handlers.test.ts deleted file mode 100644 index ecf5d2de21e..00000000000 --- a/src/main/ipc/runtime-ssh-access-handlers.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ handle: vi.fn(), link: vi.fn(), unlink: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/runtime-ssh-access', () => ({ - linkRuntimeSshAccess: mocks.link, - unlinkRuntimeSshAccess: mocks.unlink -})) -import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers' - -describe('existing paired server SSH access IPC', () => { - const invalidateTransport = vi.fn() - const request = { - selector: 'host', - requestId: 'request-1', - sshTargetId: 'ssh-host', - remotePort: 6768 - } - beforeEach(() => { - vi.clearAllMocks() - registerRuntimeSshAccessHandlers({ - getUserDataPath: () => '/test-profile', - invalidateTransport - }) - }) - function handler(channel: string): (_event: null, input: unknown) => Promise { - const registered = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registered) { - throw new Error('Handler missing') - } - return registered[1] - } - - it('registers access-only actions and forwards only validated arguments and main-owned invalidation', async () => { - expect(mocks.handle.mock.calls.map(([name]) => name)).toEqual([ - 'runtimeEnvironments:linkSshAccess', - 'runtimeEnvironments:unlinkSshAccess' - ]) - const result = { - id: 'host', - endpoints: [{ id: 'ssh-endpoint', endpoint: 'ws://127.0.0.1:41000' }] - } - mocks.link.mockResolvedValue(result) - expect(await handler('runtimeEnvironments:linkSshAccess')(null, request)).toBe(result) - expect(mocks.link).toHaveBeenCalledExactlyOnceWith('/test-profile', request, { - invalidateTransport - }) - await handler('runtimeEnvironments:unlinkSshAccess')(null, { - selector: 'host', - requestId: 'unlink-1' - }) - expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith( - '/test-profile', - { selector: 'host', requestId: 'unlink-1' }, - { invalidateTransport } - ) - }) - - it.each([ - { remotePort: 0 }, - { remotePort: 65536 }, - { requestId: '../request' }, - { verifiedRuntimeId: 'renderer-supplied' }, - { userDataPath: '/other-profile' }, - { owner: { type: 'orcad-runtime', environmentId: 'other' } } - ])('refuses invalid or authority-bearing link arguments: %j', async (change) => { - await expect( - handler('runtimeEnvironments:linkSshAccess')(null, { ...request, ...change }) - ).rejects.toThrow() - expect(mocks.link).not.toHaveBeenCalled() - }) - - it('refuses renderer-supplied unlink snapshots', async () => { - await expect( - handler('runtimeEnvironments:unlinkSshAccess')(null, { - selector: 'host', - requestId: 'unlink-1', - expectedEnvironment: { id: 'other' } - }) - ).rejects.toThrow() - expect(mocks.unlink).not.toHaveBeenCalled() - }) - - it('propagates pending failures instead of reporting a successful link', async () => { - const error = new Error('SSH endpoint identity was not verified') - mocks.link.mockRejectedValue(error) - await expect(handler('runtimeEnvironments:linkSshAccess')(null, request)).rejects.toBe(error) - }) -}) diff --git a/src/main/ipc/runtime-ssh-access-handlers.ts b/src/main/ipc/runtime-ssh-access-handlers.ts deleted file mode 100644 index deed2936f35..00000000000 --- a/src/main/ipc/runtime-ssh-access-handlers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ipcMain } from 'electron' -import { - RuntimeSshAccessLinkRequestSchema, - RuntimeSshAccessUnlinkRequestSchema -} from '../../shared/runtime-ssh-access' -import { linkRuntimeSshAccess, unlinkRuntimeSshAccess } from '../ssh/runtime-ssh-access' - -export function registerRuntimeSshAccessHandlers(options: { - getUserDataPath: () => string - invalidateTransport: (environmentId: string) => void | Promise -}): void { - ipcMain.handle('runtimeEnvironments:linkSshAccess', async (_event, input: unknown) => { - const args = RuntimeSshAccessLinkRequestSchema.parse(input) - return linkRuntimeSshAccess(options.getUserDataPath(), args, { - invalidateTransport: options.invalidateTransport - }) - }) - ipcMain.handle('runtimeEnvironments:unlinkSshAccess', async (_event, input: unknown) => { - const args = RuntimeSshAccessUnlinkRequestSchema.parse(input) - return unlinkRuntimeSshAccess(options.getUserDataPath(), args, { - invalidateTransport: options.invalidateTransport - }) - }) -} diff --git a/src/main/ipc/runtime-watcher-disposal-owners.test.ts b/src/main/ipc/runtime-watcher-disposal-owners.test.ts deleted file mode 100644 index a889db510a4..00000000000 --- a/src/main/ipc/runtime-watcher-disposal-owners.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { RuntimeWatcherDisposalOwners } from './runtime-watcher-disposal-owners' - -function owner() { - return { - dispose: vi.fn(), - subscribe: vi.fn(), - disposeAndWait: vi.fn(async () => {}) - } -} - -it('retains retired cleanup and joins concurrent shutdown callers', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const retired = owner() - const pending = Promise.withResolvers() - retired.disposeAndWait.mockReturnValue(pending.promise) - owners.retire(retired) - const shutdown = owners.disposeAndWait(() => {}) - expect(owners.disposeAndWait(() => {})).toBe(shutdown) - const finished = vi.fn() - const result = shutdown.then(finished) - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - expect(retired.disposeAndWait).toHaveBeenCalledOnce() - pending.resolve() - await result - await owners.disposeAndWait(() => {}) - expect(retired.disposeAndWait).toHaveBeenCalledOnce() -}) - -it('does not retry a newly failed attempt until a later explicit shutdown call', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const failed = owner() - const sibling = owner() - const pending = Promise.withResolvers() - const failure = new Error('child still live') - failed.disposeAndWait.mockRejectedValueOnce(failure) - sibling.disposeAndWait.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = owners.disposeAndWait(() => { - owners.retire(failed) - owners.retire(sibling) - }) - const result = shutdown.catch((error: unknown) => { - finished() - return error - }) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - expect(owners.disposeAndWait(() => {})).toBe(shutdown) - expect(failed.disposeAndWait).toHaveBeenCalledOnce() - pending.resolve() - expect(await result).toMatchObject({ errors: [failure] }) - await owners.disposeAndWait(() => {}) - expect(failed.disposeAndWait).toHaveBeenCalledTimes(2) - expect(sibling.disposeAndWait).toHaveBeenCalledOnce() -}) - -it('retains synchronous failures and attempts sibling owners', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const failed = owner() - const sibling = owner() - failed.disposeAndWait.mockImplementationOnce(() => { - throw new Error('sync failure') - }) - await expect( - owners.disposeAndWait(() => { - owners.retire(failed) - owners.retire(sibling) - }) - ).rejects.toThrow('watcher_pool_shutdown_incomplete') - expect(sibling.disposeAndWait).toHaveBeenCalledOnce() - await owners.disposeAndWait(() => {}) - expect(failed.disposeAndWait).toHaveBeenCalledTimes(2) -}) - -it('publishes the retained attempt before a synchronous disposal callback reenters', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const child = owner() - const pending = Promise.withResolvers() - let nested: Promise | undefined - child.disposeAndWait.mockImplementation(() => { - owners.retire(child) - nested = owners.disposeAndWait(() => {}) - return pending.promise - }) - const shutdown = owners.disposeAndWait(() => owners.retire(child)) - expect(nested).toBe(shutdown) - expect(child.disposeAndWait).toHaveBeenCalledOnce() - pending.resolve() - await shutdown -}) - -it('refuses to acknowledge a supervisor without a physical-disposal API', async () => { - const owners = new RuntimeWatcherDisposalOwners() - const legacy = { dispose: vi.fn(), subscribe: vi.fn() } - await expect(owners.disposeAndWait(() => owners.retire(legacy))).rejects.toMatchObject({ - errors: [ - expect.objectContaining({ message: 'watcher_supervisor_awaited_disposal_unavailable' }) - ] - }) - expect(legacy.dispose).toHaveBeenCalledOnce() -}) diff --git a/src/main/ipc/runtime-watcher-disposal-owners.ts b/src/main/ipc/runtime-watcher-disposal-owners.ts deleted file mode 100644 index e88c741844f..00000000000 --- a/src/main/ipc/runtime-watcher-disposal-owners.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { RuntimeWatcherPoolSupervisor } from './runtime-watcher-pool-state' - -type Attempt = { pending: Promise | null; failure?: unknown } - -type Completion = { promise: Promise; resolve: () => void; reject: (error: unknown) => void } - -// Why not Promise.withResolvers: the relay bundles this pool and still targets Node 18 hosts. -function createCompletion(): Completion { - let resolve!: () => void - let reject!: (error: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -export class RuntimeWatcherDisposalOwners { - private readonly retained = new Map() - private shutdown: Promise | null = null - - retire(owner: RuntimeWatcherPoolSupervisor): void { - if (!this.retained.has(owner)) { - this.start(owner) - } - } - - disposeAndWait(disposePool: () => void): Promise { - if (this.shutdown) { - return this.shutdown - } - const retry = [...this.retained].filter(([, attempt]) => !attempt.pending) - const completion = createCompletion() - this.shutdown = completion.promise - const failures: unknown[] = [] - try { - disposePool() - } catch (error) { - failures.push(error) - } - for (const [owner, attempt] of retry) { - if (this.retained.get(owner) === attempt) { - this.start(owner) - } - } - const pending = [...this.retained.values()].map( - (attempt) => attempt.pending ?? Promise.reject(attempt.failure) - ) - void Promise.allSettled(pending).then((results) => { - for (const result of results) { - if (result.status === 'rejected') { - failures.push(result.reason) - } - } - this.shutdown = null - if (failures.length > 0) { - completion.reject(new AggregateError(failures, 'watcher_pool_shutdown_incomplete')) - } else { - completion.resolve() - } - }) - return completion.promise - } - - private start(owner: RuntimeWatcherPoolSupervisor): void { - const completion = createCompletion() - const attempt: Attempt = { pending: completion.promise } - this.retained.set(owner, attempt) - void completion.promise.catch(() => {}) - const failed = (error: unknown): void => { - attempt.pending = null - attempt.failure = error - completion.reject(error) - } - try { - if (!owner.disposeAndWait) { - owner.dispose() - throw new Error('watcher_supervisor_awaited_disposal_unavailable') - } - void owner.disposeAndWait().then(() => { - if (this.retained.get(owner) === attempt) { - this.retained.delete(owner) - } - completion.resolve() - }, failed) - } catch (error) { - failed(error) - } - } -} diff --git a/src/main/ipc/runtime-watcher-pool-shutdown.test.ts b/src/main/ipc/runtime-watcher-pool-shutdown.test.ts deleted file mode 100644 index 1f37f3540c5..00000000000 --- a/src/main/ipc/runtime-watcher-pool-shutdown.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool' -import type { WatcherProcessHooks } from './parcel-watcher-process-subscription' -import { WatcherProcessFailure } from './parcel-watcher-process-failure' - -function supervisor() { - const pending = Promise.withResolvers() - let hooks: WatcherProcessHooks | undefined - return { - pending, - dispose: vi.fn(), - disposeAndWait: vi.fn(() => pending.promise), - subscribe: vi.fn( - async ( - _dir: string, - _callback: unknown, - _options: unknown, - options?: WatcherProcessHooks - ) => { - hooks = options - return { unsubscribe: vi.fn(async () => {}) } - } - ), - fail: () => - hooks?.onTerminalError?.( - new WatcherProcessFailure('failed', 'supervisor', 'process_unavailable') - ) - } -} - -it('awaits retired and replacement supervisors after logical retirement removed the old slot', async () => { - const old = supervisor() - const current = supervisor() - const create = vi.fn().mockReturnValueOnce(old).mockReturnValueOnce(current) - const pool = new RuntimeWatcherProcessPool({ createSupervisor: create }) - await pool.subscribe('/first', vi.fn(), {}) - old.fail() - await new Promise((resolve) => setImmediate(resolve)) - expect(old.disposeAndWait).toHaveBeenCalledOnce() - await pool.subscribe('/second', vi.fn(), {}) - const finished = vi.fn() - const shutdown = pool.disposeAndWait() - expect(pool.disposeAndWait()).toBe(shutdown) - const result = shutdown.then(finished) - current.pending.resolve() - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - await expect(pool.subscribe('/third', vi.fn(), {})).rejects.toThrow('disposed') - expect(create).toHaveBeenCalledTimes(2) - old.pending.resolve() - await result -}) - -it('retains failure after synchronous pool disposal and retries it explicitly', async () => { - const child = supervisor() - const failure = new Error('child still running') - child.disposeAndWait.mockRejectedValueOnce(failure) - const pool = new RuntimeWatcherProcessPool({ createSupervisor: () => child }) - await pool.subscribe('/first', vi.fn(), {}) - const shutdown = pool.disposeAndWait() - await expect(shutdown).rejects.toMatchObject({ errors: [failure] }) - const retry = pool.disposeAndWait() - expect(child.disposeAndWait).toHaveBeenCalledTimes(2) - child.pending.resolve() - await retry -}) - -it('does not duplicate termination when a queued retirement races with synchronous disposal', async () => { - const child = supervisor() - const pool = new RuntimeWatcherProcessPool({ createSupervisor: () => child }) - await pool.subscribe('/first', vi.fn(), {}) - child.fail() - pool.dispose() - const shutdown = pool.disposeAndWait() - await new Promise((resolve) => setImmediate(resolve)) - expect(child.disposeAndWait).toHaveBeenCalledOnce() - child.pending.resolve() - await shutdown -}) diff --git a/src/main/ipc/runtime-watcher-pool-state.ts b/src/main/ipc/runtime-watcher-pool-state.ts index a99c9b93895..e6c016b9844 100644 --- a/src/main/ipc/runtime-watcher-pool-state.ts +++ b/src/main/ipc/runtime-watcher-pool-state.ts @@ -1,7 +1,6 @@ import type { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' -export type RuntimeWatcherPoolSupervisor = Pick & - Partial> +export type RuntimeWatcherPoolSupervisor = Pick export type RuntimeWatcherPoolSlot = { supervisor: RuntimeWatcherPoolSupervisor @@ -11,13 +10,6 @@ export type RuntimeWatcherPoolSlot = { disposed: boolean } -export function activeWatcherSlots( - slots: ReadonlySet, - isolated: boolean -): RuntimeWatcherPoolSlot[] { - return [...slots].filter((slot) => slot.isolated === isolated && !slot.retired) -} - export type RuntimeWatcherPoolAssignment = { slot: RuntimeWatcherPoolSlot leases: number diff --git a/src/main/ipc/runtime-watcher-process-pool.ts b/src/main/ipc/runtime-watcher-process-pool.ts index 852fd5abdf4..4bafc38208d 100644 --- a/src/main/ipc/runtime-watcher-process-pool.ts +++ b/src/main/ipc/runtime-watcher-process-pool.ts @@ -7,17 +7,15 @@ import type { WatcherProcessSubscription } from './parcel-watcher-process-subscription' import { RuntimeWatcherPendingAssignment } from './runtime-watcher-pending-assignment' -import { RuntimeWatcherDisposalOwners } from './runtime-watcher-disposal-owners' import { RuntimeWatcherPoolLifecycle } from './runtime-watcher-pool-lifecycle' import { RuntimeWatcherPredecessorBarriers } from './runtime-watcher-predecessor-barriers' import { RuntimeWatcherQuarantineQueue } from './runtime-watcher-quarantine-queue' import { handleRuntimeWatcherSubscriptionFailure } from './runtime-watcher-subscription-failure' -import { - activeWatcherSlots, - type RuntimeWatcherPoolAssignment, - type RuntimeWatcherPoolSlot, - type RuntimeWatcherPoolSupervisor, - type RuntimeWatcherProcessPoolOptions +import type { + RuntimeWatcherPoolAssignment, + RuntimeWatcherPoolSlot, + RuntimeWatcherPoolSupervisor, + RuntimeWatcherProcessPoolOptions } from './runtime-watcher-pool-state' export type { RuntimeWatcherProcessPoolOptions } from './runtime-watcher-pool-state' @@ -41,7 +39,6 @@ export class RuntimeWatcherProcessPool { RuntimeWatcherPendingAssignment >() private readonly lifecycle = new RuntimeWatcherPoolLifecycle() - private disposalOwners = new RuntimeWatcherDisposalOwners() private readonly predecessorBarriers = new RuntimeWatcherPredecessorBarriers() private readonly quarantineQueue: RuntimeWatcherQuarantineQueue @@ -145,12 +142,9 @@ export class RuntimeWatcherProcessPool { resetForTest(): void { this.dispose() - this.disposalOwners = new RuntimeWatcherDisposalOwners() this.lifecycle.reset() } - disposeAndWait = (): Promise => this.disposalOwners.disposeAndWait(() => this.dispose()) - forgetRoot(dir: string): void { // Physical subscriptions release their assignment through unsubscribe or // terminal callbacks. This clears fault history after setup gives up. @@ -227,7 +221,9 @@ export class RuntimeWatcherProcessPool { } private sharedSlot(): RuntimeWatcherPoolSlot { - const sharedSlots = activeWatcherSlots(this.activeSlots, false) + const sharedSlots = Array.from(this.activeSlots).filter( + (slot) => !slot.isolated && !slot.retired + ) if (sharedSlots.length < this.maxSharedSupervisors) { return this.createSlot(false) } @@ -237,7 +233,9 @@ export class RuntimeWatcherProcessPool { } private quarantineSlot(dir: string): RuntimeWatcherPoolSlot | Promise { - const quarantineSlots = activeWatcherSlots(this.activeSlots, true) + const quarantineSlots = Array.from(this.activeSlots).filter( + (slot) => slot.isolated && !slot.retired + ) if (quarantineSlots.length < this.maxQuarantineSupervisors) { return this.createSlot(true) } @@ -275,11 +273,7 @@ export class RuntimeWatcherProcessPool { slot.roots.clear() // Why: failAllSubscriptions is still iterating callbacks; defer disposal // so every logical root receives the supervisor failure first. - const owners = this.disposalOwners queueMicrotask(() => { - if (this.disposalOwners !== owners) { - return - } this.disposeSlot(slot) this.drainQuarantineWaiters() }) @@ -314,7 +308,8 @@ export class RuntimeWatcherProcessPool { private drainQuarantineWaiters(): void { while ( this.quarantineQueue.length > 0 && - activeWatcherSlots(this.activeSlots, true).length < this.maxQuarantineSupervisors + Array.from(this.activeSlots).filter((slot) => slot.isolated && !slot.retired).length < + this.maxQuarantineSupervisors ) { this.quarantineQueue.grantNext(this.createSlot(true)) } @@ -325,7 +320,7 @@ export class RuntimeWatcherProcessPool { return } slot.disposed = true - this.disposalOwners.retire(slot.supervisor) + slot.supervisor.dispose() this.allSlots.delete(slot) } } diff --git a/src/main/ipc/ssh-active-relay-sessions.ts b/src/main/ipc/ssh-active-relay-sessions.ts index 3c12496f7d4..69b781be41d 100644 --- a/src/main/ipc/ssh-active-relay-sessions.ts +++ b/src/main/ipc/ssh-active-relay-sessions.ts @@ -1,8 +1,5 @@ import type { SshRelaySession } from '../ssh/ssh-relay-session' -import { - setDirectSshAuthorityResolver, - setSshActiveMultiplexerResolver -} from '../ssh/ssh-target-registry' +import { setSshActiveMultiplexerResolver } from '../ssh/ssh-target-registry' import { setWorktreeRemovalSshHostHomeResolver } from '../worktree-removal-execution-host-route' // One session per SSH target owns the whole relay lifecycle (mux, providers, abort controller, state machine). @@ -25,5 +22,3 @@ export function getActiveSshHostHomeDirectory(targetId: string): string | null { } setWorktreeRemovalSshHostHomeResolver(getActiveSshHostHomeDirectory) - -setDirectSshAuthorityResolver((targetId) => activeSessions.has(targetId)) diff --git a/src/main/ipc/ssh-connect-attempt-registry.ts b/src/main/ipc/ssh-connect-attempt-registry.ts index 5aba526d683..5a1e3632982 100644 --- a/src/main/ipc/ssh-connect-attempt-registry.ts +++ b/src/main/ipc/ssh-connect-attempt-registry.ts @@ -44,44 +44,3 @@ export const resetRelayInFlight = new Map>() // Why: ssh:testConnection connects then disconnects; suppressing broadcasts during the test avoids worktree cards flashing connected → disconnected. export const testingTargets = new Set() export const testConnectionProbes = new Set>() -const testConnectionProbesByTarget = new Map>>() - -export function hasSshTestConnectionProbes(targetId: string): boolean { - return (testConnectionProbesByTarget.get(targetId)?.size ?? 0) > 0 -} - -export function runSshTestConnectionProbe( - targetId: string, - operation: () => Promise -): Promise { - const probes = testConnectionProbesByTarget.get(targetId) ?? new Set>() - let probe!: Promise - // Publish before connection callbacks can start a reset or shutdown drain. - probe = Promise.resolve() - .then(operation) - .finally(() => { - testConnectionProbes.delete(probe) - probes.delete(probe) - if (probes.size === 0 && testConnectionProbesByTarget.get(targetId) === probes) { - testConnectionProbesByTarget.delete(targetId) - testingTargets.delete(targetId) - credentialRequestedForTarget.delete(targetId) - } - }) - probes.add(probe) - testConnectionProbesByTarget.set(targetId, probes) - testConnectionProbes.add(probe) - testingTargets.add(targetId) - return probe -} - -/** Reserve target admission before joining; failed probes still own cleanup until settled. */ -export async function awaitSshTestConnectionProbes(targetId: string): Promise { - while (true) { - const probes = testConnectionProbesByTarget.get(targetId) - if (!probes?.size) { - return - } - await Promise.allSettled(probes) - } -} diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts index 946dfed7bf9..9ea533c77c3 100644 --- a/src/main/ipc/ssh-connection-handlers.ts +++ b/src/main/ipc/ssh-connection-handlers.ts @@ -23,7 +23,8 @@ import { credentialRequestedForTarget, invalidateConnectAttempt, resetRelayInFlight, - runSshTestConnectionProbe + testConnectionProbes, + testingTargets } from './ssh-connect-attempt-registry' import { connectTarget } from './ssh-connect-flow' import { connectionManager, persistedStore } from './ssh-ipc-context' @@ -265,16 +266,18 @@ export function registerSshConnectionHandlers(): void { } } + testingTargets.add(args.targetId) // Why a tracked promise and not just the id: a probe holds a real transport that no session owns, // so shutdown has to be able to join it before the final drain disconnects what is left. - const probe = runSshTestConnectionProbe(args.targetId, async () => { + const probe = (async () => { // Why: a probe transport opened after the shutdown drain would outlive orderly teardown. assertSshConnectsNotFenced() const conn = await connectionManager!.connect(target) const state = conn.getState() await connectionManager!.disconnect(args.targetId) return state - }) + })() + testConnectionProbes.add(probe) try { return { success: true, state: await probe } } catch (err) { @@ -282,6 +285,11 @@ export function registerSshConnectionHandlers(): void { success: false, error: err instanceof Error ? err.message : String(err) } + } finally { + testConnectionProbes.delete(probe) + testingTargets.delete(args.targetId) + // Why: clear so a test's credential prompt doesn't leave lastRequiredPassphrase=true and defer this target at startup. + credentialRequestedForTarget.delete(args.targetId) } }) } diff --git a/src/main/ipc/ssh-host-sleep-reconnect.test.ts b/src/main/ipc/ssh-host-sleep-reconnect.test.ts index e0f9721e6e0..1994e8c1a0e 100644 --- a/src/main/ipc/ssh-host-sleep-reconnect.test.ts +++ b/src/main/ipc/ssh-host-sleep-reconnect.test.ts @@ -4,16 +4,12 @@ const manager = vi.hoisted(() => { const connection = {} return { getConnection: vi.fn(() => connection), reconnect: vi.fn(async () => {}) } }) -const recoverManagedTunnels = vi.hoisted(() => vi.fn(async () => {})) vi.mock('electron', async () => { const { EventEmitter } = await import('node:events') return { powerMonitor: new EventEmitter() } }) vi.mock('./ssh-ipc-context', () => ({ connectionManager: manager })) -vi.mock('../ssh/orcad-managed-tunnel', () => ({ - recoverOrcadManagedTunnelsAfterHostResume: recoverManagedTunnels -})) import { powerMonitor } from 'electron' import { activeSessions } from './ssh-active-relay-sessions' @@ -60,24 +56,4 @@ describe('host sleep reconnect in plain SSH mode', () => { powerMonitor.emit('resume') await vi.waitFor(() => expect(manager.reconnect).toHaveBeenCalledWith('target-1')) }) - - it('recovers managed tunnels with the same probe policy, even with no relay sessions', async () => { - registerPowerMonitorReconnect(() => '/user-data') - powerMonitor.emit('resume') - await vi.waitFor(() => - expect(recoverManagedTunnels).toHaveBeenCalledWith('/user-data', { - attempts: 2, - timeoutMs: 5_000 - }) - ) - }) - - it('leaves managed tunnels alone when the caller supplies no profile path', async () => { - const session = plainSession(async () => true) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resume path only calls the methods stubbed here. - activeSessions.set('target-1', session as never) - registerPowerMonitorReconnect() - await resumeAndSettle() - expect(recoverManagedTunnels).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ipc/ssh-host-sleep-reconnect.ts b/src/main/ipc/ssh-host-sleep-reconnect.ts index 3c559059f45..731946e3cfc 100644 --- a/src/main/ipc/ssh-host-sleep-reconnect.ts +++ b/src/main/ipc/ssh-host-sleep-reconnect.ts @@ -1,5 +1,4 @@ import { powerMonitor } from 'electron' -import { recoverOrcadManagedTunnelsAfterHostResume } from '../ssh/orcad-managed-tunnel' import type { SshRelaySession } from '../ssh/ssh-relay-session' import { activeSessions } from './ssh-active-relay-sessions' import { connectionManager } from './ssh-ipc-context' @@ -33,7 +32,7 @@ async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise string): void { +export function registerPowerMonitorReconnect(): void { powerMonitorUnsubscribe?.() const onSuspend = (): void => { for (const session of activeSessions.values()) { @@ -67,19 +66,6 @@ export function registerPowerMonitorReconnect(getUserDataPath?: () => string): v } })() } - // Why separate: managed tunnels ride their own connections, not relay sessions. - if (getUserDataPath) { - void recoverOrcadManagedTunnelsAfterHostResume(getUserDataPath(), { - attempts: RESUME_PROBE_ATTEMPTS, - timeoutMs: RESUME_PROBE_TIMEOUT_MS - }).catch((err) => { - console.warn( - `[ssh] Failed to recover a managed Orca tunnel after system resume: ${ - err instanceof Error ? err.message : String(err) - }` - ) - }) - } } powerMonitor.on('suspend', onSuspend) powerMonitor.on('resume', onResume) diff --git a/src/main/ipc/ssh-ipc-module-mocks.ts b/src/main/ipc/ssh-ipc-module-mocks.ts index af05f0b1204..0855a739fb2 100644 --- a/src/main/ipc/ssh-ipc-module-mocks.ts +++ b/src/main/ipc/ssh-ipc-module-mocks.ts @@ -144,7 +144,6 @@ export function createSshIpcMocks(): SshIpcMocks { installSshPtySourceCancellationPublisher: vi.fn().mockReturnValue(() => {}) }, sshConnectionStore: { - isRuntimeOwnedSshTarget: (target: { owner?: unknown }) => target.owner !== undefined, SshConnectionStore: class MockSshConnectionStore { constructor() { return mockSshStore diff --git a/src/main/ipc/ssh-renderer-broadcast.ts b/src/main/ipc/ssh-renderer-broadcast.ts index 8dbc1cfede1..999b8e9a346 100644 --- a/src/main/ipc/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh-renderer-broadcast.ts @@ -12,10 +12,8 @@ import { enrichSshForwardEntries, getWorktreeIdsForConnection } from '../ports/ssh-advertised-url-enrichment' -import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode' -import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { activeSessions } from './ssh-active-relay-sessions' import { connectionManager, @@ -32,9 +30,8 @@ export function broadcastSshState( targetId: string, state: SshConnectionState ): void { - const target = getSshTargetRegistryStore()?.getTarget(targetId) - // Why: owned targets are hidden from clients; broadcasting them leaks internal transport state into persisted reconnect hints. - if (isRuntimeOwnedSshTargetId(targetId) || (target && isRuntimeOwnedSshTarget(target))) { + // Why: runtime-owned (ephemeral-VM) targets are hidden from the renderer, so broadcasting their state only triggers wasted listTargets() lookups. + if (isRuntimeOwnedSshTargetId(targetId)) { currentRuntime?.invalidateSshWorktreeScanCache?.(targetId) return } diff --git a/src/main/ipc/ssh-shutdown-drain.ts b/src/main/ipc/ssh-shutdown-drain.ts index 93a25071bfc..f66db066c01 100644 --- a/src/main/ipc/ssh-shutdown-drain.ts +++ b/src/main/ipc/ssh-shutdown-drain.ts @@ -61,34 +61,22 @@ async function settleTasksWithinMs( return { timedOut: [...pending], errors } } -/** Which targets the quit drain may detach; a target it may not keeps its session and transport. */ -export type SshShutdownDetachPredicate = (targetId: string) => boolean - -const detachEveryTarget: SshShutdownDetachPredicate = () => true - -function sshShutdownTasks( - targetIds: readonly string[], - mayDetach: SshShutdownDetachPredicate -): SshShutdownTask[] { +function sshShutdownTasks(targetIds: readonly string[]): SshShutdownTask[] { return [ ...targetIds - .filter((targetId) => activeSessions.has(targetId) && mayDetach(targetId)) + .filter((targetId) => activeSessions.has(targetId)) .map((targetId) => ({ targetId, promise: teardownActiveSshSession(targetId, (session) => session.detachAndPersist()) })), - { - targetId: '*transports', - promise: connectionManager?.disconnectAll(mayDetach) ?? Promise.resolve() - } + { targetId: '*transports', promise: connectionManager?.disconnectAll() ?? Promise.resolve() } ] } async function drainSshShutdown( targetIds: readonly string[], inFlight: readonly SshShutdownTask[], - detachErrors: readonly unknown[] = [], - mayDetach: SshShutdownDetachPredicate = detachEveryTarget + detachErrors: readonly unknown[] = [] ): Promise { const deadline = Date.now() + SSH_SHUTDOWN_BUDGET_MS const unfinished: SshShutdownUnfinished[] = [] @@ -108,12 +96,12 @@ async function drainSshShutdown( return settled.timedOut.length === 0 } - await runPhase('drain', sshShutdownTasks(targetIds, mayDetach)) + await runPhase('drain', sshShutdownTasks(targetIds)) // Why a second drain after the join: a connect paused in old-session teardown still publishes its // replacement session and opens a transport before it reaches the cancellation checkpoint, so the // first drain can miss both. if (await runPhase('in-flight-join', inFlight)) { - await runPhase('final-drain', sshShutdownTasks([...activeSessions.keys()], mayDetach)) + await runPhase('final-drain', sshShutdownTasks([...activeSessions.keys()])) } if (errors.length > 0 || unfinished.length > 0) { @@ -135,9 +123,7 @@ async function drainSshShutdown( // // Why no fence latch here: the committed quit path sets it before calling this, so it is already on // for the snapshot below. Called without that gate (tests), this degrades to a plain drain. -export function beginSshShutdown( - mayDetach: SshShutdownDetachPredicate = detachEveryTarget -): Promise { +export function beginSshShutdown(): Promise { if (sshShutdownDrain) { return sshShutdownDrain } @@ -150,18 +136,13 @@ export function beginSshShutdown( ...[...testConnectionProbes].map((promise) => ({ targetId: '*probe', promise })) ] for (const targetId of Array.from(connectInFlight.keys())) { - if (mayDetach(targetId)) { - invalidateConnectAttempt(targetId) - } + invalidateConnectAttempt(targetId) } const targetIds = [...activeSessions.keys()] // Why before any await: this is the whole point of the split. Each session marks its recovery lease // detached in memory now, and the final flush persists it — the remote PTYs keep running. const detachErrors: unknown[] = [] - for (const [targetId, session] of activeSessions) { - if (!mayDetach(targetId)) { - continue - } + for (const session of activeSessions.values()) { // Why per-session: this runs synchronously inside a non-async will-quit listener, so one throw // (teardownProviders -> webContents.send on a destroyed renderer, routine on quit) would escape // it and skip every later session, the drain assignment, and the store flush that persists all @@ -172,6 +153,6 @@ export function beginSshShutdown( detachErrors.push(error) } } - sshShutdownDrain = drainSshShutdown(targetIds, inFlight, detachErrors, mayDetach) + sshShutdownDrain = drainSshShutdown(targetIds, inFlight, detachErrors) return sshShutdownDrain } diff --git a/src/main/ipc/ssh-state-broadcast-fanout.test.ts b/src/main/ipc/ssh-state-broadcast-fanout.test.ts index 9c9ae661237..e7f6480c261 100644 --- a/src/main/ipc/ssh-state-broadcast-fanout.test.ts +++ b/src/main/ipc/ssh-state-broadcast-fanout.test.ts @@ -175,41 +175,6 @@ describe('SSH IPC handlers', () => { expect(runtime.notifySshStateChanged).not.toHaveBeenCalled() }) - it('keeps claimed managed-orcad SSH state off clients under the original target id', () => { - const runtime = { - onPtyData: vi.fn(), - onPtyExit: vi.fn(), - invalidateSshWorktreeScanCache: vi.fn(), - notifySshStateChanged: vi.fn() - } - registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) - mockSshStore.getTarget.mockReturnValue({ - id: 'ssh-1', - label: 'Managed orcad host', - host: 'example.com', - port: 22, - username: 'deploy', - owner: { type: 'on-demand-runtime', runtimeId: 'managed-orcad:environment-1' } - } satisfies SshTarget) - const callbacks = mockConnectionManager.callbacksRef.current as { - onStateChange: (targetId: string, state: SshConnectionState) => void - } - - callbacks.onStateChange('ssh-1', { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - - expect(runtime.invalidateSshWorktreeScanCache).toHaveBeenCalledWith('ssh-1') - expect(runtime.notifySshStateChanged).not.toHaveBeenCalled() - expect(mockWindow.webContents.send).not.toHaveBeenCalledWith( - 'ssh:state-changed', - expect.anything() - ) - }) - it('invalidates runtime scans from hidden SSH state broadcasts', () => { const runtime = { onPtyData: vi.fn(), diff --git a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts b/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts deleted file mode 100644 index 522e7487619..00000000000 --- a/src/main/ipc/ssh-target-crud-handlers-ownership.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import type { SshTarget } from '../../shared/ssh-types' - -const mocks = vi.hoisted(() => { - const state: { target?: SshTarget } = {} - return { handle: vi.fn(), remove: vi.fn(), state, addTarget: vi.fn(), updateTarget: vi.fn() } -}) - -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../ssh/ssh-target-registry', () => ({ - getSshTargetRegistryStore: () => ({ - getTarget: () => mocks.state.target, - addTarget: mocks.addTarget, - updateTarget: mocks.updateTarget, - lastRepoReadoptions: [] - }) -})) -vi.mock('./ssh-session-teardown', () => ({ removeRegisteredSshTarget: mocks.remove })) -vi.mock('./ssh-ipc-context', () => ({ getCurrentMainWindow: () => null })) - -const { registerSshTargetCrudHandlers } = await import('./ssh-target-crud-handlers') - -function handler(channel: string): (_event: unknown, args: unknown) => unknown { - const registration = mocks.handle.mock.calls.find(([name]) => name === channel) - if (!registration) { - throw new Error(`${channel} handler was not registered`) - } - return registration[1] -} - -const target: SshTarget = { id: 'ssh-1', label: 'host', host: 'host', port: 22, username: 'dev' } - -describe('SSH target CRUD against managed orcad targets', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.state.target = { ...target, owner: createManagedOrcadSshOwner('environment-1') } - registerSshTargetCrudHandlers() - }) - - it('refuses to edit or remove a target a managed server owns', async () => { - expect(() => - handler('ssh:updateTarget')(null, { id: 'ssh-1', updates: { host: 'elsewhere' } }) - ).toThrow('cannot be edited') - await expect(handler('ssh:removeTarget')(null, { id: 'ssh-1' })).rejects.toThrow( - 'cannot be removed' - ) - expect(mocks.updateTarget).not.toHaveBeenCalled() - expect(mocks.remove).not.toHaveBeenCalled() - }) - - it('refuses a target with a pending provisioning request too', () => { - mocks.state.target = { - ...target, - orcadProvisioning: { requestId: 'request-1', name: 'Managed' } - } - expect(() => handler('ssh:updateTarget')(null, { id: 'ssh-1', updates: {} })).toThrow( - 'cannot be edited' - ) - }) - - it('never lets the renderer write a provisioning intent', () => { - mocks.state.target = target - handler('ssh:updateTarget')(null, { - id: 'ssh-1', - updates: { label: 'renamed', orcadProvisioning: { requestId: 'x', name: 'y' } } - }) - handler('ssh:addTarget')(null, { - target: { ...target, orcadProvisioning: { requestId: 'x', name: 'y' } } - }) - expect(mocks.updateTarget).toHaveBeenCalledWith('ssh-1', { label: 'renamed' }) - expect(mocks.addTarget.mock.calls[0]?.[0]).not.toHaveProperty('orcadProvisioning') - }) -}) diff --git a/src/main/ipc/ssh-target-crud-handlers.ts b/src/main/ipc/ssh-target-crud-handlers.ts index 3a045f32107..e8b7a95c478 100644 --- a/src/main/ipc/ssh-target-crud-handlers.ts +++ b/src/main/ipc/ssh-target-crud-handlers.ts @@ -11,7 +11,6 @@ import { } from '../ssh/ssh-config-host-picker' import { rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' -import { isRuntimeOwnedSshTarget } from '../ssh/ssh-connection-store' import { getCurrentMainWindow } from './ssh-ipc-context' import { removeRegisteredSshTarget } from './ssh-session-teardown' @@ -35,26 +34,18 @@ function takeRepoReadoptions(): SshRepoReadoption[] { return repoReadoptions } -// Why: generations, provisioning and the runtime ladder cache are main-owned; a renderer must not forge them. +// Why: generations and the runtime ladder cache are main-owned; a renderer must not forge them. function omitRendererSshTargetGeneration< - T extends { generation?: unknown; orcadProvisioning?: unknown; remoteRuntimeResolution?: unknown } ->(value: T): Omit { + T extends { generation?: unknown; remoteRuntimeResolution?: unknown } +>(value: T): Omit { const { generation: _generation, - orcadProvisioning: _orcadProvisioning, remoteRuntimeResolution: _remoteRuntimeResolution, ...rest } = value return rest } -function assertNotRuntimeOwned(targetId: string, action: string): void { - const target = getSshTargetRegistryStore()!.getTarget(targetId) - if (target && isRuntimeOwnedSshTarget(target)) { - throw new Error(`Managed runtime SSH targets cannot be ${action} from SSH settings.`) - } -} - export function registerSshTargetCrudHandlers(): void { ipcMain.handle('ssh:listTargets', () => { return getSshTargetRegistryStore()!.listTargets() @@ -76,7 +67,6 @@ export function registerSshTargetCrudHandlers(): void { ipcMain.handle( 'ssh:updateTarget', (_event, args: { id: string; updates: SshTargetUpdateInput }) => { - assertNotRuntimeOwned(args.id, 'edited') return getSshTargetRegistryStore()!.updateTarget( args.id, omitRendererSshTargetGeneration(args.updates) @@ -85,7 +75,6 @@ export function registerSshTargetCrudHandlers(): void { ) ipcMain.handle('ssh:removeTarget', async (_event, args: { id: string }) => { - assertNotRuntimeOwned(args.id, 'removed') await removeRegisteredSshTarget(args.id) }) diff --git a/src/main/ipc/ssh-target-lifecycle-queue.ts b/src/main/ipc/ssh-target-lifecycle-queue.ts index f6c5221aa5c..014a5037175 100644 --- a/src/main/ipc/ssh-target-lifecycle-queue.ts +++ b/src/main/ipc/ssh-target-lifecycle-queue.ts @@ -1,27 +1,25 @@ // Serializes disconnect/remove/terminate/reset for a single SSH target so they cannot interleave. export const targetLifecycleInFlight = new Map>() -export function runTargetLifecycle(targetId: string, operation: () => Promise): Promise { +export function runTargetLifecycle( + targetId: string, + operation: () => Promise +): Promise { const prior = targetLifecycleInFlight.get(targetId) const operationPromise = (async () => { if (prior) { await prior.catch(() => undefined) } - return operation() + await operation() })() let trackedPromise!: Promise - trackedPromise = operationPromise - .then( - () => undefined, - () => undefined - ) - .finally(() => { - if (targetLifecycleInFlight.get(targetId) === trackedPromise) { - targetLifecycleInFlight.delete(targetId) - } - }) + trackedPromise = operationPromise.finally(() => { + if (targetLifecycleInFlight.get(targetId) === trackedPromise) { + targetLifecycleInFlight.delete(targetId) + } + }) targetLifecycleInFlight.set(targetId, trackedPromise) - return operationPromise + return trackedPromise } export async function awaitTargetLifecycle(targetId: string): Promise { diff --git a/src/main/ipc/ssh-test-connection-probe-registry.test.ts b/src/main/ipc/ssh-test-connection-probe-registry.test.ts deleted file mode 100644 index c8b75e1c1ea..00000000000 --- a/src/main/ipc/ssh-test-connection-probe-registry.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { - awaitSshTestConnectionProbes, - credentialRequestedForTarget, - runSshTestConnectionProbe, - testConnectionProbes, - testingTargets -} from './ssh-connect-attempt-registry' - -function deferred() { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -it('publishes global and target tracking before callback-capable work', async () => { - const close = deferred() - let joined!: Promise - const finished = vi.fn() - const operation = vi.fn(async () => { - expect(testConnectionProbes.has(probe)).toBe(true) - expect(testingTargets.has('published')).toBe(true) - joined = awaitSshTestConnectionProbes('published').then(finished) - await close.promise - return 'connected' - }) - const probe = runSshTestConnectionProbe('published', operation) - expect(operation).not.toHaveBeenCalled() - expect(testConnectionProbes.has(probe)).toBe(true) - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - close.resolve() - await expect(probe).resolves.toBe('connected') - await joined - expect(finished).toHaveBeenCalledTimes(1) - expect(testConnectionProbes.has(probe)).toBe(false) - expect(testingTargets.has('published')).toBe(false) -}) - -it('joins every admitted probe and retains callback suppression until the final settlement', async () => { - const first = deferred() - const second = deferred() - const a = runSshTestConnectionProbe('multiple', () => first.promise) - const b = runSshTestConnectionProbe('multiple', () => second.promise) - credentialRequestedForTarget.add('multiple') - const finished = vi.fn() - const joined = awaitSshTestConnectionProbes('multiple').then(finished) - first.resolve() - await a - expect(testingTargets.has('multiple')).toBe(true) - expect(credentialRequestedForTarget.has('multiple')).toBe(true) - expect(finished).not.toHaveBeenCalled() - second.resolve() - await b - await joined - expect(testingTargets.has('multiple')).toBe(false) - expect(credentialRequestedForTarget.has('multiple')).toBe(false) -}) - -it('joins failed probes without mistaking rejection for unfinished work', async () => { - const failure = new Error('connection failed') - const probe = runSshTestConnectionProbe('failed', async () => { - throw failure - }) - const result = expect(probe).rejects.toBe(failure) - await expect(awaitSshTestConnectionProbes('failed')).resolves.toBeUndefined() - await result - expect(testConnectionProbes.has(probe)).toBe(false) - expect(testingTargets.has('failed')).toBe(false) -}) - -it('does not wait for another target', async () => { - const close = deferred() - const probe = runSshTestConnectionProbe('other', () => close.promise) - await awaitSshTestConnectionProbes('absent') - expect(testConnectionProbes.has(probe)).toBe(true) - close.resolve() - await probe -}) - -it('rechecks the target after a joined snapshot settles', async () => { - const first = deferred() - const second = deferred() - const a = runSshTestConnectionProbe('later', () => first.promise) - const finished = vi.fn() - const joined = awaitSshTestConnectionProbes('later').then(finished) - const b = runSshTestConnectionProbe('later', () => second.promise) - first.resolve() - await a - await Promise.resolve() - expect(finished).not.toHaveBeenCalled() - second.resolve() - await b - await joined - expect(finished).toHaveBeenCalledTimes(1) -}) diff --git a/src/main/ipc/ssh-test-probe-presence.test.ts b/src/main/ipc/ssh-test-probe-presence.test.ts deleted file mode 100644 index 73287c4798f..00000000000 --- a/src/main/ipc/ssh-test-probe-presence.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, it } from 'vitest' -import { - hasSshTestConnectionProbes, - runSshTestConnectionProbe, - testingTargets -} from './ssh-connect-attempt-registry' - -it('publishes target-scoped presence before callbacks and retains it until settlement', async () => { - const target = 'probe-presence-success' - const work = Promise.withResolvers() - const probe = runSshTestConnectionProbe(target, async () => { - expect(hasSshTestConnectionProbes(target)).toBe(true) - await work.promise - }) - expect(hasSshTestConnectionProbes(target)).toBe(true) - expect(hasSshTestConnectionProbes('unrelated')).toBe(false) - testingTargets.delete(target) - expect(hasSshTestConnectionProbes(target)).toBe(true) - work.resolve() - await probe - expect(hasSshTestConnectionProbes(target)).toBe(false) -}) - -it('keeps other probes visible after a failed probe settles', async () => { - const target = 'probe-presence-failure' - const work = Promise.withResolvers() - const pending = runSshTestConnectionProbe(target, () => work.promise) - const failed = runSshTestConnectionProbe(target, async () => { - throw new Error('failed') - }) - await expect(failed).rejects.toThrow('failed') - expect(hasSshTestConnectionProbes(target)).toBe(true) - work.resolve() - await pending - expect(hasSshTestConnectionProbes(target)).toBe(false) -}) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 607f16bb354..9ccf4ad16f6 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -77,8 +77,6 @@ import { broadcastPortForwards, relayStateOverrides } from './ssh-renderer-broad import { resetSshShutdownDrain } from './ssh-shutdown-drain' import { registerSshTargetCrudHandlers } from './ssh-target-crud-handlers' import { targetLifecycleInFlight } from './ssh-target-lifecycle-queue' -import { disposeOrcadManagedTunnels } from '../ssh/orcad-managed-tunnel' -import { getAppEnvironment } from '../../shared/app-environment' const SSH_IPC_CHANNELS = [ 'ssh:listTargets', @@ -211,7 +209,7 @@ export function registerSshHandlers( } }) refreshActiveRelaySessions() - registerPowerMonitorReconnect(() => getAppEnvironment().getPath('userData')) + registerPowerMonitorReconnect() registerSshBrowseHandler(() => connectionManager) setSshConnectionManagerResolver(() => connectionManager) @@ -255,7 +253,6 @@ export async function resetSshHandlerStateForTests(): Promise { resetSshShutdownDrain() await connectionManager?.disconnectAll() - disposeOrcadManagedTunnels() portForwardManager?.dispose() setConnectionManager(null) setSshConnectionManagerResolver(null) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 256cb87fe9f..11b897b81db 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -371,7 +371,7 @@ export function recordWorkspaceLineageForCreatedWorktree( async function spawnLocalStartupAndSetupTerminals(args: { runtime: OrcaRuntimeService | undefined - worktree: Pick + worktree: Worktree startup: CreateWorktreeArgs['startup'] setup: CreateWorktreeResult['setup'] defaultTabs: CreateWorktreeResult['defaultTabs'] @@ -410,18 +410,22 @@ async function spawnLocalStartupAndSetupTerminals(args: { try { // Why: only after `git worktree add` + metadata registration is the path safe for a runtime PTY to boot the agent while setup runs alongside. - const terminal = await runtime.createTerminal(`id:${worktree.id}`, { - command: sequencedStartup.command, - ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), - env: sequencedStartup.env, - ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), - ...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}), - ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), - startupCommandDelivery: sequencedStartup.startupCommandDelivery, - telemetry: sequencedStartup.telemetry, - // Why: the submitting renderer decides whether to open the workspace; activating here yanked users who moved on (#9944). - surfaceOwner: false - }) + const terminal = await runtime.createTerminal( + `id:${worktree.id}`, + { + command: sequencedStartup.command, + ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}), + env: sequencedStartup.env, + ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), + ...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}), + ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), + startupCommandDelivery: sequencedStartup.startupCommandDelivery, + telemetry: sequencedStartup.telemetry, + // Why: the submitting renderer decides whether to open the workspace; activating here yanked users who moved on (#9944). + surfaceOwner: false + }, + worktree + ) startupTerminalHandle = terminal.handle startupTerminal = { spawned: true, @@ -454,21 +458,29 @@ async function spawnLocalStartupAndSetupTerminals(args: { if (!startupTerminalHandle) { throw new Error('startup_terminal_missing') } - await runtime.splitTerminal(startupTerminalHandle, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: setup.envVars, - activate: false, - surfaceOwner: false - }) + await runtime.splitTerminal( + startupTerminalHandle, + { + direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', + command: setupCommand, + env: setup.envVars, + activate: false, + surfaceOwner: false + }, + worktree + ) } else { - await runtime.createTerminal(`id:${worktree.id}`, { - title: 'Setup', - command: setupCommand, - env: setup.envVars, - activate: false, - surfaceOwner: false - }) + await runtime.createTerminal( + `id:${worktree.id}`, + { + title: 'Setup', + command: setupCommand, + env: setup.envVars, + activate: false, + surfaceOwner: false + }, + worktree + ) } didSpawnSetup = true } catch (error) { diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index 7da7756bd11..96e1b804194 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -485,7 +485,7 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).toHaveBeenCalledTimes(listWorktreesCallsAfterCreate) }) - it('completes a create the listing failed and keeps sibling worktrees authorized', async () => { + it('verifies a create without re-listing and keeps sibling worktrees authorized', async () => { const sibling = { path: '/workspace/existing-sibling', head: 'sib123', @@ -504,8 +504,9 @@ describe('registerWorktreeHandlers', () => { sibling ]) await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'existing-sibling' }) + const listingCalls = listWorktreesMock.mock.calls.length - // The create Git could no longer list, recovered by reading the worktree directly. + // Only the new checkout needs verification, even when the full listing is unavailable. listWorktreesMock.mockRejectedValue(new Error('git worktree list timed out.')) describeCreatedWorktreeMock.mockResolvedValue({ path: '/workspace/improve-dashboard', @@ -526,6 +527,7 @@ describe('registerWorktreeHandlers', () => { await expect( resolveRegisteredWorktreePath('/workspace/improve-dashboard', store as never) ).resolves.toBe(resolve('/workspace/improve-dashboard')) + expect(listWorktreesMock).toHaveBeenCalledTimes(listingCalls) }) it('uses branchNameOverride for the git branch while keeping the sanitized worktree path', async () => { @@ -687,7 +689,8 @@ describe('registerWorktreeHandlers', () => { request_kind: 'new' }, surfaceOwner: false - } + }, + expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' }) ) expect(runtimeStub.createTerminal).toHaveBeenNthCalledWith( 2, @@ -701,7 +704,8 @@ describe('registerWorktreeHandlers', () => { }, activate: false, surfaceOwner: false - } + }, + expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' }) ) const startupCreateCall = runtimeStub.createTerminal.mock.calls[0] const setupCreateCall = runtimeStub.createTerminal.mock.calls[1] @@ -818,13 +822,17 @@ describe('registerWorktreeHandlers', () => { expect(runtimeStub.createTerminal).toHaveBeenCalledTimes(1) // A user who moved on must not be scrolled to the new workspace by its setup pane (#9944). - expect(runtimeStub.splitTerminal).toHaveBeenCalledWith('term-startup', { - direction: 'vertical', - command: expect.stringContaining('setup-runner.sh'), - env: expect.any(Object), - activate: false, - surfaceOwner: false - }) + expect(runtimeStub.splitTerminal).toHaveBeenCalledWith( + 'term-startup', + { + direction: 'vertical', + command: expect.stringContaining('setup-runner.sh'), + env: expect.any(Object), + activate: false, + surfaceOwner: false + }, + expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' }) + ) }) it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => { diff --git a/src/main/ipc/worktrees-ssh-provider-authority.test.ts b/src/main/ipc/worktrees-ssh-provider-authority.test.ts index 8eb025da52f..8d14755ab18 100644 --- a/src/main/ipc/worktrees-ssh-provider-authority.test.ts +++ b/src/main/ipc/worktrees-ssh-provider-authority.test.ts @@ -5,7 +5,6 @@ import type { ProviderRequestId } from '../../shared/detected-worktree-provider- import { toSshExecutionHostId } from '../../shared/execution-host' import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees/listing/register-detected-worktree-handlers' import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { hasSshProviderContinuations } from '../ssh/ssh-provider-continuations' import { getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -96,99 +95,6 @@ describe('registerWorktreeHandlers', () => { setupWorktreeHandlers() }) - it.each(['resolve', 'reject'] as const)( - 'retains canceled SSH provider continuations until the provider settles: %s', - async (outcome) => { - const targetId = `continuation-cancel-${outcome}` - const repo = { - id: `repo-${targetId}`, - path: '/remote/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: targetId - } - let resolveProvider!: (value: GitWorktreeInfo[]) => void - let rejectProvider!: (error: Error) => void - const provider = { - listWorktrees: vi.fn( - () => - new Promise((resolve, reject) => { - resolveProvider = resolve - rejectProvider = reject - }) - ) - } - store.getRepos.mockReturnValue([repo]) - getSshGitProviderMock.mockReturnValue(provider) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: request ids are minted by the renderer; any unique string is a valid id here. - const providerRequestId = `request-${targetId}` as ProviderRequestId - const pending = handlers['worktrees:listDetected'](ipcEvent, { - providerRequestId, - repoId: repo.id, - executionHostId: toSshExecutionHostId(targetId), - expectedAuthority: getSshProviderAuthority(targetId) - }) - await Promise.resolve() - expect(provider.listWorktrees).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations(targetId)).toBe(true) - handlers['worktrees:cancelListDetected'](ipcEvent, { providerRequestId }) - await expect(pending).resolves.toMatchObject({ status: 'canceled' }) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(hasSshProviderContinuations(`${targetId}-other`)).toBe(false) - if (outcome === 'resolve') { - resolveProvider([]) - } else { - rejectProvider(new Error('late provider failure')) - } - await vi.waitFor(() => expect(hasSshProviderContinuations(targetId)).toBe(false)) - expect(store.setWorktreeMeta).not.toHaveBeenCalled() - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - } - ) - - it('tracks legacy SSH listing until settlement without tracking a local folder listing', async () => { - const targetId = 'continuation-legacy' - const repo = { - id: 'repo-legacy', - path: '/remote/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: targetId - } - const localRepo: Repo = { - id: 'repo-local', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0, - kind: 'folder' - } - let resolveProvider!: (value: GitWorktreeInfo[]) => void - const provider = { - listWorktrees: vi.fn( - () => - new Promise((resolve) => { - resolveProvider = resolve - }) - ) - } - store.getRepos.mockReturnValue([repo, localRepo]) - getSshGitProviderMock.mockReturnValue(provider) - const pending = handlers['worktrees:listDetected'](ipcEvent, { repoId: repo.id }) - expect(hasSshProviderContinuations(targetId)).toBe(true) - await expect( - handlers['worktrees:listDetected'](ipcEvent, { repoId: localRepo.id }) - ).resolves.toMatchObject({ repoId: localRepo.id, authoritative: true }) - expect(hasSshProviderContinuations(localRepo.id)).toBe(false) - expect(hasSshProviderContinuations(targetId)).toBe(true) - expect(provider.listWorktrees).toHaveBeenCalledOnce() - resolveProvider([]) - await expect(pending).resolves.toMatchObject({ repoId: repo.id, authoritative: true }) - expect(hasSshProviderContinuations(targetId)).toBe(false) - }) - it.each([ ['malformed', 'ssh:%'], ['contradictory', toSshExecutionHostId('target-b')] diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index 1708a561c23..029ffc6171a 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -211,9 +211,10 @@ describe('registerWorktreeHandlers', () => { 'origin/main', { wslDistro: 'Ubuntu' } ) - expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { - wslDistro: 'Ubuntu' - }) + expect(listWorktreesMock).toHaveBeenCalledWith( + '/workspace/repo', + expect.objectContaining({ wslDistro: 'Ubuntu' }) + ) expectEveryGitCallRoutedTo('Ubuntu') }) diff --git a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts index c1bcbbf3d59..bbc7eb2b25e 100644 --- a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts +++ b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts @@ -20,7 +20,8 @@ export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): vo baseBranch: args.baseBranch, runtime, gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo), - prepareCheckout: (base) => prepareWorktreeCreateForRepo(store, repo, base) + prepareCheckout: (base, beforeMaterialization) => + prepareWorktreeCreateForRepo(store, repo, base, beforeMaterialization) }) } catch { // Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there. diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts index de466ff519e..442a70985c8 100644 --- a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts @@ -1,5 +1,4 @@ import { ipcMain } from 'electron' -import { runSshProviderContinuation } from '../../../ssh/ssh-provider-continuations' import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types' import type { HostQualifiedDetectedWorktreeResult, @@ -73,21 +72,16 @@ export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): v }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) : undefined try { - const list = () => - listHostQualifiedDetectedWorktrees( - store, - args, - controller - ? { - signal: controller.signal, - status: () => (timedOut ? 'timed-out' : 'canceled') - } - : undefined - ) - const providerResult = - parsedHost?.kind === 'ssh' - ? runSshProviderContinuation(parsedHost.targetId, list) - : list() + const providerResult = listHostQualifiedDetectedWorktrees( + store, + args, + controller + ? { + signal: controller.signal, + status: () => (timedOut ? 'timed-out' : 'canceled') + } + : undefined + ) return abortedResult ? await Promise.race([providerResult, abortedResult]) : await providerResult @@ -112,19 +106,17 @@ export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): v const connectionId = getSshTargetIdForExecutionHost(getRepoExecutionHostId(repo)) const provider = connectionId ? getSshGitProvider(connectionId) : undefined const authority = connectionId ? { ...getSshProviderAuthority(connectionId) } : undefined - const list = () => - listDetectedWorktreesForCapturedRepo( - store, - repo, - () => - isCapturedRepoCurrent(store, repo) && - (!connectionId || - (getSshGitProvider(connectionId) === provider && - authority !== undefined && - isCurrentSshProviderAuthority(authority))), - provider - ) - const result = await (connectionId ? runSshProviderContinuation(connectionId, list) : list()) + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + () => + isCapturedRepoCurrent(store, repo) && + (!connectionId || + (getSshGitProvider(connectionId) === provider && + authority !== undefined && + isCurrentSshProviderAuthority(authority))), + provider + ) return result && !('providerAbortStatus' in result) ? result : { diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index d5e09d2f8f2..d91743b0185 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -1,10 +1,7 @@ import { ipcMain } from 'electron' import { getLocalWorktreeCatalogVersion } from '../../../local-worktree-scan-generation' import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' -import { - getRepoExecutionHostId, - getSshTargetIdForExecutionHost -} from '../../../../shared/execution-host' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { withWorktreeSpan } from '../../../observability/instrumentation' import { parseWorktreeId } from '../../worktree-logic' import type { RemoveWorktreeArgs } from '../ipc-context-schemas' @@ -20,7 +17,6 @@ import { waitForPendingWorktreeRemoval } from '../../../worktree-background-removal' import { runSerializedWorktreeRemovalAcceptance } from '../../../worktree-removal-acceptance-queue' -import { runSshProviderContinuation } from '../../../ssh/ssh-provider-continuations' export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { const { store, options, worktreeRemovalsInFlight } = context @@ -40,7 +36,6 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo if (pending) { return { ...(await pending), catalogVersion: getLocalWorktreeCatalogVersion(repoId) } } - const targetId = getSshTargetIdForExecutionHost(removalHostId) ?? repo.connectionId const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) const optionsKey = getWorktreeRemovalOptionsKey(args) const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) @@ -53,15 +48,11 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. const removal = withWorktreeSpan({ stage: 'remove', path: worktreePath }, async () => { - const execute = () => - executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) const accept = async (): Promise => // Why: another client's removal of this worktree may have been accepted during the wait. waitForPendingWorktreeRemoval(args.worktreeId, removalHostId) ? { removing: true } - : targetId - ? runSshProviderContinuation(targetId, execute) - : execute() + : executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) const accepted = await (repo.connectionId ? accept() : runSerializedWorktreeRemovalAcceptance(repo.path, accept)) diff --git a/src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts b/src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts deleted file mode 100644 index a57bd68d594..00000000000 --- a/src/main/ipc/worktrees/removal/worktree-removal-continuations.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import { hasSshProviderContinuations } from '../../../ssh/ssh-provider-continuations' -import type { WorktreeIpcContext } from '../worktree-ipc-context' -import { registerWorktreeRemovalHandlers } from './register-worktree-removal-handlers' - -const mocks = vi.hoisted(() => ({ - handle: vi.fn(), - execute: vi.fn(), - resolveRepo: vi.fn() -})) -vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../../../observability/instrumentation', () => ({ - withWorktreeSpan: (_options: unknown, operation: () => unknown) => operation() -})) -vi.mock('../../worktree-logic', () => ({ - parseWorktreeId: () => ({ repoId: 'repo', worktreePath: '/work/feature' }) -})) -vi.mock('../repo-host-ownership', () => ({ resolveRepoForExecutionHost: mocks.resolveRepo })) -vi.mock('./execute-worktree-removal', () => ({ executeWorktreeRemoval: mocks.execute })) - -beforeEach(() => vi.clearAllMocks()) - -function setup(connectionId?: string, kind = 'git') { - mocks.resolveRepo.mockReturnValue({ id: 'repo', connectionId, kind }) - const worktreeRemovalsInFlight = new Map() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: removal reads only store and worktreeRemovalsInFlight from the context. - registerWorktreeRemovalHandlers({ - store: {}, - worktreeRemovalsInFlight - } as unknown as WorktreeIpcContext) - const handler = mocks.handle.mock.calls[0]![1] - return { - remove: (): Promise => handler({}, { worktreeId: 'repo:/work/feature' }), - worktreeRemovalsInFlight - } -} - -it.each(['git', 'folder'])( - 'tracks the full SSH %s removal before invocation and through cleanup', - async (kind) => { - const state = setup('target', kind) - const removed = Promise.withResolvers() - const cleaned = Promise.withResolvers() - mocks.execute.mockImplementation(async () => { - expect(hasSshProviderContinuations('target')).toBe(true) - await removed.promise - await cleaned.promise - return { success: true } - }) - const first = state.remove() - const joined = state.remove() - expect(mocks.execute).toHaveBeenCalledOnce() - expect(hasSshProviderContinuations('elsewhere')).toBe(false) - removed.resolve() - await Promise.resolve() - expect(hasSshProviderContinuations('target')).toBe(true) - cleaned.resolve() - await Promise.all([first, joined]) - expect(hasSshProviderContinuations('target')).toBe(false) - expect(state.worktreeRemovalsInFlight.size).toBe(0) - } -) - -it('retains a failed removal until its underlying operation rejects', async () => { - const state = setup('target') - const pending = Promise.withResolvers() - mocks.execute.mockReturnValue(pending.promise) - const result = state.remove() - expect(hasSshProviderContinuations('target')).toBe(true) - pending.reject(new Error('cleanup failed')) - await expect(result).rejects.toThrow('cleanup failed') - expect(hasSshProviderContinuations('target')).toBe(false) - expect(state.worktreeRemovalsInFlight.size).toBe(0) -}) - -it('does not register local removal as SSH work', async () => { - const state = setup() - const pending = Promise.withResolvers() - mocks.execute.mockReturnValue(pending.promise) - const result = state.remove() - await vi.waitFor(() => expect(mocks.execute).toHaveBeenCalledOnce()) - expect(hasSshProviderContinuations('target')).toBe(false) - pending.resolve({}) - await result -}) - -it('tracks a folder owner expressed only by its unified execution host', async () => { - const state = setup() - mocks.resolveRepo.mockReturnValue({ id: 'repo', kind: 'folder', executionHostId: 'ssh:target' }) - const pending = Promise.withResolvers() - mocks.execute.mockReturnValue(pending.promise) - const result = state.remove() - // Local acceptance is serialized per repo path first, so the continuation starts after a turn. - await vi.waitFor(() => expect(hasSshProviderContinuations('target')).toBe(true)) - pending.resolve({}) - await result - expect(hasSshProviderContinuations('target')).toBe(false) -}) diff --git a/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts b/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts deleted file mode 100644 index c67e4772bee..00000000000 --- a/src/main/memory/hydrate-local-pty-registry-folder-workspaces.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { SessionInfo } from '../daemon/types' -import type { Store } from '../persistence' - -const getDaemonProviderMock = vi.fn() -vi.mock('../daemon/daemon-init', () => ({ - getDaemonProvider: () => getDaemonProviderMock() -})) -vi.mock('../project-runtime-git-options', () => ({ - getLocalProjectWorktreeGitOptions: () => ({}) -})) -const listLocalRepoWorktreesStrictMock = vi.fn() -vi.mock('../repo-worktrees', () => ({ - listLocalRepoWorktreesStrict: (...args: unknown[]) => listLocalRepoWorktreesStrictMock(...args) -})) - -function makeStore(folderWorkspaces: FolderWorkspace[]): Store { - const store: Partial = { - getRepos: () => [], - getFolderWorkspaces: () => folderWorkspaces, - getProjectGroups: () => [], - getAllWorktreeMeta: () => ({}), - getAllWorktreeMetaForHost: () => ({}) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: hydration reads only the store members stubbed here. - return store as Store -} - -// Why fresh modules: the hydrator memoizes its pass and the registry is module-scoped. -async function loadFresh() { - vi.resetModules() - const hydrateMod = await import('./hydrate-local-pty-registry') - const registryMod = await import('./pty-registry') - return { - hydrate: hydrateMod.hydrateLocalPtyRegistryAtBoot, - listRegisteredPtys: registryMod.listRegisteredPtys - } -} - -beforeEach(() => { - getDaemonProviderMock.mockReset() - listLocalRepoWorktreesStrictMock.mockReset() -}) - -it('hydrates surviving true folder workspace PTYs without enumerating Git', async () => { - const { hydrate, listRegisteredPtys } = await loadFresh() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership reads only id and executionHostId. - const workspace = { id: 'folder-workspace-1', executionHostId: 'local' } as FolderWorkspace - const session = { sessionId: 'folder:folder-workspace-1@@cafebabe', pid: 4242 } - getDaemonProviderMock.mockReturnValue({ listSessions: vi.fn().mockResolvedValue([session]) }) - - await hydrate(makeStore([workspace])) - - expect(listRegisteredPtys()).toEqual([ - expect.objectContaining({ - ptyId: 'folder:folder-workspace-1@@cafebabe', - worktreeId: 'folder:folder-workspace-1', - pid: 4242 - }) - ]) - expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() -}) - -it.each(['deleted', 'remote', 'ssh'] as const)( - 'rechecks folder ownership after inventory when the catalog becomes %s', - async (change) => { - const { hydrate, listRegisteredPtys } = await loadFresh() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ownership reads only id and executionHostId. - const folders = [{ id: 'folder-1', executionHostId: 'local' } as FolderWorkspace] - getDaemonProviderMock.mockReturnValue({ - listSessions: vi.fn().mockImplementation(async (): Promise[]> => { - if (change === 'deleted') { - folders.splice(0) - } else { - folders[0].executionHostId = change === 'ssh' ? 'ssh:target-1' : 'runtime:environment-1' - } - return [{ sessionId: 'folder:folder-1@@cafebabe', pid: 4242 }] - }) - }) - - await hydrate(makeStore(folders)) - - expect(listRegisteredPtys()).toEqual([]) - expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() - } -) diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts index ffa2f703e03..b7dd5e1d233 100644 --- a/src/main/memory/hydrate-local-pty-registry.test.ts +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -60,8 +60,7 @@ function makeStore( kind?: Repo['kind'] path?: string }[] = [], - worktreeMeta: Record = {}, - folderWorkspaces: FolderWorkspace[] = [] + worktreeMeta: Record = {} ): Store { const built: Repo[] = repos.map((r) => ({ id: r.id, @@ -73,18 +72,15 @@ function makeStore( executionHostId: r.executionHostId ?? null, kind: r.kind })) - const store: Partial = { + return { getRepos: () => built, - getFolderWorkspaces: () => folderWorkspaces, - getProjectGroups: () => [], + getFolderWorkspaces: (): FolderWorkspace[] => [], getAllWorktreeMeta: () => worktreeMeta, getAllWorktreeMetaForHost: (hostId) => Object.fromEntries( Object.entries(worktreeMeta).filter(([, meta]) => !meta.hostId || meta.hostId === hostId) ) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: hydration reads only the store members stubbed above. - return store as Store + } as Store } function makeProvider(sessions: SessionInfo[]): Pick { diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index 6c70dc4c0e6..4e5063b5fc1 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -2,6 +2,7 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/ex import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' import { parsePtySessionId } from '../../shared/pty-session-id-format' +import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import { isFolderRepo } from '../../shared/repo-kind' import type { Repo } from '../../shared/repo-types' import { splitWorktreeId, worktreeIdComparisonKey } from '../../shared/worktree/id' @@ -16,7 +17,6 @@ import { readAllWorktreeMetaForHost } from '../persistence/host-qualified-worktr import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { listLocalRepoWorktreesStrict } from '../repo-worktrees' import { listRegisteredPtys, registerPty } from './pty-registry' -import { getVerifiedLocalFolderWorkspaceKeys } from './verified-local-folder-workspaces' type HydrationStore = Store @@ -165,11 +165,6 @@ async function hydrateLocalPtyRegistry( } throwIfSignalAborted(signal) - const verifiedLocalFolderKeys = getVerifiedLocalFolderWorkspaceKeys({ - folderWorkspaces: store.getFolderWorkspaces(), - projectGroups: store.getProjectGroups(), - repos: store.getRepos() - }) for (const info of inventory.sessions) { throwIfSignalAborted(signal) if (alreadyRegistered.has(info.sessionId)) { @@ -191,7 +186,7 @@ async function hydrateLocalPtyRegistry( return complete function isVerifiedLocalWorktree(worktreeId: string): boolean { - if (verifiedLocalFolderKeys.has(worktreeId) || verifiedFolderWorktreeIds.has(worktreeId)) { + if (verifiedFolderWorktreeIds.has(worktreeId)) { return true } const key = worktreeIdComparisonKey(worktreeId) @@ -225,6 +220,17 @@ function getVerifiedFolderWorktreeIds( repoCatalog: LocalRepoCatalog ): Set { const verified = new Set() + const folders = store.getFolderWorkspaces() + const counts = new Map() + for (const folder of folders) { + counts.set(folder.id, (counts.get(folder.id) ?? 0) + 1) + } + for (const folder of folders) { + const worktree = folderWorkspaceToWorktree(folder) + if (counts.get(folder.id) === 1 && worktree.hostId === LOCAL_EXECUTION_HOST_ID) { + verified.add(worktree.id) + } + } const metadata = readAllWorktreeMetaForHost(store, LOCAL_EXECUTION_HOST_ID) for (const [worktreeId, meta] of Object.entries(metadata)) { const parsed = splitWorktreeId(worktreeId) diff --git a/src/main/memory/verified-local-folder-workspaces.test.ts b/src/main/memory/verified-local-folder-workspaces.test.ts deleted file mode 100644 index 594e701561b..00000000000 --- a/src/main/memory/verified-local-folder-workspaces.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { FolderWorkspaceHostState } from '../../shared/folder-workspace-execution-host' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../shared/project-group-types' -import type { Repo } from '../../shared/repo-types' -import { getVerifiedLocalFolderWorkspaceKeys } from './verified-local-folder-workspaces' - -const folder = (patch: Partial = {}): FolderWorkspace => ({ - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Workspace', - folderPath: '/workspace', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - createdAt: 0, - lastActivityAt: 0, - updatedAt: 0, - ...patch -}) -const repo = (patch: Partial = {}): Repo => ({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0, - ...patch -}) -const projectGroup = (patch: Partial = {}): ProjectGroup => ({ - id: 'group-1', - name: 'Group', - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 0, - updatedAt: 0, - ...patch -}) -const state = (patch: Partial = {}): FolderWorkspaceHostState => ({ - folderWorkspaces: [folder()], - projectGroups: [], - repos: [], - ...patch -}) - -describe('verified local folder workspace keys', () => { - it.each([undefined, null, 'local'] as const)('accepts local ownership %s', (executionHostId) => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - folderWorkspaces: [folder({ executionHostId })] - }) - ) - ).toEqual(new Set(['folder:folder-1'])) - }) - - it.each(['ssh:target-1', 'runtime:environment-1', 'invalid', ''])( - 'rejects nonlocal or invalid ownership %s', - (executionHostId) => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - folderWorkspaces: [ - // Why Object.assign: invalid stored ids must reach the parser without widening the type. - Object.assign(folder(), { executionHostId }) - ] - }) - ) - ).toEqual(new Set()) - } - ) - - it('honors explicit local ownership over legacy scope', () => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - folderWorkspaces: [folder({ executionHostId: 'local', connectionId: 'target-1' })], - repos: [repo({ executionHostId: 'runtime:environment-1' })] - }) - ) - ).toEqual(new Set(['folder:folder-1'])) - }) - - it.each([ - state({ folderWorkspaces: [] }), - state({ folderWorkspaces: [folder(), folder()] }), - state({ - folderWorkspaces: [ - folder({ executionHostId: 'local' }), - folder({ executionHostId: 'runtime:environment-1' }) - ] - }), - state({ folderWorkspaces: [folder({ connectionId: 'target-1' })] }), - state({ projectGroups: [projectGroup({ connectionId: 'target-1' })] }), - state({ repos: [repo({ executionHostId: 'runtime:environment-1' })] }), - state({ repos: [repo({ executionHostId: 'ssh:target-1' })] }), - state({ repos: [repo(), repo({ id: 'repo-2', connectionId: 'target-1' })] }), - state({ repos: [repo(), repo({ id: 'repo-2', executionHostId: 'runtime:environment-1' })] }) - ])('rejects missing, duplicate, remote, or ambiguous scope %#', (catalog) => { - expect(getVerifiedLocalFolderWorkspaceKeys(catalog)).toEqual(new Set()) - }) - - it('accepts local inferred scope without unrelated runtime repos affecting it', () => { - expect( - getVerifiedLocalFolderWorkspaceKeys( - state({ - repos: [ - repo(), - repo({ id: 'remote', path: '/elsewhere', executionHostId: 'runtime:environment-1' }) - ] - }) - ) - ).toEqual(new Set(['folder:folder-1'])) - }) -}) diff --git a/src/main/memory/verified-local-folder-workspaces.ts b/src/main/memory/verified-local-folder-workspaces.ts deleted file mode 100644 index eae3ee39967..00000000000 --- a/src/main/memory/verified-local-folder-workspaces.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' -import { - findFolderWorkspaceCandidateRepos, - resolveFolderWorkspaceHost, - type FolderWorkspaceHostState -} from '../../shared/folder-workspace-execution-host' -import { folderWorkspaceKey } from '../../shared/workspace-scope' - -export function getVerifiedLocalFolderWorkspaceKeys(state: FolderWorkspaceHostState): Set { - const counts = new Map() - for (const workspace of state.folderWorkspaces) { - counts.set(workspace.id, (counts.get(workspace.id) ?? 0) + 1) - } - const keys = new Set() - for (const workspace of state.folderWorkspaces) { - if (counts.get(workspace.id) !== 1) { - continue - } - const pin = parseExecutionHostId(workspace.executionHostId) - if (workspace.executionHostId != null) { - if (pin?.kind === 'local') { - keys.add(folderWorkspaceKey(workspace.id)) - } - continue - } - if (resolveFolderWorkspaceHost(state, workspace.id).kind !== 'local') { - continue - } - // The shared legacy resolver projects runtime ownership as local; attribution cannot. - if ( - findFolderWorkspaceCandidateRepos(state, workspace.id).some( - (repo) => getRepoExecutionHostId(repo) !== 'local' - ) - ) { - continue - } - keys.add(folderWorkspaceKey(workspace.id)) - } - return keys -} diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index 8f947272dbc..ff811983a25 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -26,12 +26,14 @@ const FIRST_JOURNAL_SEQUENCE = 1 export type JournalLoad = { state: JournalReducerState - /** A row from a future schema was met: no writes, no deletion. */ + /** A row from a future schema, or of a kind this build does not know, was met: no writes, no + * deletion. */ readOnly: boolean /** Set when the surviving prefix is unusable and the caller must roll the epoch. */ corrupt: boolean - /** Rows skipped because their body failed to parse (future-version rows are - * `readOnly`, never counted here). The store discloses these in the timeline. */ + /** Rows dropped because they failed to parse or name another sequence than their key (an + * unreadable row latches `readOnly`, never counted here). The store discloses these in the + * timeline. */ malformedRows: number /** Directory-internal: the first sequence of an unusable suffix. The store * deletes from here before it accepts a write; a probe leaves it alone. */ @@ -92,10 +94,11 @@ export function startJournalRowFold(input: JournalRowFoldInput): { const add = (entry: { seq: number; rowJson: string }): boolean => { const parsed = parseJournalRow(entry.rowJson) - if (!parsed.ok) { + // A body naming another sequence than its key is malformed there: writes number past the key. + if (!parsed.ok || parsed.row.seq !== entry.seq) { truncateFrom = entry.seq - latched = parsed.unreadable - malformedRows = parsed.unreadable ? 0 : 1 + latched = !parsed.ok && parsed.unreadable + malformedRows = latched ? 0 : 1 return false } const row = parsed.row diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index e65d5919b39..9276b1a8c05 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -4,6 +4,14 @@ // in-place rewrite. A row whose version this build does not understand is // UNREADABLE, not skippable: the caller must degrade to read-only rather than // render a partial timeline or compact past a row it cannot interpret. +// +// A row whose KIND this build does not know is UNREADABLE the same way, and kept +// on disk, when it has the envelope every row keeps: a non-empty `epoch`, an +// integer `seq` >= 1, an integer `fence` and a numeric `ts`. So a new kind keeps +// that envelope; changing the envelope is a `v` bump. Builds from before this +// rule delete the journal from an unknown kind, so a new kind either ships its +// reader first and is written only once no supported build lacks that reader, +// or is written at a bumped `v`. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { @@ -79,10 +87,10 @@ export type JournalTombstoneRow = JournalRowBase & { queueResume?: true } -/** One Stop that took effect. Temporary carrier: a tombstone's extra key, because a released host - * deletes the journal from the first row kind it does not know (`journal-open.ts` then - * `journal-store-open.ts`) but ignores an unknown key; a row kind of its own once released hosts - * skip unknown kinds instead. */ +/** One Stop that took effect. Temporary carrier: a tombstone's extra key, which every host ignores, + * where a host from before the header's rule deletes the journal from a kind it does not know. A + * kind of its own ships its reader first and is written once no supported build lacks that + * reader, or is written at a bumped `v`: an older host must never fold past a person's Stop. */ export type JournalStopEvent = { /** Persisted: never rename an arm. Only `user-stop` pauses the queue. */ reason: StructuredAgentSessionStopCause @@ -180,25 +188,17 @@ export type JournalRowParse = | { ok: true; row: JournalRow } /** Malformed JSON or a shape this build rejects outright. */ | { ok: false; unreadable: false } - /** A future schema version. The host must not write or compact this journal. */ + /** A future schema version, or a kind this build does not know. The host must not write or + * compact this journal. */ | { ok: false; unreadable: true } -const ROW_KINDS = new Set([ - 'epoch', - 'item', - 'tombstone', - 'submission', - 'dispatch', - 'lifecycle-batch' -]) - export function serializeJournalRow(row: JournalRow): string { return JSON.stringify(row) } /** - * Parse one persisted line. Older versions are upcast; newer versions are - * reported as unreadable so the caller fails closed. + * Parse one persisted line. Older versions are upcast; newer versions and newer + * kinds are reported as unreadable so the caller fails closed. */ export function parseJournalRow(line: string): JournalRowParse { let parsed: unknown @@ -230,7 +230,13 @@ export function parseJournalRow(line: string): JournalRowParse { } } dropUnusableContextUsage(upcast) - return isJournalRow(upcast) ? { ok: true, row: upcast } : { ok: false, unreadable: false } + if (isJournalRow(upcast)) { + return { ok: true, row: upcast } + } + // A newer build's kind is placed by the envelope every row keeps; one without it is damage. + const { kind } = upcast + const unknownKind = typeof kind === 'string' && kind !== '' && !KNOWN_ROW_KINDS.has(kind) + return { ok: false, unreadable: unknownKind && hasJournalRowEnvelope(upcast) } } /** Linkage ids this build cannot trust, removed from a row it still keeps. @@ -317,60 +323,56 @@ function isPlainObject(value: unknown): value is Record { * schema — their nested shapes are dereferenced unguarded all the way to the * rendered surface, so a JSON-valid corruption must fail here, not there. */ function isJournalRow(record: Record): record is JournalRow { - if (typeof record.kind !== 'string' || !ROW_KINDS.has(record.kind)) { - return false - } - if ( - typeof record.epoch !== 'string' || - !record.epoch || - !Number.isInteger(record.seq) || - (record.seq as number) < 1 || - !Number.isInteger(record.fence) || - typeof record.ts !== 'number' - ) { - return false - } - if (record.kind === 'item') { - return ( - typeof record.itemId === 'string' && - Number.isInteger(record.revision) && - isAdmissibleAgentJournalItemBody(record.body) - ) - } - if (record.kind === 'tombstone') { - return typeof record.itemId === 'string' && Number.isInteger(record.revision) - } - if (record.kind === 'submission') { - return ( - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.payloadFingerprint === 'string' && - isPlainObject(record.providerHandle) && - isAdmissibleAgentJournalMessageBody(record.body) - ) - } - if (record.kind === 'dispatch') { - return ( - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.state === 'string' && - record.state.length > 0 && - (record.providerItemId === null || typeof record.providerItemId === 'string') && - (record.reason === null || typeof record.reason === 'string') - ) - } - if (record.kind === 'lifecycle-batch') { - return ( - typeof record.settlementId === 'string' && - record.settlementId.length > 0 && - Array.isArray(record.mutations) && - record.mutations.length > 0 && - record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && - Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES && - record.mutations.every(isLifecycleMutation) - ) - } - return typeof record.reason === 'string' && isPlainObject(record.providerHandle) + const fieldCheck = typeof record.kind === 'string' ? KNOWN_ROW_KINDS.get(record.kind) : undefined + return fieldCheck !== undefined && hasJournalRowEnvelope(record) && fieldCheck(record) +} + +/** Each kind's own fields, keyed by every kind the union holds: a kind without a check here fails + * to compile, never reads as a newer build's kind. */ +const ROW_FIELD_CHECK_BY_KIND: Record< + JournalRow['kind'], + (record: Record) => boolean +> = { + epoch: (record) => typeof record.reason === 'string' && isPlainObject(record.providerHandle), + item: (record) => + typeof record.itemId === 'string' && + Number.isInteger(record.revision) && + isAdmissibleAgentJournalItemBody(record.body), + tombstone: (record) => typeof record.itemId === 'string' && Number.isInteger(record.revision), + submission: (record) => + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.payloadFingerprint === 'string' && + isPlainObject(record.providerHandle) && + isAdmissibleAgentJournalMessageBody(record.body), + dispatch: (record) => + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.state === 'string' && + record.state.length > 0 && + (record.providerItemId === null || typeof record.providerItemId === 'string') && + (record.reason === null || typeof record.reason === 'string'), + 'lifecycle-batch': (record) => + typeof record.settlementId === 'string' && + record.settlementId.length > 0 && + Array.isArray(record.mutations) && + record.mutations.length > 0 && + record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && + Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES && + record.mutations.every(isLifecycleMutation) +} +const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_FIELD_CHECK_BY_KIND)) + +/** The fields every row keeps whatever its kind: its epoch, its place in it, its writer, its time. */ +function hasJournalRowEnvelope(record: Record): boolean { + return ( + typeof record.epoch === 'string' && + record.epoch !== '' && + Number.isInteger(record.seq) && + Number(record.seq) >= 1 && + Number.isInteger(record.fence) && + typeof record.ts === 'number' + ) } function isLifecycleMutation(value: unknown): value is JournalLifecycleMutation { diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts new file mode 100644 index 00000000000..794ca52ab7d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts @@ -0,0 +1,276 @@ +// A newer build's row kind is never repaired away: like a newer row version, it latches this build +// read-only with every row kept, so the newer build still reads the whole chat after an upgrade. +// A row whose stored sequence and body disagree is damage at its stored sequence. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { parseJournalRow, type JournalRow } from './journal-row-schema' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-newer-kind', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-newer-kind-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function itemIds(journal: { snapshot: () => { items: { itemId: string }[] } }): string[] { + return journal.snapshot().items.map((entry) => entry.itemId) +} + +/** What a newer build would write: a kind this build does not know, in the envelope every row keeps. */ +function newerRow(epoch: string, seq: number, extra: Record = {}) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'future-mark', + epoch, + seq, + fence: 1, + ts: 5_000 + seq, + payload: { said: 'by a newer build', seq }, + ...extra + } +} + +function open() { + return journals.open({ identity: IDENTITY, stateDirectory: root }) +} + +/** Closes the chat, applies `edit` to its stored rows as a newer build or a bad write would, and + * reopens it as a restarted host would. */ +async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) { + await journals.closeAll() + const { db } = openTestJournalHostDatabase(root) + edit((seq, json) => { + db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run( + IDENTITY.sessionId, + seq + ) + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, json, 5_000 + seq) + }) + closeTestJournalHostDatabase(root) + return open() +} + +function stored(): { seq: number; rowJson: string }[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId).map( + (row) => ({ seq: row.seq, rowJson: row.rowJson }) + ) +} + +function storedKinds(): [number, unknown][] { + return stored().map((row) => [row.seq, JSON.parse(row.rowJson).kind]) +} + +/** A journal of its anchor and one item, closed: the next row lands at sequence 3. */ +async function journalWithOneItem(): Promise { + const first = await open() + await first.appendItem(item(0), { kind: 'status', text: 'before' }, SCOPE) + return first.epoch +} + +/** One valid row of every kind this build writes; a kind added to the union without one here + * fails to compile. */ +function rowOfEveryKind(): { [Kind in JournalRow['kind']]: Extract } { + const base = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: 'epoch-1', fence: 1, ts: 1 } + const providerHandle = IDENTITY.providerHandle + return { + epoch: { ...base, seq: 1, kind: 'epoch', reason: 'session_created', providerHandle }, + item: { + ...base, + seq: 2, + kind: 'item', + itemId: 'i', + revision: 1, + body: { kind: 'status', text: 'x' } + }, + tombstone: { ...base, seq: 3, kind: 'tombstone', itemId: 'i', revision: 2 }, + submission: { + ...base, + seq: 4, + kind: 'submission', + clientMessageId: 'c', + payloadFingerprint: 'f', + providerHandle, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } + }, + dispatch: { + ...base, + seq: 5, + kind: 'dispatch', + clientMessageId: 'c', + state: 'accepted', + providerItemId: null, + reason: null + }, + 'lifecycle-batch': { + ...base, + seq: 6, + kind: 'lifecycle-batch', + settlementId: 's', + mutations: [{ kind: 'tombstone', itemId: 'i', revision: 3 }] + } + } +} + +it("reads a row of every kind this build writes, never as a newer build's kind", () => { + for (const row of Object.values(rowOfEveryKind())) { + expect(parseJournalRow(JSON.stringify(row))).toEqual({ ok: true, row }) + } +}) + +describe('parsing a row of a kind this build does not know', () => { + it('reads one in the envelope every row keeps as unreadable', () => { + expect(parseJournalRow(JSON.stringify(newerRow('epoch-1', 7)))).toEqual({ + ok: false, + unreadable: true + }) + }) + + it.each([ + ['no sequence', { seq: undefined }], + ['a sequence of 0', { seq: 0 }], + ['a string sequence', { seq: '7' }], + ['a fractional fence', { fence: 1.5 }], + ['a string timestamp', { ts: '5007' }], + ['an empty epoch', { epoch: '' }], + ['an empty kind', { kind: '' }], + ['a kind that is not a string', { kind: 7 }] + ])('reads one with %s as malformed', (_name, broken) => { + const parsed = parseJournalRow(JSON.stringify(newerRow('epoch-1', 7, broken))) + expect(parsed).toEqual({ ok: false, unreadable: false }) + }) + + it('reads a known kind that fails its own checks as malformed', () => { + const parsed = parseJournalRow( + JSON.stringify({ ...newerRow('epoch-1', 7), kind: 'item', itemId: 'x', revision: 1 }) + ) + expect(parsed).toEqual({ ok: false, unreadable: false }) + }) + + it('reads a future schema version as unreadable before it looks at the kind', () => { + const parsed = parseJournalRow( + JSON.stringify(newerRow('epoch-1', 7, { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 })) + ) + expect(parsed).toEqual({ ok: false, unreadable: true }) + }) +}) + +describe("a journal holding a newer build's row kind", () => { + it('opens read-only with the rows before it, refuses writes, keeps every row, and reads whole once the kind is known', async () => { + const first = await open() + for (const ordinal of [0, 1, 2, 3]) { + await first.appendItem(item(ordinal), { kind: 'status', text: `item ${ordinal}` }, SCOPE) + } + const epoch = first.epoch + await journals.closeAll() + const known = stored().find((row) => row.seq === 3)?.rowJson ?? '' + const newer = JSON.stringify(newerRow(epoch, 3)) + + const journal = await restartAfter((put) => put(3, newer)) + const latched = stored() + expect(journal.isReadOnly).toBe(true) + expect(journal.repair).toEqual({ malformedRows: 0 }) + expect(itemIds(journal)).toEqual(['codex:thread-1:turn-1:0']) + await expect( + journal.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) + ).rejects.toMatchObject({ code: 'journal_read_only' }) + + const reopened = await restartAfter() + expect(reopened.isReadOnly).toBe(true) + expect(stored()).toEqual(latched) + expect(stored().find((row) => row.seq === 3)?.rowJson).toBe(newer) + + // Stand-in for the newer build after an upgrade: the same place holds a kind it reads. + const upgraded = await restartAfter((put) => put(3, known)) + expect(upgraded.isReadOnly).toBe(false) + expect(itemIds(upgraded)).toHaveLength(4) + await upgraded.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) + expect(upgraded.cursor().sequence).toBe(6) + }) + + it('stays read-only with nothing deleted when the row names another sequence than its key', async () => { + const epoch = await journalWithOneItem() + const journal = await restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 9)))) + expect(journal.isReadOnly).toBe(true) + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'future-mark'] + ]) + }) + + it('reads a newer kind whose envelope is broken as malformed and drops from it', async () => { + const epoch = await journalWithOneItem() + const journal = await restartAfter((put) => + put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' }))) + ) + expect(journal.isReadOnly).toBe(false) + expect(journal.repair).toEqual({ malformedRows: 1 }) + // 3 is the disclosure the repair appends where the broken row was. + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'item'] + ]) + }) +}) + +describe('a row whose body names another sequence than its stored key', () => { + it.each([ + ['an earlier', 2], + ['a later', 9] + ])( + 'is dropped at its key when it names %s one, and the next write succeeds', + async (_name, seq) => { + const epoch = await journalWithOneItem() + const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 } + const journal = await restartAfter((put) => put(3, JSON.stringify(tombstone))) + expect(journal.repair).toEqual({ malformedRows: 1 }) + expect(journal.needsRebuild).toBe(true) + // The valid row at 2 stays; 3 is the disclosure the repair appends. + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'item'] + ]) + + await journal.appendItem(item(1), { kind: 'status', text: 'after' }, SCOPE) + const reopened = await restartAfter() + expect(reopened.cursor().sequence).toBe(4) + expect(itemIds(reopened)).toHaveLength(3) + } + ) +}) diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.test.ts b/src/main/orca-profiles/profile-project-session-field-disposition.test.ts index ce509767119..daedc20a111 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.test.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.test.ts @@ -66,120 +66,6 @@ describe('client-hosted rows in the repo-removal and transfer paths', () => { expect(result.clientHostedBrowserPagesByWorktree).toBeUndefined() }) - it('rekeys markdown frontmatter visibility with the open file identity', () => { - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { - [REMOVED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-a/README.md', - relativePath: 'README.md', - worktreeId: REMOVED_WORKTREE_ID, - language: 'markdown' - } - ] - }, - markdownFrontmatterVisible: { - '/tmp/worktree-a/README.md': false - } - } - - const result = extractSessionForTransfer(session, REMOVED_REPO_ID, TRANSFER_TARGET_REPO_ID) - - expect(result.markdownFrontmatterVisible).toEqual({ '/tmp/worktree-a/README.md': false }) - }) - - it('omits stale markdown visibility entries that no longer name an open file', () => { - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { - [REMOVED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-a/README.md', - relativePath: 'README.md', - worktreeId: REMOVED_WORKTREE_ID, - language: 'markdown' - } - ] - }, - markdownFrontmatterVisible: { - '/tmp/worktree-a/README.md': false, - '/tmp/worktree-a/closed.md': false - } - } - - const result = extractSessionForTransfer(session, REMOVED_REPO_ID, TRANSFER_TARGET_REPO_ID) - - expect(result.markdownFrontmatterVisible).toEqual({ '/tmp/worktree-a/README.md': false }) - }) - - it('prunes markdown visibility for files in a removed repo', () => { - const session = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { - [REMOVED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-a/README.md', - relativePath: 'README.md', - worktreeId: REMOVED_WORKTREE_ID, - language: 'markdown' - } - ], - [RETAINED_WORKTREE_ID]: [ - { - filePath: '/tmp/worktree-b/README.md', - relativePath: 'README.md', - worktreeId: RETAINED_WORKTREE_ID, - language: 'markdown' - } - ] - }, - markdownFrontmatterVisible: { - '/tmp/worktree-a/README.md': false, - '/tmp/worktree-b/README.md': false - } - } - - const result = removeRepoFromWorkspaceSession(session, REMOVED_REPO_ID) - - expect(result.markdownFrontmatterVisible).toEqual({ '/tmp/worktree-b/README.md': false }) - }) - - it('keeps a unified-only terminal layout attached during transfer', () => { - const session = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: {}, - unifiedTabs: { - [REMOVED_WORKTREE_ID]: [ - { - id: 'terminal-tab-1', - entityId: 'terminal-tab-1', - groupId: 'group-1', - worktreeId: REMOVED_WORKTREE_ID, - contentType: 'terminal' as const, - label: 'Terminal', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'terminal-tab-1': { - root: { type: 'leaf' as const, leafId: 'leaf-1' }, - activeLeafId: 'leaf-1', - expandedLeafId: null, - titlesByLeafId: { 'leaf-1': 'shell' } - } - } - } - - const result = extractSessionForTransfer(session, REMOVED_REPO_ID, TRANSFER_TARGET_REPO_ID) - - expect(result.terminalLayoutsByTabId).toEqual(session.terminalLayoutsByTabId) - }) - it('removes the transferred repo rows from the source it left', () => { const source = removeRepoFromWorkspaceSession( sessionWithClientHostedRows(), diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index 219ef63d8ca..02a7ce77ca6 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -66,10 +66,7 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { }, openFilesByWorktree: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByBespokeRule' }, activeFileIdByWorktree: { onRepoRemoval: 'prunedByOwnerKey', onTransfer: 'copiedByOwnerKey' }, - markdownFrontmatterVisible: { - onRepoRemoval: 'prunedByBespokeRule', - onTransfer: 'copiedByBespokeRule' - }, + markdownFrontmatterVisible: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' }, browserTabsByWorktree: { onRepoRemoval: 'prunedByBespokeRule', onTransfer: 'copiedByBespokeRule' diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts index d17c3b24302..0c1ea243b3e 100644 --- a/src/main/orca-profiles/profile-project-session-state.ts +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -3,7 +3,6 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from './profile-project-session-field-disposition' -import { markdownFileIdCandidates } from './profile-session-markdown-transfer' import { isRepoWorktreeId, ownerKeyBelongsToRepo, @@ -59,10 +58,6 @@ export function mergeWorkspaceSessions( } : {}), openFilesByWorktree: { ...base.openFilesByWorktree, ...incoming.openFilesByWorktree }, - markdownFrontmatterVisible: { - ...base.markdownFrontmatterVisible, - ...incoming.markdownFrontmatterVisible - }, browserTabsByWorktree: { ...base.browserTabsByWorktree, ...incoming.browserTabsByWorktree @@ -138,17 +133,6 @@ export function removeRepoFromWorkspaceSession( ): WorkspaceSessionState { const next = structuredClone(session ?? getDefaultWorkspaceSession()) const removedTerminalTabIds = new Set() - const removedMarkdownFileIds = new Set() - for (const [ownerKey, files] of Object.entries(next.openFilesByWorktree ?? {})) { - if (!ownerKeyBelongsToRepo(ownerKey, repoId)) { - continue - } - for (const file of files) { - markdownFileIdCandidates(file.filePath, ownerKey, file.runtimeEnvironmentId).forEach((id) => - removedMarkdownFileIds.add(id) - ) - } - } for (const [ownerKey, tabs] of Object.entries(next.tabsByWorktree)) { if (!ownerKeyBelongsToRepo(ownerKey, repoId)) { continue @@ -178,13 +162,6 @@ export function removeRepoFromWorkspaceSession( const record = next[field] as Record | undefined ;(next as Record)[field] = removeRepoWorktreeRecord(record, repoId) } - if (next.markdownFrontmatterVisible) { - next.markdownFrontmatterVisible = Object.fromEntries( - Object.entries(next.markdownFrontmatterVisible).filter( - ([fileId]) => !removedMarkdownFileIds.has(fileId) - ) - ) - } if (next.terminalSurfaceTombstonesByPaneKey) { next.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( Object.entries(next.terminalSurfaceTombstonesByPaneKey).filter( diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index 39c3b3b6189..3018f07173c 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -1,10 +1,24 @@ +import { getDefaultWorkspaceSession } from '../../shared/constants' import type { ExecutionHostId } from '../../shared/execution-host' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { rekeyOwnerKey, rekeyWorktreeId } from './profile-project-worktree-identity' +import type { + BrowserPage, + BrowserPageDocLocation, + BrowserWorkspace +} from '../../shared/browser-workspace-types' +import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location' +import type { Tab, TabGroup } from '../../shared/tab-types' +import type { TerminalTab } from '../../shared/terminal-tab-types' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../shared/workspace-session-state-types' +import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { SESSION_FIELDS_COPIED_BY_OWNER_KEY } from './profile-project-session-field-disposition' import { - extractSessionOwnersForTransfer, - hasTransferredSessionState -} from './profile-session-owner-transfer' + isRepoWorktreeId, + rekeyOwnerKey, + rekeyWorktreeId +} from './profile-project-worktree-identity' export function extractHostSessionsForTransfer( sessions: Partial> | undefined, @@ -24,13 +38,210 @@ export function extractHostSessionsForTransfer( return next } +function hasTransferredSessionState(session: WorkspaceSessionState): boolean { + return ( + Object.keys(session.tabsByWorktree).length > 0 || + Object.keys(session.openFilesByWorktree ?? {}).length > 0 || + Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || + Object.keys(session.unifiedTabs ?? {}).length > 0 || + Object.keys(session.tabGroups ?? {}).length > 0 || + Object.keys(session.lastVisitedAtByWorktreeId ?? {}).length > 0 || + Object.keys(session.terminalTopologyRevisionByRepoId ?? {}).length > 0 + ) +} + export function extractSessionForTransfer( session: WorkspaceSessionState | undefined, oldRepoId: string, newRepoId: string ): WorkspaceSessionState { - return extractSessionOwnersForTransfer(session, { - mapOwnerKey: (ownerKey) => rekeyOwnerKey(oldRepoId, newRepoId, ownerKey), - mapWorktreeId: (worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId) - }) + const source = session ?? getDefaultWorkspaceSession() + const transferred = getDefaultWorkspaceSession() + const copiedTerminalTabIds = new Set() + const copiedBrowserWorkspaceIds = new Set() + const mapOwnerRecord = ( + record: Record | undefined, + mapValue: (value: T) => T + ): Record => { + const next: Record = {} + for (const [ownerKey, value] of Object.entries(record ?? {})) { + const nextOwnerKey = rekeyOwnerKey(oldRepoId, newRepoId, ownerKey) + if (nextOwnerKey) { + next[nextOwnerKey] = mapValue(value) + } + } + return next + } + transferred.tabsByWorktree = mapOwnerRecord(source.tabsByWorktree, (tabs) => + tabs.map((tab) => { + copiedTerminalTabIds.add(tab.id) + return rekeyTerminalTab(tab, oldRepoId, newRepoId) + }) + ) + transferred.openFilesByWorktree = mapOwnerRecord(source.openFilesByWorktree, (files) => + files.map((file) => rekeyOpenFile(file, oldRepoId, newRepoId)) + ) + transferred.browserTabsByWorktree = mapOwnerRecord(source.browserTabsByWorktree, (tabs) => + tabs.map((tab) => { + copiedBrowserWorkspaceIds.add(tab.id) + return rekeyBrowserWorkspace(tab, oldRepoId, newRepoId) + }) + ) + transferred.browserPagesByWorkspace = copyBrowserPages( + source.browserPagesByWorkspace, + copiedBrowserWorkspaceIds, + oldRepoId, + newRepoId + ) + // Driven by the census so a field cannot be added to the session type and forgotten here. The + // census is also where a field's deliberate non-transfer is recorded -- notably the runtime's + // client-hosted rows, which name a paired device this payload does not carry. + for (const field of SESSION_FIELDS_COPIED_BY_OWNER_KEY) { + const record = source[field] as Record | undefined + ;(transferred as Record)[field] = mapOwnerRecord(record, (value) => + structuredClone(value) + ) + } + transferred.unifiedTabs = mapOwnerRecord(source.unifiedTabs, (tabs) => + tabs.map((tab) => rekeyUnifiedTab(tab, oldRepoId, newRepoId)) + ) + transferred.tabGroups = mapOwnerRecord(source.tabGroups, (groups) => + groups.map((group) => rekeyTabGroup(group, oldRepoId, newRepoId)) + ) + transferred.terminalLayoutsByTabId = {} + for (const tabId of copiedTerminalTabIds) { + const layout = source.terminalLayoutsByTabId[tabId] + if (layout) { + transferred.terminalLayoutsByTabId[tabId] = structuredClone(layout) + } + } + if (source.localOnlyScrollbackByTabId) { + transferred.localOnlyScrollbackByTabId = Object.fromEntries( + Object.entries(source.localOnlyScrollbackByTabId) + .filter(([tabId]) => copiedTerminalTabIds.has(tabId)) + .map(([tabId, buffers]) => [tabId, structuredClone(buffers)]) + ) + } + transferred.terminalPtyIncarnationsByPaneKey = Object.fromEntries( + Object.entries(source.terminalPtyIncarnationsByPaneKey ?? {}).filter(([paneKey]) => { + const separator = paneKey.lastIndexOf(':') + return separator > 0 && copiedTerminalTabIds.has(paneKey.slice(0, separator)) + }) + ) + transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( + Object.entries(source.terminalSurfaceTombstonesByPaneKey ?? {}).flatMap( + ([paneKey, tombstone]) => + isRepoWorktreeId(oldRepoId, tombstone.worktreeId) + ? [ + [ + paneKey, + { + ...structuredClone(tombstone), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tombstone.worktreeId) + } + ] as const + ] + : [] + ) + ) + transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown + ?.filter((worktreeId) => isRepoWorktreeId(oldRepoId, worktreeId)) + .map((worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId)) + if (source.activeWorktreeId && isRepoWorktreeId(oldRepoId, source.activeWorktreeId)) { + transferred.activeWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, source.activeWorktreeId) + } + const activeScope = source.activeWorkspaceKey + ? parseWorkspaceKey(source.activeWorkspaceKey) + : null + if (activeScope?.type === 'worktree' && isRepoWorktreeId(oldRepoId, activeScope.worktreeId)) { + transferred.activeWorkspaceKey = worktreeWorkspaceKey( + rekeyWorktreeId(oldRepoId, newRepoId, activeScope.worktreeId) + ) + } + return transferred +} + +function rekeyTerminalTab(tab: TerminalTab, oldRepoId: string, newRepoId: string): TerminalTab { + return { + ...structuredClone(tab), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId) + } +} + +function rekeyOpenFile( + file: PersistedOpenFile, + oldRepoId: string, + newRepoId: string +): PersistedOpenFile { + return { + ...structuredClone(file), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, file.worktreeId) + } +} + +function rekeyBrowserWorkspace( + workspace: BrowserWorkspace, + oldRepoId: string, + newRepoId: string +): BrowserWorkspace { + return { + ...structuredClone(workspace), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, workspace.worktreeId), + ...(workspace.docLocation + ? { docLocation: rekeyBrowserDocLocation(workspace.docLocation, oldRepoId, newRepoId) } + : {}), + // Why: both the session profile and the resolved partition string are + // source-profile-scoped; carrying either across would point the restored + // pane at a partition the target profile's allowlist rejects. + sessionProfileId: null, + sessionPartition: null + } +} + +function rekeyBrowserPage(page: BrowserPage, oldRepoId: string, newRepoId: string): BrowserPage { + return { + ...structuredClone(page), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId), + ...(page.docLocation + ? { docLocation: rekeyBrowserDocLocation(page.docLocation, oldRepoId, newRepoId) } + : {}) + } +} + +function rekeyBrowserDocLocation( + location: BrowserPageDocLocation, + oldRepoId: string, + newRepoId: string +): BrowserPageDocLocation { + const nextWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, location.worktreeId) + return remapBrowserPageDocLocation(location, location.worktreeId, nextWorktreeId) +} + +function copyBrowserPages( + pagesByWorkspace: Record | undefined, + workspaceIds: ReadonlySet, + oldRepoId: string, + newRepoId: string +): Record { + const next: Record = {} + for (const [workspaceId, pages] of Object.entries(pagesByWorkspace ?? {})) { + if (workspaceIds.has(workspaceId)) { + next[workspaceId] = pages.map((page) => rekeyBrowserPage(page, oldRepoId, newRepoId)) + } + } + return next +} + +function rekeyUnifiedTab(tab: Tab, oldRepoId: string, newRepoId: string): Tab { + return { + ...structuredClone(tab), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId) + } +} + +function rekeyTabGroup(group: TabGroup, oldRepoId: string, newRepoId: string): TabGroup { + return { + ...structuredClone(group), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, group.worktreeId) + } } diff --git a/src/main/orca-profiles/profile-session-markdown-transfer.ts b/src/main/orca-profiles/profile-session-markdown-transfer.ts deleted file mode 100644 index c9edf77a9c5..00000000000 --- a/src/main/orca-profiles/profile-session-markdown-transfer.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' - -type OwnerProjection = { - mapOwnerKey: (ownerKey: string) => string | null - mapWorktreeId: (worktreeId: string) => string -} - -/** Map file-keyed markdown visibility along with the persisted open-file identity it belongs to. */ -export function mapMarkdownFrontmatterVisible( - visibleByFileId: Record | undefined, - filesByOwner: WorkspaceSessionState['openFilesByWorktree'] | undefined, - projection: OwnerProjection -): Record { - const next: Record = {} - const sourceToDestination = buildMarkdownFrontmatterIdMap(filesByOwner, projection) - for (const [sourceFileId, visible] of Object.entries(visibleByFileId ?? {})) { - if (visible) { - // Visible is the hydration default; preserving only hidden overrides avoids carrying stale - // positive entries while retaining the exact rendered result. - continue - } - const destinationId = sourceToDestination.get(sourceFileId) - if (destinationId) { - next[destinationId] = false - } - } - return next -} - -/** - * Builds the file-id mapping used by hydration, marking a source id null when two transferred - * files claim it. An ambiguous visibility override is never guessed at by the migration. - */ -export function buildMarkdownFrontmatterIdMap( - filesByOwner: WorkspaceSessionState['openFilesByWorktree'] | undefined, - projection: OwnerProjection -): ReadonlyMap { - const result = new Map() - for (const [sourceOwnerKey, files] of Object.entries(filesByOwner ?? {})) { - if (!projection.mapOwnerKey(sourceOwnerKey)) { - continue - } - const destinationWorktreeId = projection.mapWorktreeId(sourceOwnerKey) - const sourceWorktreeIds = [sourceOwnerKey] - const separator = sourceOwnerKey.indexOf('|') - if (separator > 0) { - sourceWorktreeIds.push(sourceOwnerKey.slice(separator + 1)) - } - for (const file of files) { - const destinationId = ownedEditorFileId( - file.filePath, - destinationWorktreeId, - file.runtimeEnvironmentId - ) - for (const sourceWorktreeId of sourceWorktreeIds) { - const candidates = markdownFileIdCandidates( - file.filePath, - sourceWorktreeId, - file.runtimeEnvironmentId - ) - for (const sourceId of candidates) { - const mapped = sourceId === file.filePath ? file.filePath : destinationId - const existing = result.get(sourceId) - result.set(sourceId, existing === undefined || existing === mapped ? mapped : null) - } - } - } - } - return result -} - -/** Returns the IDs hydration can assign to a persisted file before any runtime data is loaded. */ -export function markdownFileIdCandidates( - filePath: string, - worktreeId: string, - runtimeEnvironmentId: string | null | undefined -): ReadonlySet { - return new Set([filePath, ownedEditorFileId(filePath, worktreeId, runtimeEnvironmentId)]) -} - -function ownedEditorFileId( - filePath: string, - worktreeId: string, - runtimeEnvironmentId: string | null | undefined -): string { - const runtimeKey = runtimeEnvironmentId?.trim() || 'local' - return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` -} diff --git a/src/main/orca-profiles/profile-session-owner-transfer.test.ts b/src/main/orca-profiles/profile-session-owner-transfer.test.ts deleted file mode 100644 index ebcbb472032..00000000000 --- a/src/main/orca-profiles/profile-session-owner-transfer.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { PersistedOpenFile } from '../../shared/workspace-session-state-types' -import { mergeWorkspaceSessions } from './profile-project-session-state' -import { extractSessionForTransfer } from './profile-project-session-transfer' -import { buildMarkdownFrontmatterIdMap } from './profile-session-markdown-transfer' -import { extractSessionOwnersForTransfer } from './profile-session-owner-transfer' - -const SOURCE = 'repo-1::/tmp/a' -const DESTINATION = 'repo-9::/tmp/a' -const editorId = (worktreeId: string, filePath: string, runtime = 'local') => - `editor:${encodeURIComponent(worktreeId)}:${runtime}:${encodeURIComponent(filePath)}` - -function file(filePath: string, worktreeId = SOURCE): PersistedOpenFile { - return { filePath, relativePath: filePath.split('/').at(-1)!, worktreeId, language: 'markdown' } -} - -describe('session owner transfer', () => { - it('moves a hidden override keyed by the owned editor id to the destination worktree', () => { - const session = { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: { [SOURCE]: [file('/tmp/a/README.md')] }, - markdownFrontmatterVisible: { - [editorId(SOURCE, '/tmp/a/README.md')]: false, - // Visible is the hydration default, so it is not carried. - '/tmp/a/README.md': true - } - } - - const result = extractSessionForTransfer(session, 'repo-1', 'repo-9') - - expect(result.markdownFrontmatterVisible).toEqual({ - [editorId(DESTINATION, '/tmp/a/README.md')]: false - }) - }) - - it('refuses to guess an override two transferred files both claim', () => { - // Two host-qualified owners of one worktree each claim its unqualified editor id. - const owners = { [`h1|${SOURCE}`]: 'repo-9::/tmp/h1', [`h2|${SOURCE}`]: 'repo-9::/tmp/h2' } - const projection = { - mapOwnerKey: (key: string) => owners[key] ?? null, - mapWorktreeId: (key: string) => owners[key] ?? key - } - const files = { - [`h1|${SOURCE}`]: [file('/tmp/a/x.md')], - [`h2|${SOURCE}`]: [file('/tmp/a/x.md')] - } - - expect( - buildMarkdownFrontmatterIdMap(files, projection).get(editorId(SOURCE, '/tmp/a/x.md')) - ).toBe(null) - const visibility = extractSessionOwnersForTransfer( - { - ...getDefaultWorkspaceSession(), - openFilesByWorktree: files, - markdownFrontmatterVisible: { [editorId(SOURCE, '/tmp/a/x.md')]: false } - }, - projection - ).markdownFrontmatterVisible - - expect(visibility).toEqual({}) - }) - - it('carries a worktree focus key and lets the destination merge keep its own overrides', () => { - const transferred = extractSessionForTransfer( - { - ...getDefaultWorkspaceSession(), - activeWorkspaceKey: `worktree:${SOURCE}`, - openFilesByWorktree: { [SOURCE]: [file('/tmp/a/README.md')] }, - markdownFrontmatterVisible: { '/tmp/a/README.md': false } - }, - 'repo-1', - 'repo-9' - ) - expect(transferred.activeWorkspaceKey).toBe(`worktree:${DESTINATION}`) - - const merged = mergeWorkspaceSessions( - { ...getDefaultWorkspaceSession(), markdownFrontmatterVisible: { '/other.md': false } }, - transferred - ) - - expect(merged.markdownFrontmatterVisible).toEqual({ - '/other.md': false, - '/tmp/a/README.md': false - }) - }) -}) diff --git a/src/main/orca-profiles/profile-session-owner-transfer.ts b/src/main/orca-profiles/profile-session-owner-transfer.ts deleted file mode 100644 index 040cf921f35..00000000000 --- a/src/main/orca-profiles/profile-session-owner-transfer.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { BrowserPage, BrowserWorkspace } from '../../shared/browser-workspace-types' -import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location' -import type { Tab, TabGroup } from '../../shared/tab-types' -import type { TerminalTab } from '../../shared/terminal-tab-types' -import type { - PersistedOpenFile, - WorkspaceSessionState -} from '../../shared/workspace-session-state-types' -import { isWorkspaceKey } from '../../shared/workspace-scope' -import { SESSION_FIELDS_COPIED_BY_OWNER_KEY } from './profile-project-session-field-disposition' -import { mapMarkdownFrontmatterVisible } from './profile-session-markdown-transfer' - -export type SessionOwnerProjection = { - mapOwnerKey: (ownerKey: string) => string | null - mapWorktreeId: (worktreeId: string) => string -} - -export function extractSessionOwnersForTransfer( - session: WorkspaceSessionState | undefined, - projection: SessionOwnerProjection -): WorkspaceSessionState { - const source = session ?? getDefaultWorkspaceSession() - const transferred = getDefaultWorkspaceSession() - const terminalTabIds = new Set() - const browserWorkspaceIds = new Set() - const mapOwnerRecord = ( - record: Record | undefined, - mapValue: (value: T) => T - ): Record => { - const next: Record = {} - for (const [ownerKey, value] of Object.entries(record ?? {})) { - const nextOwnerKey = projection.mapOwnerKey(ownerKey) - if (nextOwnerKey) { - next[nextOwnerKey] = mapValue(value) - } - } - return next - } - transferred.tabsByWorktree = mapOwnerRecord(source.tabsByWorktree, (tabs) => - tabs.map((tab) => { - terminalTabIds.add(tab.id) - return mapTerminalTab(tab, projection) - }) - ) - // Newer sessions may persist a terminal only in the unified tab model while the legacy - // terminal map is absent. Keep its layout and pane metadata attached to the transferred tab. - for (const [ownerKey, tabs] of Object.entries(source.unifiedTabs ?? {})) { - if (!projection.mapOwnerKey(ownerKey)) { - continue - } - for (const tab of tabs) { - if (tab.contentType === 'terminal') { - terminalTabIds.add(tab.id) - terminalTabIds.add(tab.entityId) - } - } - } - transferred.openFilesByWorktree = mapOwnerRecord(source.openFilesByWorktree, (files) => - files.map((file) => mapOpenFile(file, projection)) - ) - transferred.markdownFrontmatterVisible = mapMarkdownFrontmatterVisible( - source.markdownFrontmatterVisible, - source.openFilesByWorktree, - projection - ) - transferred.browserTabsByWorktree = mapOwnerRecord(source.browserTabsByWorktree, (tabs) => - tabs.map((tab) => { - browserWorkspaceIds.add(tab.id) - return mapBrowserWorkspace(tab, projection) - }) - ) - transferred.browserPagesByWorkspace = copyBrowserPages( - source.browserPagesByWorkspace, - browserWorkspaceIds, - projection - ) - for (const field of SESSION_FIELDS_COPIED_BY_OWNER_KEY) { - const record = source[field] as Record | undefined - ;(transferred as Record)[field] = mapOwnerRecord(record, (value) => - structuredClone(value) - ) - } - transferred.unifiedTabs = mapOwnerRecord(source.unifiedTabs, (tabs) => - tabs.map((tab) => mapUnifiedTab(tab, projection)) - ) - transferred.tabGroups = mapOwnerRecord(source.tabGroups, (groups) => - groups.map((group) => mapTabGroup(group, projection)) - ) - transferred.terminalLayoutsByTabId = Object.fromEntries( - [...terminalTabIds].flatMap((tabId) => { - const layout = source.terminalLayoutsByTabId[tabId] - return layout ? [[tabId, structuredClone(layout)] as const] : [] - }) - ) - if (source.localOnlyScrollbackByTabId) { - transferred.localOnlyScrollbackByTabId = Object.fromEntries( - Object.entries(source.localOnlyScrollbackByTabId) - .filter(([tabId]) => terminalTabIds.has(tabId)) - .map(([tabId, buffers]) => [tabId, structuredClone(buffers)]) - ) - } - transferred.terminalPtyIncarnationsByPaneKey = Object.fromEntries( - Object.entries(source.terminalPtyIncarnationsByPaneKey ?? {}).filter(([paneKey]) => - paneBelongsToTabs(paneKey, terminalTabIds) - ) - ) - transferred.terminalSurfaceTombstonesByPaneKey = Object.fromEntries( - Object.entries(source.terminalSurfaceTombstonesByPaneKey ?? {}).flatMap( - ([paneKey, tombstone]) => - projection.mapOwnerKey(tombstone.worktreeId) - ? [ - [ - paneKey, - { - ...structuredClone(tombstone), - worktreeId: projection.mapWorktreeId(tombstone.worktreeId) - } - ] as const - ] - : [] - ) - ) - transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown - ?.filter((worktreeId) => projection.mapOwnerKey(worktreeId) !== null) - .map(projection.mapWorktreeId) - const activeWorktreeId = source.activeWorktreeId - ? projection.mapOwnerKey(source.activeWorktreeId) - : null - if (activeWorktreeId) { - transferred.activeWorktreeId = projection.mapWorktreeId(source.activeWorktreeId!) - } - const activeWorkspaceKey = source.activeWorkspaceKey - ? projection.mapOwnerKey(source.activeWorkspaceKey) - : null - if (activeWorkspaceKey && isWorkspaceKey(activeWorkspaceKey)) { - transferred.activeWorkspaceKey = activeWorkspaceKey - } - return transferred -} - -export function hasTransferredSessionState(session: WorkspaceSessionState): boolean { - return ( - Object.keys(session.tabsByWorktree).length > 0 || - Object.keys(session.openFilesByWorktree ?? {}).length > 0 || - Object.keys(session.markdownFrontmatterVisible ?? {}).length > 0 || - Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || - Object.keys(session.browserPagesByWorkspace ?? {}).length > 0 || - Object.keys(session.unifiedTabs ?? {}).length > 0 || - Object.keys(session.tabGroups ?? {}).length > 0 || - Object.keys(session.terminalLayoutsByTabId ?? {}).length > 0 || - SESSION_FIELDS_COPIED_BY_OWNER_KEY.some( - (field) => Object.keys(session[field] ?? {}).length > 0 - ) || - Object.keys(session.terminalSurfaceTombstonesByPaneKey ?? {}).length > 0 || - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length > 0 || - Boolean(session.activeWorktreeId || session.activeWorkspaceKey) || - (session.activeWorktreeIdsOnShutdown?.length ?? 0) > 0 - ) -} - -function mapTerminalTab(tab: TerminalTab, projection: SessionOwnerProjection): TerminalTab { - const { pendingActivationSpawn: _pendingActivationSpawn, ...persisted } = tab - return { - ...structuredClone(persisted), - worktreeId: projection.mapWorktreeId(tab.worktreeId) - } -} - -function mapOpenFile( - file: PersistedOpenFile, - projection: SessionOwnerProjection -): PersistedOpenFile { - return { - ...structuredClone(file), - worktreeId: projection.mapWorktreeId(file.worktreeId) - } -} - -function mapBrowserWorkspace( - workspace: BrowserWorkspace, - projection: SessionOwnerProjection -): BrowserWorkspace { - return { - ...structuredClone(workspace), - worktreeId: projection.mapWorktreeId(workspace.worktreeId), - ...(workspace.docLocation - ? { - docLocation: remapBrowserPageDocLocation( - workspace.docLocation, - workspace.docLocation.worktreeId, - projection.mapWorktreeId(workspace.docLocation.worktreeId) - ) - } - : {}), - // Why: the browser session profile and partition are source-profile-scoped. - sessionProfileId: null, - sessionPartition: null - } -} - -function mapBrowserPage(page: BrowserPage, projection: SessionOwnerProjection): BrowserPage { - return { - ...structuredClone(page), - worktreeId: projection.mapWorktreeId(page.worktreeId), - ...(page.docLocation - ? { - docLocation: remapBrowserPageDocLocation( - page.docLocation, - page.docLocation.worktreeId, - projection.mapWorktreeId(page.docLocation.worktreeId) - ) - } - : {}) - } -} - -function copyBrowserPages( - pagesByWorkspace: Record | undefined, - workspaceIds: ReadonlySet, - projection: SessionOwnerProjection -): Record { - const next: Record = {} - for (const [workspaceId, pages] of Object.entries(pagesByWorkspace ?? {})) { - if (workspaceIds.has(workspaceId)) { - next[workspaceId] = pages.map((page) => mapBrowserPage(page, projection)) - } - } - return next -} - -function mapUnifiedTab(tab: Tab, projection: SessionOwnerProjection): Tab { - return { - ...structuredClone(tab), - worktreeId: projection.mapWorktreeId(tab.worktreeId) - } -} - -function mapTabGroup(group: TabGroup, projection: SessionOwnerProjection): TabGroup { - return { ...structuredClone(group), worktreeId: projection.mapWorktreeId(group.worktreeId) } -} - -function paneBelongsToTabs(paneKey: string, tabIds: ReadonlySet): boolean { - const separator = paneKey.lastIndexOf(':') - return separator > 0 && tabIds.has(paneKey.slice(0, separator)) -} diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index 8ce23178f76..4994003eef2 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -131,42 +131,6 @@ afterEach(async () => { }) describe('ElectronServeBrowserProcess start-up', () => { - it('does not launch when startup is already cancelled', async () => { - const processHandle = new ElectronServeBrowserProcess(INSTALLED_EXECUTABLE) - started.push(processHandle) - await expect(processHandle.start(AbortSignal.abort())).rejects.toThrow() - expect(spawnProcessMock).not.toHaveBeenCalled() - }) - - it('cancels readiness polling and cleans up the unready sidecar', async () => { - await setControl({ capabilities: [['runtime.v1']] }) - const controller = new AbortController() - const processHandle = new ElectronServeBrowserProcess(INSTALLED_EXECUTABLE) - started.push(processHandle) - const starting = processHandle.start(controller.signal) - const outcome = starting.then( - () => ({ rejected: false }), - () => ({ rejected: true }) - ) - try { - await vi.waitFor(async () => expect(await sidecarRequests()).not.toHaveLength(0), { - timeout: 10_000 - }) - } finally { - controller.abort() - await outcome - } - expect(await outcome).toEqual({ rejected: true }) - expect(processHandle.isAvailable()).toBe(false) - const userDataPath = (spawnSpec().args ?? []) - .find((arg) => arg.startsWith('--user-data-dir='))! - .slice('--user-data-dir='.length) - const metadata = JSON.parse(await readFile(join(userDataPath, 'orca-runtime.json'), 'utf8')) - await processHandle.stop() - expect(existsSync(userDataPath)).toBe(false) - expect(() => process.kill(metadata.pid, 0)).toThrow() - }) - it('launches the installed app in headless serve mode without orcad browser env', async () => { for (const key of AGENT_BROWSER_ENVIRONMENT_KEYS) { vi.stubEnv(key, `leaked-${key}`) diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts index f03406e1eb0..ae3508289c9 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -97,13 +97,11 @@ export class ElectronServeBrowserProcess { constructor(private readonly executablePath: string) {} - async start(signal?: AbortSignal): Promise { - signal?.throwIfAborted() + async start(): Promise { const temporaryRoot = process.platform === 'win32' ? tmpdir() : '/tmp' const userDataPath = await mkdtemp(join(temporaryRoot, 'orcad-browser-')) this.sidecarDataPath = userDataPath const port = await reserveLoopbackPort() - signal?.throwIfAborted() const child = spawnProcess({ program: this.executablePath, args: [ @@ -124,14 +122,12 @@ export class ElectronServeBrowserProcess { const deadline = Date.now() + START_TIMEOUT_MS let lastError: unknown = null while (Date.now() < deadline) { - signal?.throwIfAborted() const metadata = readRuntimeMetadata(userDataPath) if (metadata) { try { const status = RuntimeStatusResult.parse( await sendOrcadSidecarRequest(metadata, 'status.get', undefined, 5_000) ) - signal?.throwIfAborted() if (status.capabilities?.includes('browser.headless.v1')) { this.metadata = metadata return @@ -144,7 +140,7 @@ export class ElectronServeBrowserProcess { if (child.exitCode !== null || child.signalCode !== null) { break } - await delay(100, undefined, { signal }) + await delay(100) } throw new Error( `Installed Electron browser provider did not become ready: ${ diff --git a/src/main/orcad/external-chromium-browser-process.test.ts b/src/main/orcad/external-chromium-browser-process.test.ts deleted file mode 100644 index 6122a4b2fe4..00000000000 --- a/src/main/orcad/external-chromium-browser-process.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { start, stop, initialize, clear } = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), - initialize: vi.fn(), - clear: vi.fn() -})) -vi.mock('./external-chromium-browser-session', () => ({ - ExternalChromiumBrowserSession: class { - start = start - stop = stop - } -})) -vi.mock('./external-chromium-tab-registry', () => ({ - ExternalChromiumTabRegistry: class { - initialize = initialize - clear = clear - } -})) - -import { ExternalChromiumBrowserProcess } from './external-chromium-browser-process' - -describe('external Chromium startup cancellation', () => { - beforeEach(() => vi.resetAllMocks()) - - it('does not publish a session that resolves after cancellation and permits cleanup', async () => { - const controller = new AbortController() - start.mockImplementation(async () => { - controller.abort() - return 'tab-live' - }) - const browser = new ExternalChromiumBrowserProcess( - '/opt/orca/agent-browser', - { executablePath: '/opt/orca/chromium', provider: 'chromium' }, - '/state' - ) - await expect(browser.start(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - expect(start).toHaveBeenCalledWith(controller.signal) - expect(initialize).not.toHaveBeenCalled() - expect(browser.isAvailable()).toBe(false) - await browser.stop() - expect(stop).toHaveBeenCalledWith() - expect(clear).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/orcad/external-chromium-browser-process.ts b/src/main/orcad/external-chromium-browser-process.ts index 112278547cd..912a44775e7 100644 --- a/src/main/orcad/external-chromium-browser-process.ts +++ b/src/main/orcad/external-chromium-browser-process.ts @@ -37,10 +37,8 @@ export class ExternalChromiumBrowserProcess { this.tabs = new ExternalChromiumTabRegistry(this.session) } - async start(signal?: AbortSignal): Promise { - const activeTabId = await this.session.start(signal) - signal?.throwIfAborted() - this.tabs.initialize(activeTabId) + async start(): Promise { + this.tabs.initialize(await this.session.start()) this.available = true } diff --git a/src/main/orcad/external-chromium-browser-session.test.ts b/src/main/orcad/external-chromium-browser-session.test.ts index 989d3f6f217..730a3d36e2d 100644 --- a/src/main/orcad/external-chromium-browser-session.test.ts +++ b/src/main/orcad/external-chromium-browser-session.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdir } from 'node:fs/promises' -const runProcessMock = vi.fn<(spec: Spec) => Promise>() +const runProcessMock = vi.fn() vi.mock('../../shared/child-process/run-process', () => ({ - runProcess: (spec: Spec) => runProcessMock(spec) + runProcess: (spec: unknown) => runProcessMock(spec) })) vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(async () => undefined), @@ -22,7 +21,7 @@ const BASE = { sessionName: 'orca-orcad-0123456789abcdef' } -type Spec = { args?: readonly string[]; env?: NodeJS.ProcessEnv; signal?: AbortSignal } +type Spec = { args?: readonly string[]; env?: NodeJS.ProcessEnv } function commands(): string[][] { return runProcessMock.mock.calls.map((call) => [...((call[0] as Spec).args ?? [])]) @@ -31,60 +30,8 @@ function commands(): string[][] { describe('orcad external-chromium agent-browser environment', () => { beforeEach(() => { runProcessMock.mockReset() - vi.mocked(mkdir).mockClear() }) - it('does no work when startup is already aborted', async () => { - const controller = new AbortController() - controller.abort() - const session = new ExternalChromiumBrowserSession( - '/opt/orca/agent-browser', - { executablePath: BASE.executablePath, provider: 'chromium' }, - '/state' - ) - await expect(session.start(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - expect(mkdir).not.toHaveBeenCalled() - expect(runProcessMock).not.toHaveBeenCalled() - }) - - it.each(['tab', 'close', 'open'])( - 'does not continue startup after abort during %s', - async (phase) => { - const controller = new AbortController() - runProcessMock.mockImplementation(async (spec: Spec) => { - if (spec.args?.includes(phase)) { - controller.abort() - } - return { - code: 0, - signal: null, - stdout: JSON.stringify({ success: true, data: { tabs: [] } }), - stderr: '', - timedOut: false - } - }) - const session = new ExternalChromiumBrowserSession( - '/opt/orca/agent-browser', - { executablePath: BASE.executablePath, provider: 'chromium' }, - '/state' - ) - await expect(session.start(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - const specs = runProcessMock.mock.calls.map(([spec]) => spec) - const issued = specs.map((spec) => - spec.args?.find((arg) => ['tab', 'close', 'open'].includes(arg)) - ) - expect(issued).toEqual( - phase === 'tab' ? ['tab'] : phase === 'close' ? ['tab', 'close'] : ['tab', 'close', 'open'] - ) - for (const spec of specs) { - expect(spec.signal).toBe(spec.args?.includes('close') ? undefined : controller.signal) - } - await session.stop() - expect(runProcessMock.mock.lastCall?.[0]).toMatchObject({ signal: undefined }) - expect(commands().at(-1)).toContain('close') - } - ) - // Why: this daemon owns the user's remote Chromium, so an idle bound would close a live browser. it('never bounds the daemon that owns the Chromium tree', () => { const env = externalChromiumAgentBrowserEnvironment({ inheritedEnv: {}, ...BASE }) diff --git a/src/main/orcad/external-chromium-browser-session.ts b/src/main/orcad/external-chromium-browser-session.ts index 56c7bd47e78..de72ae8a061 100644 --- a/src/main/orcad/external-chromium-browser-session.ts +++ b/src/main/orcad/external-chromium-browser-session.ts @@ -87,25 +87,20 @@ export class ExternalChromiumBrowserSession { this.profilePath = join(statePath, `browser-${launch.provider}`) } - async start(signal?: AbortSignal): Promise { - signal?.throwIfAborted() + async start(): Promise { await mkdir(this.profilePath, { recursive: true }) - signal?.throwIfAborted() // Why: the session name is stable across runs, so a daemon an earlier orcad left behind is // still driving the user's Chromium. Unlike the pane bridge this session never passes --cdp, // so nothing binds it to the old process — a surviving one is reusable as-is, and closing it // would take the remote user's browser and every tab with it (#16367). - const reusable = await this.readActiveTabId(signal) - signal?.throwIfAborted() + const reusable = await this.readActiveTabId() if (reusable) { return reusable } // Nothing answered, so anything under this name is wedged or half-dead; reclaim it. await this.stop() - signal?.throwIfAborted() - await this.run(['open', 'about:blank'], COMMAND_TIMEOUT_MS, signal) - const opened = await this.readActiveTabId(signal) - signal?.throwIfAborted() + await this.run(['open', 'about:blank']) + const opened = await this.readActiveTabId() if (!opened) { throw new BrowserError( BROWSER_UNAVAILABLE_ERROR_CODE, @@ -115,12 +110,11 @@ export class ExternalChromiumBrowserSession { return opened } - private async readActiveTabId(signal?: AbortSignal): Promise { + private async readActiveTabId(): Promise { try { - const tabs = await this.readTabs(signal) + const tabs = await this.readTabs() return (tabs.find((tab) => tab.active) ?? tabs[0])?.tabId ?? null } catch { - signal?.throwIfAborted() return null } } @@ -137,10 +131,8 @@ export class ExternalChromiumBrowserSession { await this.run(['tab', agentPageId]) } - async readTabs(signal?: AbortSignal): Promise { - return ( - AgentBrowserTabsResult.parse(await this.run(['tab'], COMMAND_TIMEOUT_MS, signal)).tabs ?? [] - ) + async readTabs(): Promise { + return AgentBrowserTabsResult.parse(await this.run(['tab'])).tabs ?? [] } async screenshot(params: Record, full: boolean): Promise { @@ -160,12 +152,7 @@ export class ExternalChromiumBrowserSession { return { data } } - async run( - command: readonly string[], - timeoutMs = COMMAND_TIMEOUT_MS, - signal?: AbortSignal - ): Promise { - signal?.throwIfAborted() + async run(command: readonly string[], timeoutMs = COMMAND_TIMEOUT_MS): Promise { const args = ['--session', this.sessionName, '--profile', this.profilePath] if (this.launch.browserArgs?.length) { args.push('--args', this.launch.browserArgs.join('\n')) @@ -183,10 +170,8 @@ export class ExternalChromiumBrowserSession { args, env, timeoutMs, - signal, maxOutputBytes: MAX_OUTPUT_BYTES }) - signal?.throwIfAborted() if (result.timedOut) { throw new BrowserError('browser_timeout', 'Browser command timed out.') } diff --git a/src/main/orcad/main-preflight-order.test.ts b/src/main/orcad/main-preflight-order.test.ts index 4f1f69763b9..8a7fc60407f 100644 --- a/src/main/orcad/main-preflight-order.test.ts +++ b/src/main/orcad/main-preflight-order.test.ts @@ -3,10 +3,6 @@ import { ORCAD_PROFILE_PREFLIGHT_FLAG, ORCAD_STARTUP_PREFLIGHT_FLAG } from '../../shared/orcad-profile-preflight' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG -} from '../../shared/orcad-stop-request' /** * The precondition is only worth anything if it runs first. A loader failure is not @@ -42,12 +38,6 @@ vi.mock('./orcad-native-preflight', () => ({ } })) -vi.mock('./orcad-managed-stop-command', () => ({ - runOrcadManagedStopCommandAndExit: async (argv: string[]) => { - order.push(`managed-stop:${argv.join(' ')}`) - } -})) - vi.mock('./orcad-entry', () => ({ main: async () => { order.push('main') @@ -77,15 +67,4 @@ describe('orcad entry', () => { expect(order).toEqual(['profile-admission', 'preflight', 'main']) }) - - it.each([ORCAD_COMPLETE_MANAGED_STOP_FLAG, ORCAD_CANCEL_MANAGED_STOP_FLAG])( - 'runs %s without preflights, the bundled handoff, or a runtime', - async (flag) => { - vi.spyOn(process, 'argv', 'get').mockReturnValue(['runtime', 'orcad.js', flag, '{}']) - await import('./main') - await vi.waitFor(() => expect(order).toHaveLength(1)) - - expect(order).toEqual([`managed-stop:${flag} {}`]) - } - ) }) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 211fc94540d..46f98853c02 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -8,10 +8,6 @@ import { } from '../../shared/orcad-profile-preflight' import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight' import { handoffToBundledOrcad } from './orcad-bundled-runtime' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG -} from '../../shared/orcad-stop-request' // 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 @@ -34,42 +30,28 @@ function failStartup(error: unknown): void { process.exit(resolveOrcadExitCode(error)) } -// Why before the bundled handoff and preflights: completing a stop must not start a runtime. -if ( - process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG || - process.argv[2] === ORCAD_CANCEL_MANAGED_STOP_FLAG -) { - void import('./orcad-managed-stop-command').then(({ runOrcadManagedStopCommandAndExit }) => - runOrcadManagedStopCommandAndExit(process.argv.slice(2)) - ) -} else { - startOrcadProcess() -} - -function startOrcadProcess(): void { - try { - if (!handoffToBundledOrcad()) { - const flag = process.argv[2] - if ( - (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && - process.argv.length === 4 - ) { - void runOrcadProfilePreflight(process.argv[3], { - nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG +try { + if (!handoffToBundledOrcad()) { + const flag = process.argv[2] + if ( + (flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) && + process.argv.length === 4 + ) { + void runOrcadProfilePreflight(process.argv[3], { + nativeFeatures: flag === ORCAD_PROFILE_PREFLIGHT_FLAG + }) + // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. + .then(() => process.stdout.write('', () => process.exit(0))) + .catch(failStartup) + } else { + void preflightBundledOrcadStartup() + .then(() => { + runOrcadNativePreflight() + return main() }) - // Why exit: the owner reads to EOF, so a lingering native handle must not hold the probe open. - .then(() => process.stdout.write('', () => process.exit(0))) - .catch(failStartup) - } else { - void preflightBundledOrcadStartup() - .then(() => { - runOrcadNativePreflight() - return main() - }) - .catch(failStartup) - } + .catch(failStartup) } - } catch (error) { - failStartup(error) } +} catch (error) { + failStartup(error) } diff --git a/src/main/orcad/orcad-browser-provider.ts b/src/main/orcad/orcad-browser-provider.ts index a350ef8f9f9..fdb8bfad34b 100644 --- a/src/main/orcad/orcad-browser-provider.ts +++ b/src/main/orcad/orcad-browser-provider.ts @@ -23,7 +23,6 @@ export type OrcadBrowserProvider = { export type OrcadBrowserProviderOptions = { userDataPath: string - signal?: AbortSignal environment?: NodeJS.ProcessEnv resolveInstalledElectronExecutable?: () => Promise resolveAgentBrowserBinary?: () => string | null @@ -31,20 +30,6 @@ export type OrcadBrowserProviderOptions = { type ExecutableProbe = 'ok' | 'missing' | 'not_executable' -class OrcadBrowserCleanupError extends AggregateError {} - -async function cleanupFailedProvider( - processHandle: { stop(): Promise }, - startupError: unknown -): Promise { - try { - await processHandle.stop() - } catch (cleanupError) { - throw new OrcadBrowserCleanupError([startupError, cleanupError], 'orcad_browser_cleanup_failed') - } - throw startupError -} - /** Splits the two failures apart: a wrong path and a forgotten chmod +x need different fixes. */ async function probeExecutable(path: string): Promise { const mode = process.platform === 'win32' ? constants.F_OK : constants.X_OK @@ -114,14 +99,14 @@ export async function resolveInstalledElectronExecutable(): Promise { const processHandle = new ExternalChromiumBrowserProcess(agentBrowserPath, launch, userDataPath) try { - await processHandle.start(signal) + await processHandle.start() } catch (error) { - return cleanupFailedProvider(processHandle, error) + await processHandle.stop() + throw error } return { kind: launch.provider, @@ -131,15 +116,13 @@ async function startProvider( } } -async function startElectronServeProvider( - executablePath: string, - signal?: AbortSignal -): Promise { +async function startElectronServeProvider(executablePath: string): Promise { const processHandle = new ElectronServeBrowserProcess(executablePath) try { - await processHandle.start(signal) + await processHandle.start() } catch (error) { - return cleanupFailedProvider(processHandle, error) + await processHandle.stop() + throw error } return { kind: 'electron', @@ -154,9 +137,6 @@ export async function resolveOrcadBrowserProvider( options: OrcadBrowserProviderOptions ): Promise { const environment = options.environment ?? process.env - if (options.signal?.aborted) { - return null - } await mkdir(options.userDataPath, { recursive: true, mode: 0o700 }) const declined = (cause: RuntimeBrowserUnavailableCause): null => { @@ -167,23 +147,14 @@ export async function resolveOrcadBrowserProvider( const installedElectronExecutable = await ( options.resolveInstalledElectronExecutable ?? resolveInstalledElectronExecutable )() - if (options.signal?.aborted) { - return null - } // Why held rather than reported now: Chromium may still resolve, and if it does not, its // own concrete fault is the more actionable one for an operator who set the env var. let electronFailure: RuntimeBrowserUnavailableCause | null = null if (installedElectronExecutable) { try { setRuntimeBrowserUnavailableCause(null) - return await startElectronServeProvider(installedElectronExecutable, options.signal) + return await startElectronServeProvider(installedElectronExecutable) } catch (error) { - if (error instanceof OrcadBrowserCleanupError) { - throw error - } - if (options.signal?.aborted) { - return null - } console.warn('[orcad] Installed Electron browser provider unavailable:', error) electronFailure = { reason: 'electron_start_failed', detail: errorDetail(error) } } @@ -203,9 +174,6 @@ export async function resolveOrcadBrowserProvider( } const probe = await probeExecutable(chromiumExecutable) - if (options.signal?.aborted) { - return null - } if (probe !== 'ok') { return declined({ reason: probe === 'missing' ? 'executable_not_found' : 'executable_not_executable', @@ -218,16 +186,9 @@ export async function resolveOrcadBrowserProvider( return await startProvider( agentBrowserPath, { executablePath: chromiumExecutable, provider: 'chromium' }, - options.userDataPath, - options.signal + options.userDataPath ) } catch (error) { - if (error instanceof OrcadBrowserCleanupError) { - throw error - } - if (options.signal?.aborted) { - return null - } console.warn('[orcad] External Chromium browser provider unavailable:', error) return declined({ reason: 'chromium_start_failed', detail: errorDetail(error) }) } diff --git a/src/main/orcad/orcad-browser-startup-cancellation.test.ts b/src/main/orcad/orcad-browser-startup-cancellation.test.ts deleted file mode 100644 index 9eca8d4e1da..00000000000 --- a/src/main/orcad/orcad-browser-startup-cancellation.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import { resolveOrcadBrowserProvider } from './orcad-browser-provider' - -const { start, stop, chromiumStart } = vi.hoisted(() => ({ - start: vi.fn(), - stop: vi.fn(), - chromiumStart: vi.fn() -})) -vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(), access: vi.fn() })) -vi.mock('./electron-serve-browser-process', () => ({ - ElectronServeBrowserProcess: class { - start = start - stop = stop - } -})) -vi.mock('./external-chromium-browser-process', () => ({ - ExternalChromiumBrowserProcess: class { - start = chromiumStart - } -})) -beforeEach(() => vi.resetAllMocks()) - -const options = { - userDataPath: '/fixture', - resolveInstalledElectronExecutable: async () => '/fixture/electron', - resolveAgentBrowserBinary: () => '/fixture/driver', - environment: { ORCA_BROWSER_EXECUTABLE: '/fixture/chromium' } -} - -it('does not launch a provider after pre-cancellation', async () => { - await expect( - resolveOrcadBrowserProvider({ ...options, signal: AbortSignal.abort() }) - ).resolves.toBeNull() - expect(start).not.toHaveBeenCalled() - expect(chromiumStart).not.toHaveBeenCalled() -}) - -it('cleans cancelled Electron startup without launching a fallback', async () => { - const controller = new AbortController() - start.mockImplementation(async () => { - controller.abort() - controller.signal.throwIfAborted() - }) - await expect( - resolveOrcadBrowserProvider({ ...options, signal: controller.signal }) - ).resolves.toBeNull() - expect(stop).toHaveBeenCalledOnce() - expect(chromiumStart).not.toHaveBeenCalled() -}) - -it('propagates failed cleanup even when startup was cancelled', async () => { - const controller = new AbortController() - start.mockImplementation(async () => { - controller.abort() - controller.signal.throwIfAborted() - }) - stop.mockRejectedValue(new Error('sidecar still owned')) - await expect( - resolveOrcadBrowserProvider({ ...options, signal: controller.signal }) - ).rejects.toThrow('orcad_browser_cleanup_failed') - expect(chromiumStart).not.toHaveBeenCalled() -}) diff --git a/src/main/orcad/orcad-browser-startup.test.ts b/src/main/orcad/orcad-browser-startup.test.ts deleted file mode 100644 index 9f2f5812ac7..00000000000 --- a/src/main/orcad/orcad-browser-startup.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { startOrcadBrowserProvider } from './orcad-browser-startup' -import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' -import { - createRuntimeBrowserCommands, - runtimeBrowserCommandsFactoryIsHeadless, - runtimeBrowserUnavailableCause, - setRuntimeBrowserCommandsFactory, - setRuntimeBrowserUnavailableCause -} from '../runtime/runtime-browser-commands-factory' -import type { - RuntimeBrowserCommandHost, - RuntimeBrowserCommands -} from '../runtime/orca-runtime-browser' - -vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: vi.fn() })) -afterEach(() => { - vi.restoreAllMocks() - setRuntimeBrowserCommandsFactory(null) - setRuntimeBrowserUnavailableCause(null) -}) - -const host: RuntimeBrowserCommandHost = Object.create(null) -function delayedProvider() { - const pending = Promise.withResolvers() - vi.mocked(resolveOrcadBrowserProvider).mockReturnValueOnce(pending.promise) - const command = vi.fn(() => ({ tabs: [] })) - const provider: OrcadBrowserProvider = { - kind: 'electron', - factory: vi.fn((): RuntimeBrowserCommands => - Object.assign(Object.create(null), { browserTabList: command }) - ), - isAvailable: vi.fn(() => true), - stop: vi.fn(async () => {}) - } - const startup = startOrcadBrowserProvider({ userDataPath: '/private-fixture' }) - return { pending, provider, command, startup } -} - -it('returns before discovery and keeps already-created commands usable after readiness', async () => { - const { pending, provider, command, startup } = delayedProvider() - const commands = createRuntimeBrowserCommands(host) - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(false) - expect(() => commands.browserTabList({})).toThrow(/unavailable/) - pending.resolve(provider) - await startup.ready - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(true) - commands.browserTabList({}) - commands.browserTabList({}) - expect(provider.factory).toHaveBeenCalledTimes(1) - expect(command).toHaveBeenCalledTimes(2) - await startup.stop() - expect(() => commands.browserTabList({})).toThrow(/unavailable/) - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(false) -}) - -it('aborts pending discovery and awaits a late provider cleanup exactly once', async () => { - const { pending, provider, startup } = delayedProvider() - await Promise.resolve() - const signal = vi.mocked(resolveOrcadBrowserProvider).mock.calls.at(-1)![0].signal! - const stopped = startup.stop() - expect(startup.stop()).toBe(stopped) - expect(signal.aborted).toBe(true) - pending.resolve(provider) - await stopped - expect(provider.stop).toHaveBeenCalledTimes(1) - expect(runtimeBrowserCommandsFactoryIsHeadless()).toBe(false) -}) - -it('retains specific provider-unavailable diagnostics after discovery declines', async () => { - const { pending, startup } = delayedProvider() - setRuntimeBrowserUnavailableCause({ reason: 'driver_missing' }) - pending.resolve(null) - await startup.ready - expect(runtimeBrowserUnavailableCause()).toEqual({ reason: 'driver_missing' }) - await startup.stop() -}) - -it('does not hide resolver cleanup failures from the runtime lifetime', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { pending, startup } = delayedProvider() - pending.reject(new Error('cleanup failed')) - await startup.ready - await expect(startup.stop()).rejects.toThrow('cleanup failed') -}) - -it('propagates ready-provider cleanup failures', async () => { - const { pending, provider, startup } = delayedProvider() - vi.mocked(provider.stop).mockRejectedValueOnce(new Error('stop failed')) - pending.resolve(provider) - await startup.ready - await expect(startup.stop()).rejects.toThrow('stop failed') -}) - -it('refuses a member the provider does not implement instead of invoking it', async () => { - const { pending, provider, startup } = delayedProvider() - const commands = createRuntimeBrowserCommands(host) - pending.resolve(provider) - await startup.ready - // The fixture provider implements only browserTabList. - expect(() => commands.browserTabCreate({ worktree: 'wt-a' })).toThrow('Unknown browser command') - await startup.stop() -}) diff --git a/src/main/orcad/orcad-browser-startup.ts b/src/main/orcad/orcad-browser-startup.ts deleted file mode 100644 index fb7dc03a0ab..00000000000 --- a/src/main/orcad/orcad-browser-startup.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { BrowserError } from '../browser/browser-error' -import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' -import type { RuntimeBrowserCommands } from '../runtime/orca-runtime-browser' -import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { - resolveOrcadBrowserProvider, - type OrcadBrowserProvider, - type OrcadBrowserProviderOptions -} from './orcad-browser-provider' - -/** Browser discovery must not hold core RPC readiness hostage to a desktop authorization UI. */ -export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): { - ready: Promise - stop(): Promise -} { - const controller = new AbortController() - let provider: OrcadBrowserProvider | null = null - let startupError: unknown - let stopping: Promise | undefined - setRuntimeBrowserCommandsFactory( - (host) => { - let commands: RuntimeBrowserCommands | undefined - // Every member resolves through the getter, so the target needs no members of its own. - const target: RuntimeBrowserCommands = Object.create(null) - return new Proxy(target, { - get: (_target, property) => { - if (property === 'then' || typeof property !== 'string') { - return undefined - } - return (...args: unknown[]) => { - if (controller.signal.aborted || !provider?.isAvailable()) { - throw new BrowserError( - BROWSER_UNAVAILABLE_ERROR_CODE, - 'Browser automation is unavailable on this host.' - ) - } - commands ??= provider.factory(host) - return callBrowserCommand(commands, property, args) - } - } - }) - }, - { headless: true, isAvailable: () => !controller.signal.aborted && !!provider?.isAvailable() } - ) - const ready = Promise.resolve() - .then(() => resolveOrcadBrowserProvider({ ...options, signal: controller.signal })) - .then( - (resolved) => { - provider = resolved - if (!resolved && !controller.signal.aborted) { - setRuntimeBrowserCommandsFactory(null) - } - }, - (error: unknown) => { - startupError = error - if (!controller.signal.aborted) { - setRuntimeBrowserCommandsFactory(null) - console.warn('[orcad] Browser startup failed:', error) - } - } - ) - return { - ready, - stop: () => { - controller.abort() - stopping ??= ready.then(async () => { - await provider?.stop() - // Unexpected resolver errors may include failed cleanup of a partially started provider. - if (startupError) { - throw startupError - } - }) - return stopping - } - } -} - -type BrowserCommandMember = (this: RuntimeBrowserCommands, ...args: unknown[]) => unknown - -function isBrowserCommandMember(value: unknown): value is BrowserCommandMember { - return typeof value === 'function' -} - -/** The proxy forwards by name; anything that is not a command method is refused, not invoked. */ -function callBrowserCommand( - commands: RuntimeBrowserCommands, - name: string, - args: unknown[] -): unknown { - const member: unknown = name in commands ? commands[name] : undefined - if (!isBrowserCommandMember(member)) { - throw new BrowserError(BROWSER_UNAVAILABLE_ERROR_CODE, `Unknown browser command: ${name}`) - } - return member.call(commands, ...args) -} diff --git a/src/main/orcad/orcad-completed-stop-receipt.ts b/src/main/orcad/orcad-completed-stop-receipt.ts deleted file mode 100644 index aa89cce87d5..00000000000 --- a/src/main/orcad/orcad-completed-stop-receipt.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** Durable outcomes of a managed stop, in the data root beside the instance lock. */ -import { lstatSync } from 'node:fs' -import { dirname, join } from 'node:path' -import type { z } from 'zod' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { writeDurableSecureJsonFile } from '../../shared/secure-file' -import { - ORCAD_STOP_RECEIPTS_DIRNAME, - OrcadCompletedStopReceiptSchema, - OrcadManagedStopRequestSchema, - OrcadDaemonRetirementRecordSchema, - type OrcadCompletedStopReceipt, - type OrcadDaemonRetirementRecord, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' - -const RECEIPT_MAX_BYTES = 64 * 1024 - -export type ReceiptKind = 'completed' | 'retirement' | 'decision' - -export function orcadStopReceiptPath(request: OrcadManagedStopRequest, kind: ReceiptKind): string { - const { transactionId, instance } = OrcadManagedStopRequestSchema.parse(request) - const suffix = kind === 'completed' ? '' : `.${kind}` - return join( - dirname(instance.lockPath), - ORCAD_STOP_RECEIPTS_DIRNAME, - `${transactionId}${suffix}.json` - ) -} - -/** `null` when absent; a receipt for a different request throws rather than reading as absent. */ -export function readOrcadStopReceipt( - request: OrcadManagedStopRequest, - kind: ReceiptKind, - schema: z.ZodType -): T | null { - const path = orcadStopReceiptPath(request, kind) - try { - if (!lstatSync(path).isFile()) { - throw new Error('orcad_stop_receipt_unverifiable') - } - } catch (error) { - if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { - return null - } - throw error - } - const receipt = schema.parse( - JSON.parse(readNodeFileSyncWithinLimit(path, RECEIPT_MAX_BYTES).buffer.toString('utf8')) - ) - const expected = OrcadManagedStopRequestSchema.parse(request) - if (JSON.stringify(receipt.request) !== JSON.stringify(expected)) { - throw new Error('orcad_stop_receipt_mismatch') - } - return receipt -} - -function writeReceipt(request: OrcadManagedStopRequest, kind: ReceiptKind, receipt: unknown): void { - if (!writeDurableSecureJsonFile(orcadStopReceiptPath(request, kind), receipt)) { - throw new Error('orcad_stop_receipt_permissions_unconfirmed') - } -} - -export function readOrcadCompletedStopReceipt( - request: OrcadManagedStopRequest -): OrcadCompletedStopReceipt | null { - return readOrcadStopReceipt(request, 'completed', OrcadCompletedStopReceiptSchema) -} - -/** - * Called only after exit is proven; rewriting an existing receipt re-runs its fsync. A retiring - * request whose orcad left no retirement record reports `unverifiable`, never `retired`. - */ -export function persistOrcadCompletedStopReceipt( - request: OrcadManagedStopRequest, - exitedAt: Date -): OrcadCompletedStopReceipt { - const parsed = OrcadManagedStopRequestSchema.parse(request) - const retirement = parsed.retireIdleDaemon - ? (readOrcadDaemonRetirementRecord(parsed)?.retirement ?? 'unverifiable') - : undefined - const receipt = readOrcadCompletedStopReceipt(parsed) ?? { - schemaVersion: 1 as const, - kind: 'orcad_managed_stop_completed' as const, - request: parsed, - exitedAt: exitedAt.toISOString(), - ...(retirement ? { retirement } : {}) - } - writeReceipt(parsed, 'completed', receipt) - return receipt -} - -export function readOrcadDaemonRetirementRecord( - request: OrcadManagedStopRequest -): OrcadDaemonRetirementRecord | null { - return readOrcadStopReceipt(request, 'retirement', OrcadDaemonRetirementRecordSchema) -} - -export function persistOrcadDaemonRetirementRecord( - request: OrcadManagedStopRequest, - outcome: Pick -): void { - writeReceipt(request, 'retirement', { - schemaVersion: 1, - kind: 'orcad_managed_stop_retirement', - request: OrcadManagedStopRequestSchema.parse(request), - ...outcome - }) -} diff --git a/src/main/orcad/orcad-daemon-retirement.test.ts b/src/main/orcad/orcad-daemon-retirement.test.ts deleted file mode 100644 index 01c0d780da5..00000000000 --- a/src/main/orcad/orcad-daemon-retirement.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('../daemon/daemon-init', () => ({ - requestIdleDaemonRetirement: vi.fn(), - listLiveDaemonSessions: vi.fn(), - releaseDaemonRetirementFence: vi.fn() -})) - -import type { DaemonIdleRetirementResult } from '../daemon/daemon-pty-runtime-state' -import { retireOrcadDaemonIfIdle } from './orcad-daemon-retirement' - -function ports( - result: () => Promise, - liveSessions: number | null = null -) { - return { - request: vi.fn(result), - releaseFence: vi.fn(), - countLiveSessions: vi.fn(async () => liveSessions), - timeoutMs: 20 - } -} - -describe('best-effort daemon retirement', () => { - it('reports retired only when the daemon accepted, and keeps its fence', async () => { - const retiring = ports(async () => ({ state: 'retiring' })) - expect(await retireOrcadDaemonIfIdle(retiring)).toMatchObject({ retirement: 'retired' }) - expect(retiring.releaseFence).not.toHaveBeenCalled() - }) - - it('leaves a busy daemon running, reopens admission, and reports live', async () => { - const busy = ports(async () => ({ state: 'busy', liveSessions: 3 })) - expect(await retireOrcadDaemonIfIdle(busy)).toMatchObject({ - retirement: 'live', - liveSessions: 3 - }) - expect(busy.releaseFence).toHaveBeenCalledOnce() - }) - - it('counts sessions itself when the daemon did not say how many', async () => { - const busy = ports(async () => ({ state: 'busy', liveSessions: null }), 1) - expect(await retireOrcadDaemonIfIdle(busy)).toMatchObject({ - retirement: 'live', - liveSessions: 1 - }) - }) - - it.each([ - [ - 'an unsupported daemon', - async (): Promise => ({ state: 'unsupported' }) - ], - [ - 'an unverifiable census', - async (): Promise => ({ state: 'unverifiable' }) - ], - [ - 'lost contact', - async (): Promise => { - throw new Error('socket closed') - } - ], - ['no answer', () => new Promise(() => {})] - ])('never reads %s as idle, and reopens admission', async (_name, result) => { - const unanswered = ports(result) - expect(await retireOrcadDaemonIfIdle(unanswered)).toMatchObject({ - retirement: 'unverifiable', - liveSessions: null - }) - expect(unanswered.releaseFence).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/orcad/orcad-daemon-retirement.ts b/src/main/orcad/orcad-daemon-retirement.ts deleted file mode 100644 index c5f0bb3936f..00000000000 --- a/src/main/orcad/orcad-daemon-retirement.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Best-effort retirement of the terminal daemon when a managed stop asks for it. - * - * The daemon normally outlives orcad so terminals survive a restart (D7). Retirement happens - * only when the daemon itself proves it owns no live session across every generation; a busy - * or unanswering daemon stays up, and orcad's own stop never waits on that outcome. - */ -import { - listLiveDaemonSessions, - releaseDaemonRetirementFence, - requestIdleDaemonRetirement -} from '../daemon/daemon-init' -import type { OrcadDaemonRetirementVerdict } from '../../shared/orcad-stop-request' - -export const ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS = 5_000 - -export type OrcadDaemonRetirement = { - retirement: OrcadDaemonRetirementVerdict - liveSessions: number | null - reason: string | null -} - -/** Live sessions across every daemon generation, or `null` when any could not answer. */ -export async function countLiveOrcadDaemonSessions(): Promise { - try { - const sessions = await listLiveDaemonSessions() - return sessions ? sessions.filter((session) => session.isAlive).length : null - } catch { - return null - } -} - -type RetirementPorts = { - request: typeof requestIdleDaemonRetirement - releaseFence: typeof releaseDaemonRetirementFence - countLiveSessions: typeof countLiveOrcadDaemonSessions - timeoutMs: number -} - -const DEFAULT_PORTS: RetirementPorts = { - request: requestIdleDaemonRetirement, - releaseFence: releaseDaemonRetirementFence, - countLiveSessions: countLiveOrcadDaemonSessions, - timeoutMs: ORCAD_DAEMON_RETIREMENT_TIMEOUT_MS -} - -export async function retireOrcadDaemonIfIdle( - ports: Partial = {} -): Promise { - const { request, releaseFence, countLiveSessions, timeoutMs } = { ...DEFAULT_PORTS, ...ports } - const result = await withTimeout( - request().catch(() => ({ state: 'unverifiable' as const })), - timeoutMs, - { state: 'timed-out' as const } - ) - if (result.state === 'retiring') { - return { retirement: 'retired', liveSessions: 0, reason: null } - } - // A daemon that stays must not be left refusing new terminals. - releaseFence() - const liveSessions = - result.state === 'busy' && result.liveSessions !== null - ? result.liveSessions - : await withTimeout(countLiveSessions(), timeoutMs, null) - if (liveSessions !== null && liveSessions > 0) { - return { - retirement: 'live', - liveSessions, - reason: - `${liveSessions} terminal ${liveSessions === 1 ? 'session is' : 'sessions are'} still ` + - 'live, so the daemon stays up and keeps them.' - } - } - return { - retirement: 'unverifiable', - liveSessions, - reason: - result.state === 'unsupported' - ? 'The terminal daemon predates idle retirement, so it was left running.' - : 'The host could not prove the daemon idle, so it was left running.' - } -} - -/** No answer within the bound is `fallback`; the caller treats it as unverifiable. */ -async function withTimeout(work: Promise, timeoutMs: number, fallback: F): Promise { - let timer: ReturnType | undefined - try { - return await Promise.race([ - work, - new Promise((resolve) => { - timer = setTimeout(() => resolve(fallback), timeoutMs) - }) - ]) - } finally { - clearTimeout(timer) - } -} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 2aa611658b7..854b4dfb723 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -22,10 +22,6 @@ import { startOrcadWithHost } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' -import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' -import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' -import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' -import type { OrcadManagedStopContext } from '../../shared/orcad-stop-request' import { changedAiVaultSearchSettings, type AiVaultSearchSettings @@ -41,18 +37,13 @@ function createNodeAppEnvironment(): AppEnvironment { // 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. runOrcadQuitHandlers = (): void => { - const errors: unknown[] = [] for (const handler of quitHandlers.splice(0)) { try { handler() } catch (error) { - errors.push(error) + console.error('[orcad] shutdown handler failed:', error) } } - // Why throw: a quit handler that failed may leave a writer running, which keeps the lock. - if (errors.length > 0) { - throw new AggregateError(errors, 'orcad_quit_handlers_failed') - } } return { getPath: resolveOrcadPath, @@ -106,8 +97,6 @@ export type OrcadOptions = { export type OrcadHandle = { readiness: ServeReadiness - /** What an instance-bound stop request must name to stop this process. */ - managedStop: OrcadManagedStopContext stop(): Promise } @@ -118,7 +107,7 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() - const { readiness, instance, stop } = await startOrcadWithHost( + return startOrcadWithHost( resolveUserDataPath(), (registerCleanup) => startOrcadRuntime(options, registerCleanup), () => { @@ -128,13 +117,11 @@ export async function startOrcad(options: OrcadOptions = {}): Promise {} } ) - const version = process.env.ORCA_VERSION ?? '0.0.0-orcad' - return { readiness, managedStop: { version, runtimeId: readiness.runtimeId, instance }, stop } } async function startOrcadRuntime( options: OrcadOptions, - registerCleanup: (cleanup: OrcadRuntimeCleanup) => void + registerCleanup: (cleanup: () => Promise) => void ): Promise> { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') @@ -158,28 +145,35 @@ async function startOrcadRuntime( const { AgentStatusObservedPaneIdentities, AgentStatusObservedPaneIdentityCapture } = await import('../runtime/agent-status-observed-pane-identity') - const { disposeWatcherProcessAndWait } = await import('../ipc/parcel-watcher-process') - + let rpc: InstanceType | null = null let profileStoreForShutdown: | { flushFinalOrThrowAsync(): Promise; freezeWritesAsync(): Promise } | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} - // Cleanups run in reverse: RPC, then recovery and watchers, then the final flush, then daemon. - registerCleanup(() => agentHookServer.stop()) - registerCleanup(() => uninstallHookStatusRepublish()) - registerCleanup(() => uninstallObservedStatusIdentity()) - // Why disconnect and not shut down: the daemon must outlive this process, or an orcad - // restart goes back to killing every running terminal. - registerCleanup(() => stopOrcadDaemon()) registerCleanup(async () => { - // A SQLite-backed orcad has no JSON mirror to absorb a debounced write after SIGTERM. - if (profileStoreForShutdown) { - await flushOrcadProfileStoreForShutdown(profileStoreForShutdown) + try { + await rpc?.stop() + } finally { + try { + // Stop accepting RPC writes before the final persistence barrier. A SQLite-backed + // orcad has no JSON mirror to absorb a debounced write after SIGTERM. + if (profileStoreForShutdown) { + await flushOrcadProfileStoreForShutdown(profileStoreForShutdown) + } + } finally { + try { + // Why disconnect and not shut down: the daemon must outlive this process, or an + // orcad restart goes back to killing every running terminal. + await stopOrcadDaemon() + } finally { + uninstallObservedStatusIdentity() + uninstallHookStatusRepublish() + agentHookServer.stop() + } + } } }) - // Watcher children outlive a disposal that does not wait for them. - registerCleanup(() => disposeWatcherProcessAndWait()) const { DesktopPushService } = await import('../runtime/push/desktop-push-service') const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin') @@ -302,14 +296,11 @@ async function startOrcadRuntime( await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() - // A retry armed during recovery would otherwise write after the final profile flush. - registerCleanup(() => runtime.stopLegacyWorkerTerminalRecovery()) - // Recovery binds terminal and dispatch identities; only now can startup observations be fenced. observedStatusCapture.attach(runtime) const bindHost = resolveOrcadBindHost(options.bind) - const rpc = new OrcaRuntimeRpcServer({ + rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, enableWebSocket: true, @@ -320,8 +311,6 @@ async function startOrcadRuntime( pinnedBindHost: bindHost, ...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {}) }) - // Stops first: no RPC may write while the rest of the runtime is torn down. - registerCleanup(() => rpc.stop()) await rpc.start() const pushService = DesktopPushService.create({ runtime, @@ -399,12 +388,6 @@ export { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-lifecycle' export async function main(argv: string[] = process.argv.slice(2)): Promise { const startup = startOrcad(parseArgs(argv)) - const requestShutdown = installOrcadShutdownSignals(async () => (await startup).stop()) - const handle = await startup - // Why after startup: a managed request must name the runtime and instance this run became. - installOrcadStopRequestListeners(() => requestShutdown('stop request'), { - installRoot: resolveOrcadInstallRoot(), - managedStop: handle.managedStop, - beforeManagedStop: prepareOrcadManagedStop - }) + installOrcadShutdownSignals(async () => (await startup).stop()) + await startup } diff --git a/src/main/orcad/orcad-health.test.ts b/src/main/orcad/orcad-health.test.ts index 4f75c7c9025..70e02b37e87 100644 --- a/src/main/orcad/orcad-health.test.ts +++ b/src/main/orcad/orcad-health.test.ts @@ -11,21 +11,13 @@ const { readDaemonPidRecordMock, daemonOwnsFreshPersistentPtysMock } = vi.hoisted(() => ({ - checkDaemonHealthMock: vi.fn<(socketPath: string, tokenPath: string) => Promise>(), + checkDaemonHealthMock: vi.fn<() => Promise>(), getDaemonEndpointFactsMock: vi.fn<() => unknown>(), readDaemonPidRecordMock: vi.fn<() => ParsedDaemonPid | null>(), daemonOwnsFreshPersistentPtysMock: vi.fn<() => boolean>() })) -// The real coverage fallback is per platform; a daemon may also report its own coverage. -const reportedCoverage = vi.hoisted(() => ({ value: new Array<'pty-spawn' | 'handshake'>() })) -vi.mock('../daemon/daemon-health', () => ({ - checkDaemonHealthWithCoverage: async (socketPath: string, tokenPath: string) => ({ - verdict: await checkDaemonHealthMock(socketPath, tokenPath), - coverage: - reportedCoverage.value.shift() ?? (process.platform === 'win32' ? 'handshake' : 'pty-spawn') - }) -})) +vi.mock('../daemon/daemon-health', () => ({ checkDaemonHealth: checkDaemonHealthMock })) vi.mock('../daemon/daemon-init', () => ({ getDaemonEndpointFacts: getDaemonEndpointFactsMock, readDaemonPidRecord: readDaemonPidRecordMock, @@ -131,12 +123,6 @@ describe('collectTerminalDaemonHealth', () => { // green verdict there must not be reported as a PTY round trip. expect(health.selfTest.coverage).toBe('handshake') }) - - it('reports the coverage the daemon says its probe achieved', async () => { - reportedCoverage.value.push('handshake') - const health = await collectTerminalDaemonHealth() - expect(health.selfTest).toMatchObject({ ok: true, coverage: 'handshake' }) - }) }) describe('collectOrcadHealth', () => { diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index 6507763f3cf..0c3a3525171 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -11,14 +11,13 @@ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import process from 'node:process' -import { checkDaemonHealthWithCoverage, type DaemonHealth } from '../daemon/daemon-health' +import { checkDaemonHealth, type DaemonHealth } from '../daemon/daemon-health' import { daemonOwnsFreshPersistentPtys, getDaemonEndpointFacts, readDaemonPidRecord } from '../daemon/daemon-init' import type { OrcadProfileStateAuthoritySelection } from './orcad-profile-state-telemetry' -import { ORCAD_STOP_REQUESTS_CAPABILITY } from '../../shared/orcad-stop-request' /** * How much a green self-test actually proves. @@ -68,11 +67,6 @@ export type OrcadHealth = { terminalDaemon: TerminalDaemonHealth /** The low-cardinality profile-state authority selected during startup, when available. */ profileStateAuthority?: OrcadProfileStateAuthoritySelection - /** - * Present when this build consumes stop-request files and answers the managed-stop commands. - * Absent on older builds, which a client must keep stopping with SIGTERM. - */ - stopRequests?: typeof ORCAD_STOP_REQUESTS_CAPABILITY } /** @@ -106,18 +100,14 @@ 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) { - // 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' return { ok: false, coverage, verdict: 'no-daemon', durationMs: now() - startedAt } } - // The daemon reports what its probe actually did; an older daemon falls back by platform. - const { verdict, coverage } = await checkDaemonHealthWithCoverage( - facts.socketPath, - facts.tokenPath - ) + const verdict = await checkDaemonHealth(facts.socketPath, facts.tokenPath) return { ok: verdict === 'healthy', coverage, verdict, durationMs: now() - startedAt } } @@ -172,7 +162,6 @@ export async function collectOrcadHealth( arch: process.arch, pid: process.pid, terminalDaemon: await collectTerminalDaemonHealth(), - ...(profileStateAuthority ? { profileStateAuthority } : {}), - stopRequests: ORCAD_STOP_REQUESTS_CAPABILITY + ...(profileStateAuthority ? { profileStateAuthority } : {}) } } diff --git a/src/main/orcad/orcad-instance-lock.test.ts b/src/main/orcad/orcad-instance-lock.test.ts index 354976a72ba..45f7543c515 100644 --- a/src/main/orcad/orcad-instance-lock.test.ts +++ b/src/main/orcad/orcad-instance-lock.test.ts @@ -3,7 +3,6 @@ import { mkdirSync, mkdtempSync, readFileSync, - renameSync, rmSync, statSync, writeFileSync @@ -15,8 +14,7 @@ import { acquireOrcadInstanceLock, ORCAD_LOCK_FILE_NAME, OrcadInstanceLockError, - type OrcadInstanceLockHooks, - type OrcadLockRecord + type OrcadInstanceLockHooks } from './orcad-instance-lock' const roots: string[] = [] @@ -39,18 +37,6 @@ function hooks(overrides: OrcadInstanceLockHooks = {}): OrcadInstanceLockHooks { } } -function persistedRecord(overrides: Partial = {}): OrcadLockRecord { - return { - pid: 424242, - startedAtMs: 1, - identity: 'uid-1000', - version: '1.0.0-test', - acquiredAt: '2026-01-01T00:00:00.000Z', - nonce: 'stale', - ...overrides - } -} - afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) @@ -82,14 +68,20 @@ describe('acquireOrcadInstanceLock', () => { it('reclaims the record of a holder that is gone', () => { const root = makeRoot() - writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), JSON.stringify(persistedRecord())) + 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(persistedRecord())) + 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 }) @@ -97,37 +89,11 @@ describe('acquireOrcadInstanceLock', () => { expect(JSON.parse(readFileSync(lock.path, 'utf8')).pid).toBe(process.pid) }) - it('does not displace a successor published between stale inspection and reclaim', () => { - const root = makeRoot() - const lockPath = join(root, ORCAD_LOCK_FILE_NAME) - const successor = persistedRecord({ pid: 777, startedAtMs: 2, nonce: 'successor' }) - writeFileSync(lockPath, JSON.stringify(persistedRecord())) - let raced = false - const lockHooks = hooks({ - processIsAlive: () => { - if (!raced) { - raced = true - // Simulate a contender replacing the stale record and publishing its own lock - // after this process inspected liveness but before it renames the entry. - const displacedPath = `${lockPath}.displaced` - renameSync(lockPath, displacedPath) - writeFileSync(lockPath, JSON.stringify(successor), { flag: 'wx', mode: 0o600 }) - } - return false - } - }) - - expect(() => acquireOrcadInstanceLock(root, lockHooks)).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_held' }) - ) - expect(JSON.parse(readFileSync(lockPath, 'utf8')).nonce).toBe('successor') - }) - 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(persistedRecord({ identity: 'uid-2000', nonce: 'other' })) + 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' }) @@ -140,47 +106,12 @@ describe('acquireOrcadInstanceLock', () => { // A successor reclaimed the root while this process was wedged. writeFileSync( lock.path, - JSON.stringify(persistedRecord({ pid: 777, startedAtMs: 2, nonce: 'successor' })) + JSON.stringify({ pid: 777, identity: 'uid-1000', startedAtMs: 2, nonce: 'successor' }) ) lock.release() expect(JSON.parse(readFileSync(lock.path, 'utf8')).nonce).toBe('successor') }) - it.each([ - ['invalid JSON', '{'], - ['an incomplete record', JSON.stringify({ pid: 424242, identity: 'uid-1000' })], - ['an invalid pid', JSON.stringify(persistedRecord({ pid: -1 }))], - ['an invalid start time', JSON.stringify({ ...persistedRecord(), startedAtMs: 'yesterday' })] - ])('fails closed when the existing lock contains %s', (_label, contents) => { - const root = makeRoot() - writeFileSync(join(root, ORCAD_LOCK_FILE_NAME), contents) - - expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_unreadable' }) - ) - expect(readFileSync(join(root, ORCAD_LOCK_FILE_NAME), 'utf8')).toBe(contents) - }) - - it('fails closed when the existing lock is not a regular file', () => { - const root = makeRoot() - mkdirSync(join(root, ORCAD_LOCK_FILE_NAME)) - - expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_unreadable' }) - ) - }) - - it('fails closed without reading an oversized lock into memory', () => { - const root = makeRoot() - const lockPath = join(root, ORCAD_LOCK_FILE_NAME) - writeFileSync(lockPath, 'x'.repeat(64 * 1024 + 1)) - - expect(() => acquireOrcadInstanceLock(root, hooks())).toThrow( - expect.objectContaining({ code: 'orcad_instance_lock_unreadable' }) - ) - expect(statSync(lockPath).size).toBe(64 * 1024 + 1) - }) - it.runIf(process.platform !== 'win32')( 'tightens a group/world-accessible data root rather than refusing when it can', () => { diff --git a/src/main/orcad/orcad-instance-lock.ts b/src/main/orcad/orcad-instance-lock.ts index 35583526256..052140bcd2e 100644 --- a/src/main/orcad/orcad-instance-lock.ts +++ b/src/main/orcad/orcad-instance-lock.ts @@ -15,8 +15,8 @@ import { randomUUID } from 'node:crypto' import { chmodSync, - linkSync, mkdirSync, + readFileSync, renameSync, statSync, unlinkSync, @@ -25,12 +25,9 @@ import { import { userInfo } from 'node:os' import { join } from 'node:path' import process from 'node:process' -import { z } from 'zod' import { getProcessStartedAtMs, startTimeMatches } from '../daemon/daemon-process-start-time' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' export const ORCAD_LOCK_FILE_NAME = 'orcad.lock' -const MAX_ORCAD_LOCK_BYTES = 64 * 1024 export type OrcadInstanceLockCode = | 'orcad_data_root_unusable' @@ -38,7 +35,6 @@ export type OrcadInstanceLockCode = | 'orcad_data_root_shared' | 'orcad_instance_lock_held' | 'orcad_instance_lock_foreign_identity' - | 'orcad_instance_lock_unreadable' export class OrcadInstanceLockError extends Error { constructor( @@ -50,23 +46,16 @@ export class OrcadInstanceLockError extends Error { } } -const OrcadLockRecordSchema = z.object({ - pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), +export type OrcadLockRecord = { + pid: number /** Null where the platform cannot read it; PID alone is then the (weaker) fence. */ - startedAtMs: z.number().finite().nonnegative().nullable(), + startedAtMs: number | null /** POSIX uid, or the Windows username. Compared as an opaque string. */ - identity: z.string().min(1).max(1_024), - version: z.string().min(1).max(255), - acquiredAt: z.iso.datetime({ offset: true }), + identity: string + version: string + acquiredAt: string /** Distinguishes our record from a replacement written after we lost the race. */ - nonce: z.string().min(1).max(255) -}) - -export type OrcadLockRecord = z.infer - -/** `null` when absent, unreadable, oversized or malformed; callers must not read that as free. */ -export function readOrcadInstanceLockRecord(path: string): OrcadLockRecord | null { - return parseOrcadInstanceLockRecord(readBoundedLockFile(path) ?? '') + nonce: string } export type OrcadInstanceLock = { @@ -106,11 +95,24 @@ function isErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } -/** `null` for anything that is not a complete record; never a reason to treat a lock as free. */ -export function parseOrcadInstanceLockRecord(content: string): OrcadLockRecord | null { +function parseLockRecord(content: string): OrcadLockRecord | null { try { - const result = OrcadLockRecordSchema.safeParse(JSON.parse(content)) - return result.success ? result.data : null + 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 } @@ -230,17 +232,8 @@ export function acquireOrcadInstanceLock( return makeLock(lockPath, record) } - const existing = parseOrcadInstanceLockRecord(readBoundedLockFile(lockPath) ?? '') - if (!existing) { - // Why fail closed: an unreadable record proves nothing about its holder having exited. - throw new OrcadInstanceLockError( - 'orcad_instance_lock_unreadable', - `The orcad instance lock at ${lockPath} is unreadable, malformed, or larger than ` + - `${MAX_ORCAD_LOCK_BYTES} bytes. Refusing to reclaim it without proof that its holder ` + - 'has exited. Stop orcad and remove the stale lock manually.' - ) - } - if (existing.identity !== identity) { + 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 ` + @@ -248,7 +241,7 @@ export function acquireOrcadInstanceLock( 'root corrupts it. Give each its own ORCA_USER_DATA.' ) } - if (isAlive(existing.pid) && matchesStartTime(existing.pid, existing.startedAtMs)) { + 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 ` + @@ -256,6 +249,13 @@ export function acquireOrcadInstanceLock( '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. @@ -269,15 +269,6 @@ export function acquireOrcadInstanceLock( 'holding it. Retry, or stop the other orcad.' ) } - // A contender may have replaced the entry after the liveness check; never displace its record. - const claimedContents = readBoundedLockFile(claimPath) - if (parseOrcadInstanceLockRecord(claimedContents ?? '')?.nonce !== existing.nonce) { - restoreDisplacedLock(claimPath, lockPath, claimedContents) - throw new OrcadInstanceLockError( - 'orcad_instance_lock_held', - `The orcad instance lock at ${lockPath} changed while reclaiming a stale record.` - ) - } if (!publish()) { // Someone else claimed it first. Their record is authoritative; ours is not. try { @@ -298,32 +289,9 @@ export function acquireOrcadInstanceLock( return makeLock(lockPath, record) } -/** No-clobber restore: a third contender's newer record at the canonical path stays authoritative. */ -function restoreDisplacedLock( - claimPath: string, - lockPath: string, - claimedContents: string | null -): void { +function safeRead(path: string): string | null { try { - linkSync(claimPath, lockPath) - unlinkSync(claimPath) - } catch { - if (claimedContents === null) { - return - } - try { - writeFileSync(lockPath, claimedContents, { flag: 'wx', mode: 0o600 }) - unlinkSync(claimPath) - } catch { - // A newer contender won, or restoration is unavailable; fail closed. - } - } -} - -function readBoundedLockFile(path: string): string | null { - try { - const { buffer, stats } = readNodeFileSyncWithinLimit(path, MAX_ORCAD_LOCK_BYTES) - return stats.isFile() ? buffer.toString('utf8') : null + return readFileSync(path, 'utf8') } catch { return null } @@ -342,7 +310,7 @@ function makeLock(lockPath: string, record: OrcadLockRecord): OrcadInstanceLock // 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 = parseOrcadInstanceLockRecord(readBoundedLockFile(lockPath) ?? '') + const current = parseLockRecord(safeRead(lockPath) ?? '') if (!current || current.nonce !== record.nonce) { return } diff --git a/src/main/orcad/orcad-lifecycle-host.test.ts b/src/main/orcad/orcad-lifecycle-host.test.ts deleted file mode 100644 index 58004faafa5..00000000000 --- a/src/main/orcad/orcad-lifecycle-host.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./orcad-browser-startup', () => ({ - startOrcadBrowserProvider: () => ({ ready: Promise.resolve(), stop: async () => {} }) -})) - -import { ORCAD_LOCK_FILE_NAME, readOrcadInstanceLockRecord } from './orcad-instance-lock' -import { startOrcadWithHost } from './orcad-lifecycle' - -const roots: string[] = [] -afterEach(() => { - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function dataRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'orcad-lifecycle-host-')) - roots.push(root) - return root -} - -describe('startOrcadWithHost', () => { - it('names the instance a managed stop must address, and releases its lock on a clean stop', async () => { - const root = dataRoot() - const handle = await startOrcadWithHost( - root, - async () => ({}), - () => {} - ) - expect(readOrcadInstanceLockRecord(handle.instance.lockPath)).toMatchObject({ - pid: handle.instance.pid, - nonce: handle.instance.nonce - }) - await handle.stop() - expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(false) - }) - - it('keeps the instance lock when a runtime writer could not be stopped', async () => { - const root = dataRoot() - const failure = new Error('profile writer still running') - const handle = await startOrcadWithHost( - root, - async (registerCleanup) => { - registerCleanup(() => { - throw failure - }) - return {} - }, - () => {} - ) - await expect(handle.stop()).rejects.toBe(failure) - expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(true) - }) - - it('keeps the instance lock when a quit handler fails', async () => { - const root = dataRoot() - const handle = await startOrcadWithHost( - root, - async () => ({}), - () => { - throw new AggregateError([new Error('handler')], 'orcad_quit_handlers_failed') - } - ) - await expect(handle.stop()).rejects.toThrow('orcad_quit_handlers_failed') - expect(existsSync(join(root, ORCAD_LOCK_FILE_NAME))).toBe(true) - }) -}) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index ffcec0224a3..bfff3b7f14e 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -1,7 +1,5 @@ import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { startOrcadBrowserProvider } from './orcad-browser-startup' -import { OrcadRuntimeLifetime, type OrcadRuntimeCleanup } from './orcad-runtime-lifetime' -import type { OrcadManagedStopInstance } from '../../shared/orcad-stop-request' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { acquireOrcadInstanceLock } from './orcad-instance-lock' import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime' import { resolveOrcadExitCode } from './orcad-exit-code' @@ -23,14 +21,11 @@ function createIdempotentOrcadCleanup(cleanup: () => Promise): () => Promi export const ORCAD_SHUTDOWN_DEADLINE_MS = 15_000 -/** - * A launcher and its child can both receive the same process-group or service stop signal. - * Returns the trigger stop-request listeners share, so every source runs one bounded stop. - */ +/** A launcher and its child can both receive the same process-group or service stop signal. */ export function installOrcadShutdownSignals( stop: () => Promise, deadlineMs = ORCAD_SHUTDOWN_DEADLINE_MS -): (reason: string) => void { +): void { let stopping = false const shutdown = (signal: string): void => { if (stopping) { @@ -60,26 +55,26 @@ export function installOrcadShutdownSignals( shutdown('launcher disconnect') } } - return shutdown } export async function startOrcadWithLifecycle( - start: (registerRuntimeCleanup: (cleanup: OrcadRuntimeCleanup) => void) => Promise, + start: (registerRuntimeCleanup: (cleanup: () => Promise) => void) => Promise, cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise ): Promise }> { - // Runtime resources stop in reverse registration order before any host resource. - const runtime = new OrcadRuntimeLifetime() + let cleanupRuntime = async (): Promise => {} const cleanup = createIdempotentOrcadCleanup(async () => { let runtimeCleanupSucceeded = false try { - await runtime.stop() + await cleanupRuntime() runtimeCleanupSucceeded = true } finally { await cleanupHost(runtimeCleanupSucceeded) } }) try { - const handle = await start((nextCleanup) => runtime.add(nextCleanup)) + const handle = await start((nextCleanup) => { + cleanupRuntime = nextCleanup + }) return { ...handle, stop: cleanup } } catch (error) { try { @@ -92,40 +87,41 @@ export async function startOrcadWithLifecycle( } } -/** Keep profile admission and the instance lock until every runtime writer has stopped. */ +/** Keep profile admission until every runtime writer has stopped. */ export async function startOrcadWithHost( userDataPath: string, - start: (registerCleanup: (cleanup: OrcadRuntimeCleanup) => void) => Promise, + start: (registerCleanup: (cleanup: () => Promise) => void) => Promise, runQuitHandlers: () => void -): Promise; instance: OrcadManagedStopInstance }> { +): Promise }> { const instanceLock = acquireOrcadInstanceLock(userDataPath) - const { pid, startedAtMs, nonce } = instanceLock.record - const instance = { pid, startedAtMs, nonce, lockPath: instanceLock.path } let admission: ProfileStateRuntimeAdmission | undefined - let browserProvider: ReturnType | undefined + let browserProvider: Awaited> | undefined return startOrcadWithLifecycle( async (registerCleanup) => { admission = acquireProfileStateRuntimeAdmission(userDataPath) - // Why not awaited: a desktop sidecar's authorization UI must not hold RPC readiness hostage. - browserProvider = startOrcadBrowserProvider({ userDataPath }) - return { ...(await start(registerCleanup)), instance } + browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) + const provider = browserProvider + setRuntimeBrowserCommandsFactory(provider?.factory ?? null, { + headless: provider !== null, + ...(provider ? { isAvailable: () => provider.isAvailable() } : {}) + }) + return start(registerCleanup) }, async (runtimeCleanupSucceeded) => { - // Failed teardown keeps both fences until the process actually exits. - const host = new OrcadRuntimeLifetime(() => { - if (runtimeCleanupSucceeded) { + try { + await browserProvider?.stop() + } finally { + setRuntimeBrowserCommandsFactory(null) + runQuitHandlers() + try { + // Failed teardown excludes recovery until the process actually exits. + if (runtimeCleanupSucceeded) { + admission?.release() + } + } finally { instanceLock.release() } - }) - host.add(({ failed }) => { - if (runtimeCleanupSucceeded && !failed) { - admission?.release() - } - }) - host.add(() => runQuitHandlers()) - host.add(() => setRuntimeBrowserCommandsFactory(null)) - host.add(() => browserProvider?.stop()) - await host.stop() + } } ) } diff --git a/src/main/orcad/orcad-managed-stop-admission.ts b/src/main/orcad/orcad-managed-stop-admission.ts deleted file mode 100644 index e354266853e..00000000000 --- a/src/main/orcad/orcad-managed-stop-admission.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' -import { persistOrcadDaemonRetirementRecord } from './orcad-completed-stop-receipt' -import type { OrcadDaemonRetirement } from './orcad-daemon-retirement' - -// Lazy like the rest of orcad's daemon graph: the entry module must not load it at import time. -async function retireLazily(): Promise { - const { retireOrcadDaemonIfIdle } = await import('./orcad-daemon-retirement') - return retireOrcadDaemonIfIdle() -} - -/** Runs before orcad stops for a managed request; it can record an outcome but never veto. */ -export async function prepareOrcadManagedStop( - request: OrcadManagedStopRequest, - retire: () => Promise = retireLazily -): Promise { - if (!request.retireIdleDaemon) { - return - } - const outcome = await retire().catch((error: unknown): OrcadDaemonRetirement => ({ - retirement: 'unverifiable', - liveSessions: null, - reason: `Retirement failed: ${error instanceof Error ? error.message : String(error)}` - })) - try { - persistOrcadDaemonRetirementRecord(request, outcome) - } catch (error) { - // The completion command reads a missing record as `unverifiable`. - console.error('[orcad] could not record daemon retirement:', error) - } -} diff --git a/src/main/orcad/orcad-managed-stop-cancellation.test.ts b/src/main/orcad/orcad-managed-stop-cancellation.test.ts deleted file mode 100644 index 3a19b1eed69..00000000000 --- a/src/main/orcad/orcad-managed-stop-cancellation.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - OrcadManagedStopCancellationSchema, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { acquireOrcadInstanceLock } from './orcad-instance-lock' -import { orcadManagedStopRequestPath } from './orcad-managed-stop-request' -import { cancelOrcadManagedStop } from './orcad-managed-stop-cancellation' -import { - claimOrcadManagedStopDecision, - readOrcadManagedStopDecision -} from './orcad-managed-stop-decision' -import { completeOrcadManagedStop } from './orcad-managed-stop-completion' -import { runOrcadManagedStopCancelCommand } from './orcad-managed-stop-command' -import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' - -const roots: string[] = [] -afterEach(() => { - vi.restoreAllMocks() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function running(): OrcadManagedStopRequest { - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-cancel-')) - roots.push(root) - const lock = acquireOrcadInstanceLock(root, { identity: () => 'uid-1000', startedAtMs: () => 5 }) - const { pid, startedAtMs, nonce } = lock.record - return { - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - version: '1.0.0', - runtimeId: 'runtime-1', - instance: { pid, startedAtMs, nonce, lockPath: lock.path } - } -} - -describe('cancelling a managed stop', () => { - it('lets exactly one side decide a transaction', () => { - const request = running() - expect(claimOrcadManagedStopDecision(request, 'canceled')).toBe('canceled') - expect(claimOrcadManagedStopDecision(request, 'dispatched')).toBe('canceled') - expect(readOrcadManagedStopDecision(request)).toBe('canceled') - }) - - it('withdraws a request orcad has not acted on, and removes its file', () => { - const request = running() - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(request)) - expect(cancelOrcadManagedStop(request)).toBe('canceled') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(false) - }) - - it('reports dispatched once orcad acted first, and leaves its request in place', () => { - const request = running() - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(request)) - expect(claimOrcadManagedStopDecision(request, 'dispatched')).toBe('dispatched') - expect(cancelOrcadManagedStop(request)).toBe('dispatched') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(true) - }) - - it('never removes another transaction pending for the same instance', () => { - const request = running() - const other = { ...request, transactionId: '5a7e1f0c-3b2d-4e6f-9a8b-7c6d5e4f3a2b' } - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(other)) - expect(cancelOrcadManagedStop(request)).toBe('canceled') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(true) - }) - - it('does not reissue a cancelled transaction while orcad keeps running', async () => { - const request = running() - cancelOrcadManagedStop(request) - expect( - await completeOrcadManagedStop(request, { - probeProcess: () => 'alive', - startedAtMs: () => 5, - sleep: async () => {} - }) - ).toBe('live') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(false) - }) - - it('keeps orcad running when its listener meets a cancelled request', async () => { - const request = running() - cancelOrcadManagedStop(request) - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(request)) - const onRequest = vi.fn() - const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-cancel-slot-')) - roots.push(root) - const listener = installOrcadStopRequestListeners(onRequest, { - installRoot: root, - managedStop: { - version: request.version, - runtimeId: request.runtimeId, - instance: request.instance - }, - pollIntervalMs: 10 - }) - await vi.waitFor(() => expect(report).toHaveBeenCalled()) - listener.close() - expect(onRequest).not.toHaveBeenCalled() - expect(String(report.mock.calls[0]?.[1])).toContain('orcad_managed_stop_canceled') - }) - - it('prints one cancellation line through the command', () => { - const request = running() - const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - const cancellation = runOrcadManagedStopCancelCommand([ - ORCAD_CANCEL_MANAGED_STOP_FLAG, - JSON.stringify(request) - ]) - expect(cancellation.outcome).toBe('canceled') - expect( - OrcadManagedStopCancellationSchema.parse(JSON.parse(String(write.mock.calls[0]?.[0]))) - ).toEqual(cancellation) - }) -}) diff --git a/src/main/orcad/orcad-managed-stop-cancellation.ts b/src/main/orcad/orcad-managed-stop-cancellation.ts deleted file mode 100644 index e4e0b468737..00000000000 --- a/src/main/orcad/orcad-managed-stop-cancellation.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** Withdrawing a managed stop request that the running orcad has not acted on yet. */ -import { rmSync } from 'node:fs' -import type { - OrcadManagedStopCancellation, - OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision' -import { - orcadManagedStopRequestPath, - readOrcadManagedStopRequest -} from './orcad-managed-stop-request' - -/** `dispatched` means orcad already began stopping; only its completion can say how it ended. */ -export function cancelOrcadManagedStop( - request: OrcadManagedStopRequest -): OrcadManagedStopCancellation['outcome'] { - const outcome = claimOrcadManagedStopDecision(request, 'canceled') - if (outcome === 'canceled') { - removePendingRequest(request) - } - return outcome -} - -/** Removes the request file only while it still carries this transaction. */ -function removePendingRequest(request: OrcadManagedStopRequest): void { - const path = orcadManagedStopRequestPath(request.instance) - try { - if (readOrcadManagedStopRequest(path).transactionId === request.transactionId) { - rmSync(path, { force: true }) - } - } catch { - // Absent or another transaction's request: the standing decision already fences this one. - } -} diff --git a/src/main/orcad/orcad-managed-stop-command.ts b/src/main/orcad/orcad-managed-stop-command.ts deleted file mode 100644 index b62fa2b2311..00000000000 --- a/src/main/orcad/orcad-managed-stop-command.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * `orcad --complete-managed-stop `: one stop, one JSON verdict line on stdout. - * `orcad --cancel-managed-stop `: one cancellation, one JSON outcome line. - * - * Exit 0 means a result was printed — read it, the exit code does not carry it. 64 is a - * malformed invocation; 1 is a failure before any result, which is never evidence of exit. - */ -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG, - OrcadManagedStopRequestSchema, - type OrcadManagedStopCancellation, - type OrcadManagedStopCompletion, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { cancelOrcadManagedStop } from './orcad-managed-stop-cancellation' -import { readOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt' -import { ZodError } from 'zod' -import { - completeOrcadManagedStop, - type OrcadManagedStopCompletionOptions -} from './orcad-managed-stop-completion' - -export async function runOrcadManagedStopCommand( - argv: readonly string[], - options: OrcadManagedStopCompletionOptions = {} -): Promise { - const request = parseRequestArgument(argv, ORCAD_COMPLETE_MANAGED_STOP_FLAG) - const verdict = await completeOrcadManagedStop(request, options) - const retirement = - verdict === 'exited' && request.retireIdleDaemon - ? readOrcadCompletedStopReceipt(request)?.retirement - : undefined - const completion: OrcadManagedStopCompletion = { - ...request, - kind: 'orcad_managed_stop_completion', - verdict, - receiptPersisted: verdict === 'exited', - ...(retirement ? { retirement } : {}) - } - process.stdout.write(`${JSON.stringify(completion)}\n`) - return completion -} - -export function runOrcadManagedStopCancelCommand( - argv: readonly string[] -): OrcadManagedStopCancellation { - const request = parseRequestArgument(argv, ORCAD_CANCEL_MANAGED_STOP_FLAG) - const cancellation: OrcadManagedStopCancellation = { - ...request, - kind: 'orcad_managed_stop_cancellation', - outcome: cancelOrcadManagedStop(request) - } - process.stdout.write(`${JSON.stringify(cancellation)}\n`) - return cancellation -} - -function parseRequestArgument(argv: readonly string[], flag: string): OrcadManagedStopRequest { - if (argv.length !== 2 || argv[0] !== flag || !argv[1]) { - throw new Error('orcad_managed_stop_invalid_arguments') - } - return OrcadManagedStopRequestSchema.parse(JSON.parse(argv[1])) -} - -export const ORCAD_MANAGED_STOP_EXIT_VERDICT = 0 -export const ORCAD_MANAGED_STOP_EXIT_FAILED = 1 -export const ORCAD_MANAGED_STOP_EXIT_USAGE = 64 - -export async function runOrcadManagedStopCommandAndExit(argv: readonly string[]): Promise { - let code = ORCAD_MANAGED_STOP_EXIT_VERDICT - try { - if (argv[0] === ORCAD_CANCEL_MANAGED_STOP_FLAG) { - runOrcadManagedStopCancelCommand(argv) - } else { - await runOrcadManagedStopCommand(argv) - } - } catch (error) { - console.error('orcad: managed stop failed:', error) - code = - error instanceof ZodError || - error instanceof SyntaxError || - (error instanceof Error && error.message === 'orcad_managed_stop_invalid_arguments') - ? ORCAD_MANAGED_STOP_EXIT_USAGE - : ORCAD_MANAGED_STOP_EXIT_FAILED - } - // Why exit after the write drains: the caller reads stdout to EOF. - process.stdout.write('', () => process.exit(code)) -} diff --git a/src/main/orcad/orcad-managed-stop-completion.ts b/src/main/orcad/orcad-managed-stop-completion.ts deleted file mode 100644 index 0327e4772dc..00000000000 --- a/src/main/orcad/orcad-managed-stop-completion.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Asking one orcad instance to stop and proving that it did. - * - * Runs as a short-lived command on the execution host. It never signals: it writes the - * instance-bound request the running orcad watches for, then observes until the instance is - * gone. `exited` needs proof — no process with that PID, or a PID whose start time shows it now - * belongs to another process. Anything the host cannot answer is `unverifiable`, never exit. - */ -import { setTimeout as delay } from 'node:timers/promises' -import { writeDurableSecureJsonFile } from '../../shared/secure-file' -import { - OrcadManagedStopRequestSchema, - type OrcadManagedStopInstance, - type OrcadManagedStopRequest, - type OrcadManagedStopVerdict -} from '../../shared/orcad-stop-request' -import { - getProcessStartedAtMs, - START_TIME_TOLERANCE_MS, - startTimesWithinTolerance -} from '../daemon/daemon-process-start-time' -import { persistOrcadCompletedStopReceipt } from './orcad-completed-stop-receipt' -import { readOrcadManagedStopDecision } from './orcad-managed-stop-decision' -import { - orcadInstanceLockNames, - orcadManagedStopRequestPath, - readOrcadManagedStopRequest -} from './orcad-managed-stop-request' - -export type OrcadProcessProbe = (pid: number) => 'alive' | 'missing' | 'unverifiable' - -export type OrcadManagedStopCompletionOptions = { - probeProcess?: OrcadProcessProbe - startedAtMs?: (pid: number) => number | null - sleep?: () => Promise - attempts?: number - now?: () => Date -} - -function defaultProbe(pid: number): ReturnType { - try { - process.kill(pid, 0) - return 'alive' - } catch (error) { - const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null - // EPERM proves some process holds the PID; it cannot prove ours exited. - return code === 'ESRCH' ? 'missing' : 'unverifiable' - } -} - -function observeInstance( - instance: OrcadManagedStopInstance, - options: OrcadManagedStopCompletionOptions -): OrcadManagedStopVerdict { - const probe = (options.probeProcess ?? defaultProbe)(instance.pid) - if (probe !== 'alive') { - return probe === 'missing' ? 'exited' : 'unverifiable' - } - const actual = (options.startedAtMs ?? getProcessStartedAtMs)(instance.pid) - // A reused PID is proof of exit only when both start times are known and disagree. - if ( - actual !== null && - instance.startedAtMs !== null && - !startTimesWithinTolerance(actual, instance.startedAtMs, START_TIME_TOLERANCE_MS) - ) { - return 'exited' - } - return 'live' -} - -/** Writes the request only while the lock still names this instance, then waits for exit. */ -export async function completeOrcadManagedStop( - input: OrcadManagedStopRequest, - options: OrcadManagedStopCompletionOptions = {} -): Promise { - const request = OrcadManagedStopRequestSchema.parse(input) - const attempts = options.attempts ?? 80 - if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 240) { - throw new Error('orcad_managed_stop_invalid_attempts') - } - const completed = (): 'exited' => { - persistOrcadCompletedStopReceipt(request, (options.now ?? (() => new Date()))()) - return 'exited' - } - let verdict = observeInstance(request.instance, options) - if (verdict !== 'live') { - return verdict === 'exited' ? completed() : verdict - } - if (!lockStillNamesInstance(request.instance)) { - // The process lives but no longer owns the lock it published: it is not ours to address. - return 'unverifiable' - } - if (readOrcadManagedStopDecision(request) === 'canceled') { - // A cancelled transaction is never reissued; its orcad keeps running. - return 'live' - } - const requestPath = orcadManagedStopRequestPath(request.instance) - if (!existingRequestMatches(requestPath, request)) { - return 'unverifiable' - } - if (!writeDurableSecureJsonFile(requestPath, request)) { - throw new Error('orcad_managed_stop_request_permissions_unconfirmed') - } - for (let attempt = 0; attempt < attempts; attempt++) { - await (options.sleep ?? (() => delay(250)))() - verdict = observeInstance(request.instance, options) - if (verdict !== 'live') { - return verdict === 'exited' ? completed() : verdict - } - } - return 'live' -} - -function lockStillNamesInstance(instance: OrcadManagedStopInstance): boolean { - try { - return orcadInstanceLockNames(instance) - } catch { - return false - } -} - -/** A request already present must be this one; another transaction's request is not ours. */ -function existingRequestMatches(path: string, request: OrcadManagedStopRequest): boolean { - try { - return JSON.stringify(readOrcadManagedStopRequest(path)) === JSON.stringify(request) - } catch (error) { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' - } -} diff --git a/src/main/orcad/orcad-managed-stop-decision.ts b/src/main/orcad/orcad-managed-stop-decision.ts deleted file mode 100644 index e6b9d3d029e..00000000000 --- a/src/main/orcad/orcad-managed-stop-decision.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Arbitrating a managed request between the orcad acting on it and a client cancelling it. - * - * Both sides race to create one decision file per transaction with a hard link, which fails if - * the file exists. Exactly one wins, and the loser reads the winner's decision, so a cancel can - * never report success for a stop that orcad already began. - */ -import { linkSync, rmSync } from 'node:fs' -import { randomUUID } from 'node:crypto' -import { writeDurableSecureJsonFile } from '../../shared/secure-file' -import { - OrcadManagedStopDecisionSchema, - OrcadManagedStopRequestSchema, - type OrcadManagedStopDecision, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { orcadStopReceiptPath, readOrcadStopReceipt } from './orcad-completed-stop-receipt' - -export type OrcadManagedStopDecisionValue = OrcadManagedStopDecision['decision'] - -function isAlreadyDecided(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST' -} - -export function readOrcadManagedStopDecision( - request: OrcadManagedStopRequest -): OrcadManagedStopDecisionValue | null { - return readOrcadStopReceipt(request, 'decision', OrcadManagedStopDecisionSchema)?.decision ?? null -} - -/** Returns the decision that stands: ours if we created it first, otherwise the other side's. */ -export function claimOrcadManagedStopDecision( - input: OrcadManagedStopRequest, - decision: OrcadManagedStopDecisionValue -): OrcadManagedStopDecisionValue { - const request = OrcadManagedStopRequestSchema.parse(input) - const path = orcadStopReceiptPath(request, 'decision') - const staged = `${path}.${process.pid}.${randomUUID()}.staged` - const record: OrcadManagedStopDecision = { - schemaVersion: 1, - kind: 'orcad_managed_stop_decision', - request, - decision - } - if (!writeDurableSecureJsonFile(staged, record)) { - rmSync(staged, { force: true }) - throw new Error('orcad_managed_stop_decision_permissions_unconfirmed') - } - try { - linkSync(staged, path) - return decision - } catch (error) { - if (!isAlreadyDecided(error)) { - throw error - } - const standing = readOrcadManagedStopDecision(request) - if (!standing) { - throw new Error('orcad_managed_stop_decision_unverifiable') - } - return standing - } finally { - rmSync(staged, { force: true }) - } -} diff --git a/src/main/orcad/orcad-managed-stop-request.ts b/src/main/orcad/orcad-managed-stop-request.ts deleted file mode 100644 index ffd7560cae0..00000000000 --- a/src/main/orcad/orcad-managed-stop-request.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** Validating an instance-bound stop request before the running orcad acts on it. */ -import { createHash } from 'node:crypto' -import { lstatSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { - ORCAD_MANAGED_STOP_REQUEST_MAX_BYTES, - ORCAD_MANAGED_STOP_REQUEST_PREFIX, - OrcadManagedStopRequestSchema, - type OrcadManagedStopContext, - type OrcadManagedStopInstance, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { readOrcadInstanceLockRecord } from './orcad-instance-lock' - -/** Keyed by the lock nonce, so a request for a previous instance is never this one's. */ -export function orcadManagedStopRequestPath(instance: OrcadManagedStopInstance): string { - const digest = createHash('sha256').update(instance.nonce).digest('hex') - return join(dirname(instance.lockPath), `${ORCAD_MANAGED_STOP_REQUEST_PREFIX}.${digest}`) -} - -export function readOrcadManagedStopRequest(path: string): OrcadManagedStopRequest { - if (!lstatSync(path).isFile()) { - throw new Error('orcad_managed_stop_request_not_regular_file') - } - const { buffer } = readNodeFileSyncWithinLimit(path, ORCAD_MANAGED_STOP_REQUEST_MAX_BYTES) - return OrcadManagedStopRequestSchema.parse(JSON.parse(buffer.toString('utf8'))) -} - -export function sameOrcadManagedStopInstance( - left: OrcadManagedStopInstance, - right: Pick -): boolean { - return ( - left.pid === right.pid && left.startedAtMs === right.startedAtMs && left.nonce === right.nonce - ) -} - -/** Whether the instance lock still names exactly this instance. */ -export function orcadInstanceLockNames(instance: OrcadManagedStopInstance): boolean { - if (!lstatSync(instance.lockPath).isFile()) { - return false - } - const record = readOrcadInstanceLockRecord(instance.lockPath) - return record !== null && sameOrcadManagedStopInstance(instance, record) -} - -/** Throws unless the request names this running instance and its lock is still ours. */ -export function validateOrcadManagedStopRequest( - context: OrcadManagedStopContext, - requestPath: string -): OrcadManagedStopRequest { - const request = readOrcadManagedStopRequest(requestPath) - if ( - request.version !== context.version || - request.runtimeId !== context.runtimeId || - request.instance.lockPath !== context.instance.lockPath || - !sameOrcadManagedStopInstance(request.instance, context.instance) - ) { - throw new Error('orcad_managed_stop_request_identity_mismatch') - } - if (!orcadInstanceLockNames(context.instance)) { - throw new Error('orcad_managed_stop_instance_lock_changed') - } - return request -} diff --git a/src/main/orcad/orcad-managed-stop.test.ts b/src/main/orcad/orcad-managed-stop.test.ts deleted file mode 100644 index c6f19bcf0e8..00000000000 --- a/src/main/orcad/orcad-managed-stop.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - OrcadManagedStopCompletionSchema, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { acquireOrcadInstanceLock, type OrcadInstanceLock } from './orcad-instance-lock' -import { - orcadManagedStopRequestPath, - validateOrcadManagedStopRequest -} from './orcad-managed-stop-request' -import { - completeOrcadManagedStop, - type OrcadManagedStopCompletionOptions -} from './orcad-managed-stop-completion' -import { - orcadStopReceiptPath, - readOrcadCompletedStopReceipt, - readOrcadDaemonRetirementRecord -} from './orcad-completed-stop-receipt' -import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' -import { runOrcadManagedStopCommand } from './orcad-managed-stop-command' - -const roots: string[] = [] -afterEach(() => { - vi.restoreAllMocks() - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function running(): { lock: OrcadInstanceLock; request: OrcadManagedStopRequest } { - const root = mkdtempSync(join(tmpdir(), 'orcad-managed-stop-')) - roots.push(root) - const lock = acquireOrcadInstanceLock(root, { - identity: () => 'uid-1000', - version: () => '1.0.0', - startedAtMs: () => 5_000 - }) - const { pid, startedAtMs, nonce } = lock.record - return { - lock, - request: { - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - version: '1.0.0', - runtimeId: 'runtime-1', - instance: { pid, startedAtMs, nonce, lockPath: lock.path } - } - } -} - -const context = ({ version, runtimeId, instance }: OrcadManagedStopRequest) => ({ - version, - runtimeId, - instance -}) - -function options( - overrides: Partial = {} -): OrcadManagedStopCompletionOptions { - return { - probeProcess: () => 'alive', - startedAtMs: () => 5_000, - sleep: async () => {}, - attempts: 3, - now: () => new Date('2026-10-01T00:00:00.000Z'), - ...overrides - } -} - -describe('managed stop requests', () => { - it('keys the request file to the instance, so a previous instance never matches', () => { - const { request } = running() - const other = { ...request.instance, nonce: 'other' } - expect(orcadManagedStopRequestPath(request.instance)).not.toBe( - orcadManagedStopRequestPath(other) - ) - }) - - it('accepts only a request naming this runtime, version and instance', () => { - const { request } = running() - const path = orcadManagedStopRequestPath(request.instance) - writeFileSync(path, JSON.stringify(request)) - expect(validateOrcadManagedStopRequest(context(request), path)).toEqual(request) - for (const mismatch of [ - { ...request, version: '0.9.0' }, - { ...request, runtimeId: 'runtime-2' }, - { ...request, instance: { ...request.instance, pid: request.instance.pid + 1 } } - ]) { - writeFileSync(path, JSON.stringify(mismatch)) - expect(() => validateOrcadManagedStopRequest(context(request), path)).toThrow( - 'identity_mismatch' - ) - } - }) - - it('refuses a request once the instance lock names another holder', () => { - const { lock, request } = running() - const path = orcadManagedStopRequestPath(request.instance) - writeFileSync(path, JSON.stringify(request)) - writeFileSync(lock.path, JSON.stringify({ ...lock.record, nonce: 'successor' })) - expect(() => validateOrcadManagedStopRequest(context(request), path)).toThrow( - 'instance_lock_changed' - ) - }) -}) - -describe('completing a managed stop', () => { - it('writes the request, waits for proven exit, then persists a receipt', async () => { - const { request } = running() - const probes = ['alive', 'alive', 'missing'] as const - let index = 0 - const verdict = await completeOrcadManagedStop( - request, - options({ probeProcess: () => probes[Math.min(index++, probes.length - 1)] }) - ) - expect(verdict).toBe('exited') - expect(JSON.parse(readFileSync(orcadManagedStopRequestPath(request.instance), 'utf8'))).toEqual( - request - ) - expect(readOrcadCompletedStopReceipt(request)).toMatchObject({ - kind: 'orcad_managed_stop_completed', - exitedAt: '2026-10-01T00:00:00.000Z' - }) - }) - - it('treats a reused PID with a different start time as proof of exit', async () => { - const { request } = running() - expect(await completeOrcadManagedStop(request, options({ startedAtMs: () => 99_000 }))).toBe( - 'exited' - ) - }) - - it.each([ - ['an unanswerable probe', { probeProcess: () => 'unverifiable' as const }], - ['a reused PID with no readable start time', { startedAtMs: () => null, attempts: 1 }] - ])('never reports exit for %s', async (_name, overrides) => { - const { request } = running() - const verdict = await completeOrcadManagedStop(request, options(overrides)) - expect(verdict).not.toBe('exited') - expect(existsSync(orcadStopReceiptPath(request, 'completed'))).toBe(false) - }) - - it('reports a still-running instance as live after its attempts', async () => { - const { request } = running() - expect(await completeOrcadManagedStop(request, options())).toBe('live') - }) - - it('does not address a live process that no longer holds the lock it published', async () => { - const { lock, request } = running() - writeFileSync(lock.path, JSON.stringify({ ...lock.record, nonce: 'successor' })) - expect(await completeOrcadManagedStop(request, options())).toBe('unverifiable') - expect(existsSync(orcadManagedStopRequestPath(request.instance))).toBe(false) - }) - - it('does not overwrite another transaction pending for the same instance', async () => { - const { request } = running() - const pending = { ...request, transactionId: '5a7e1f0c-3b2d-4e6f-9a8b-7c6d5e4f3a2b' } - writeFileSync(orcadManagedStopRequestPath(request.instance), JSON.stringify(pending)) - expect(await completeOrcadManagedStop(request, options())).toBe('unverifiable') - }) - - it('prints one completion line through the command', async () => { - const { request } = running() - const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - const completion = await runOrcadManagedStopCommand( - ['--complete-managed-stop', JSON.stringify(request)], - options({ probeProcess: () => 'missing' }) - ) - expect(completion).toMatchObject({ verdict: 'exited', receiptPersisted: true }) - expect( - OrcadManagedStopCompletionSchema.parse(JSON.parse(String(write.mock.calls[0]?.[0]))) - ).toEqual(completion) - await expect(runOrcadManagedStopCommand(['--complete-managed-stop'])).rejects.toThrow( - 'invalid_arguments' - ) - }) -}) - -describe('managed stops that retire the daemon', () => { - const outcome = (retirement: 'retired' | 'live' | 'unverifiable', liveSessions: number | null) => - vi.fn(async () => ({ retirement, liveSessions, reason: null })) - - it('does not touch the daemon when retirement was not asked for', async () => { - const { request } = running() - const retire = outcome('retired', 0) - await prepareOrcadManagedStop(request, retire) - expect(retire).not.toHaveBeenCalled() - expect(readOrcadDaemonRetirementRecord(request)).toBeNull() - }) - - it.each([ - ['retired', 0], - ['live', 2], - ['unverifiable', null] - ] as const)( - 'still completes the stop and records retirement %s on the receipt', - async (retirement, liveSessions) => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await prepareOrcadManagedStop(retireRequest, outcome(retirement, liveSessions)) - expect(readOrcadDaemonRetirementRecord(retireRequest)).toMatchObject({ - retirement, - liveSessions - }) - expect( - await completeOrcadManagedStop(retireRequest, options({ probeProcess: () => 'missing' })) - ).toBe('exited') - expect(readOrcadCompletedStopReceipt(retireRequest)).toMatchObject({ retirement }) - } - ) - - it('records unverifiable when the retirement attempt itself failed', async () => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await prepareOrcadManagedStop(retireRequest, async () => { - throw new Error('daemon socket closed') - }) - expect(readOrcadDaemonRetirementRecord(retireRequest)).toMatchObject({ - retirement: 'unverifiable' - }) - }) - - it('reports unverifiable when orcad exited without recording retirement', async () => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await completeOrcadManagedStop(retireRequest, options({ probeProcess: () => 'missing' })) - expect(readOrcadCompletedStopReceipt(retireRequest)).toMatchObject({ - retirement: 'unverifiable' - }) - }) - - it('omits retirement from the receipt of a plain managed stop', async () => { - const { request } = running() - await completeOrcadManagedStop(request, options({ probeProcess: () => 'missing' })) - expect(readOrcadCompletedStopReceipt(request)).not.toHaveProperty('retirement') - }) - - it('reports the recorded retirement in the completion line, so a client need not read files', async () => { - const { request } = running() - const retireRequest = { ...request, retireIdleDaemon: true as const } - await prepareOrcadManagedStop(retireRequest, outcome('live', 1)) - vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - expect( - await runOrcadManagedStopCommand( - ['--complete-managed-stop', JSON.stringify(retireRequest)], - options({ probeProcess: () => 'missing' }) - ) - ).toMatchObject({ verdict: 'exited', retirement: 'live' }) - }) -}) diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 3360fdfd944..21ad507c016 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -14,7 +14,6 @@ const state = vi.hoisted(() => ({ controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, - profileStartupErrors: new Array(), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) @@ -25,13 +24,7 @@ vi.mock('./orcad-app-paths', () => ({ resolveUserDataPath: () => state.root })) vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) -vi.mock('./orcad-instance-lock', () => ({ - acquireOrcadInstanceLock: () => ({ - path: join(state.root, 'orcad.lock'), - record: { pid: process.pid, startedAtMs: null, nonce: 'headless-instance' }, - release() {} - }) -})) +vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ startOrcadDaemon: async () => {}, stopOrcadDaemon: async () => {} @@ -44,26 +37,20 @@ vi.mock('../ipc/pty', () => ({ getSshPtyProvider: () => null })) vi.mock('./orcad-profile-state-startup', () => ({ - createOrcadProfileStateStartup: async () => { - const error = state.profileStartupErrors.shift() - if (error) { - throw error + createOrcadProfileStateStartup: async () => ({ + store: { + getSettings: () => ({}), + flushFinalOrThrowAsync: async () => {}, + freezeWritesAsync: async () => {} + }, + authority: { + backend: 'sqlite', + classification: 'neither', + authority_mode: 'sqlite-candidate', + runtime: 'orcad', + migrated: false } - return { - store: { - getSettings: () => ({}), - flushFinalOrThrowAsync: async () => {}, - freezeWritesAsync: async () => {} - }, - authority: { - backend: 'sqlite', - classification: 'neither', - authority_mode: 'sqlite-candidate', - runtime: 'orcad', - migrated: false - } - } - } + }) })) vi.mock('../orca-profiles/profile-index-store', () => ({ initOrcaProfilePaths() {}, @@ -87,7 +74,6 @@ vi.mock('../runtime/orca-runtime', () => ({ rehydrateClientHostedBrowserPages() {} async refreshRestoredOrchestrationAuthority() {} async reconcileLegacyWorkerTerminals() {} - async stopLegacyWorkerTerminalRecovery() {} setMobilePushRegistrar( registrar: Parameters[0] ) { @@ -136,7 +122,6 @@ vi.mock('../runtime/push/push-gateway-client', () => ({ afterEach(() => { rmSync(state.root, { recursive: true, force: true }) - state.profileStartupErrors.length = 0 vi.clearAllMocks() }) @@ -194,28 +179,9 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', it('releases admission when host setup fails before a runtime exists', async () => { state.root = mkdtempSync(join(tmpdir(), 'orca-headless-setup-failure-')) - state.profileStartupErrors.push(new Error('profile startup failed')) + state.browserProvider.mockRejectedValueOnce(new Error('browser setup failed')) const { startOrcad } = await import('./orcad-entry') - await expect(startOrcad()).rejects.toThrow('profile startup failed') + await expect(startOrcad()).rejects.toThrow('browser setup failed') expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) - -it('serves RPC without waiting for browser discovery', async () => { - state.root = mkdtempSync(join(tmpdir(), 'orca-headless-browser-pending-')) - let finishDiscovery!: () => void - state.browserProvider.mockReturnValueOnce( - new Promise((resolve) => { - finishDiscovery = () => resolve(null) - }) - ) - const { startOrcad } = await import('./orcad-entry') - const host = await startOrcad({ noPairing: true, json: true }) - expect(host.managedStop).toMatchObject({ - runtimeId: 'headless-runtime', - instance: { pid: process.pid, nonce: 'headless-instance' } - }) - finishDiscovery() - await host.stop() - expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) -}) diff --git a/src/main/orcad/orcad-runtime-lifetime.test.ts b/src/main/orcad/orcad-runtime-lifetime.test.ts deleted file mode 100644 index 110e7d2876b..00000000000 --- a/src/main/orcad/orcad-runtime-lifetime.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { OrcadRuntimeLifetime } from './orcad-runtime-lifetime' - -it('stops in reverse acquisition order and releases the instance lock last', async () => { - const events: string[] = [] - const lifetime = new OrcadRuntimeLifetime(() => { - events.push('lock') - }) - lifetime.add(() => { - events.push('browser') - }) - lifetime.add(async () => { - events.push('daemon') - }) - lifetime.add(() => { - events.push('rpc') - }) - const stopping = lifetime.stop() - expect(lifetime.stop()).toBe(stopping) - expect(() => lifetime.add(() => {})).toThrow('lifetime_stopping') - await stopping - expect(events).toEqual(['rpc', 'daemon', 'browser', 'lock']) - await lifetime.stop() - expect(events).toHaveLength(4) -}) - -it('awaits pending cleanup before stopping dependencies or releasing the lock', async () => { - const release = vi.fn() - const lifetime = new OrcadRuntimeLifetime(release) - const dependency = vi.fn() - let finish!: () => void - lifetime.add(dependency) - lifetime.add( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const stopping = lifetime.stop() - await Promise.resolve() - expect(release).not.toHaveBeenCalled() - expect(dependency).not.toHaveBeenCalled() - finish() - await stopping - expect(dependency).toHaveBeenCalledOnce() - expect(release).toHaveBeenCalledOnce() -}) - -it('attempts every cleanup but retains the lock if any resource cannot stop', async () => { - const release = vi.fn() - const lifetime = new OrcadRuntimeLifetime(release) - const finalCleanup = vi.fn() - const first = new Error('rpc failed') - const second = new Error('browser failed') - lifetime.add(finalCleanup) - lifetime.add(() => { - throw second - }) - lifetime.add(async () => { - throw first - }) - const stopping = lifetime.stop() - await expect(stopping).rejects.toMatchObject({ errors: [first, second] }) - expect(finalCleanup).toHaveBeenCalledOnce() - expect(release).not.toHaveBeenCalled() - expect(lifetime.stop()).toBe(stopping) -}) - -it('tells later cleanups that an earlier one failed, and rethrows a single failure as-is', async () => { - const states: boolean[] = [] - const failure = new Error('profile writer still running') - const lifetime = new OrcadRuntimeLifetime() - lifetime.add(({ failed }) => { - states.push(failed) - }) - lifetime.add(() => { - throw failure - }) - await expect(lifetime.stop()).rejects.toBe(failure) - expect(states).toEqual([true]) -}) diff --git a/src/main/orcad/orcad-runtime-lifetime.ts b/src/main/orcad/orcad-runtime-lifetime.ts deleted file mode 100644 index e73de829323..00000000000 --- a/src/main/orcad/orcad-runtime-lifetime.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type OrcadRuntimeCleanup = (state: { failed: boolean }) => void | Promise - -/** - * Stops runtime resources in reverse acquisition order, then releases profile ownership. - * - * Every cleanup runs even after an earlier one fails, but a failed teardown never releases: - * a writer that may still be running cannot hand the data root to a second orcad. - */ -export class OrcadRuntimeLifetime { - private readonly cleanups: OrcadRuntimeCleanup[] = [] - private stopping?: Promise - - constructor(private readonly release: () => void = () => {}) {} - - add(cleanup: OrcadRuntimeCleanup): void { - if (this.stopping) { - throw new Error('orcad_runtime_lifetime_stopping') - } - this.cleanups.push(cleanup) - } - - stop(): Promise { - this.stopping ??= Promise.resolve().then(async () => { - const errors: unknown[] = [] - for (const cleanup of this.cleanups.splice(0).toReversed()) { - try { - await cleanup({ failed: errors.length > 0 }) - } catch (error) { - errors.push(error) - } - } - if (errors.length === 1) { - throw errors[0] - } - if (errors.length > 1) { - throw new AggregateError(errors, 'orcad_runtime_cleanup_failed') - } - this.release() - }) - return this.stopping - } -} diff --git a/src/main/orcad/orcad-stop-request-listener.test.ts b/src/main/orcad/orcad-stop-request-listener.test.ts deleted file mode 100644 index 2563543cb95..00000000000 --- a/src/main/orcad/orcad-stop-request-listener.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' -import { acquireOrcadInstanceLock } from './orcad-instance-lock' -import { orcadManagedStopRequestPath } from './orcad-managed-stop-request' -import { - installOrcadStopRequestListeners, - type OrcadStopRequestListener -} from './orcad-stop-request-listener' - -const roots: string[] = [] -const listeners: OrcadStopRequestListener[] = [] -afterEach(() => { - vi.restoreAllMocks() - for (const listener of listeners.splice(0)) { - listener.close() - } - for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true }) - } -}) - -function directory(): string { - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-listener-')) - roots.push(root) - return root -} - -function managedContext() { - const lock = acquireOrcadInstanceLock(directory(), { identity: () => 'uid-1000' }) - const { pid, startedAtMs, nonce } = lock.record - return { - version: '1.0.0', - runtimeId: 'runtime-1', - instance: { pid, startedAtMs, nonce, lockPath: lock.path } - } -} - -function listen( - onRequest: () => void, - options: Parameters[1] -) { - const listener = installOrcadStopRequestListeners(onRequest, { pollIntervalMs: 10, ...options }) - listeners.push(listener) - return listener -} - -describe('orcad stop-request listeners', () => { - it('consumes a slot request written before the listener started', () => { - const installRoot = directory() - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - const onRequest = vi.fn() - listen(onRequest, { installRoot }) - expect(onRequest).toHaveBeenCalledOnce() - expect(existsSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) - }) - - it('picks up a slot request written later, once', async () => { - const installRoot = directory() - const onRequest = vi.fn() - listen(onRequest, { installRoot }) - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).toHaveBeenCalledOnce() - }) - - it('stops after preparation even when preparation fails', async () => { - const managedStop = managedContext() - const onRequest = vi.fn() - const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { - installRoot: directory(), - managedStop, - beforeManagedStop: async () => { - throw new Error('daemon unreachable') - } - }) - writeFileSync( - orcadManagedStopRequestPath(managedStop.instance), - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - retireIdleDaemon: true, - ...managedStop - }) - ) - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - expect(report).toHaveBeenCalledWith( - '[orcad] managed stop preparation failed:', - expect.any(Error) - ) - }) - - it('stops on a valid managed request and keeps it as evidence', async () => { - const managedStop = managedContext() - const onRequest = vi.fn() - listen(onRequest, { installRoot: directory(), managedStop }) - const path = orcadManagedStopRequestPath(managedStop.instance) - writeFileSync( - path, - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - ...managedStop - }) - ) - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - expect(existsSync(path)).toBe(true) - }) - - it('ignores a managed request for another runtime and reports it once', async () => { - const managedStop = managedContext() - const onRequest = vi.fn() - const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { installRoot: directory(), managedStop }) - writeFileSync( - orcadManagedStopRequestPath(managedStop.instance), - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - ...managedStop, - runtimeId: 'runtime-2' - }) - ) - await vi.waitFor(() => expect(report).toHaveBeenCalled()) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).not.toHaveBeenCalled() - expect(report).toHaveBeenCalledOnce() - }) - - it('stops watching once closed', async () => { - const installRoot = directory() - const onRequest = vi.fn() - listen(onRequest, { installRoot }).close() - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/orcad/orcad-stop-request-listener.ts b/src/main/orcad/orcad-stop-request-listener.ts deleted file mode 100644 index 1b09eeed3dc..00000000000 --- a/src/main/orcad/orcad-stop-request-listener.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Watching for stop requests addressed to this orcad. - * - * Two listeners share one shutdown: the plain slot request (`.orcad-stop-request` beside - * `orcad.js`, consumed by unlinking it) and the instance-bound managed request in the data - * root, which is validated and kept as evidence until the completion command proves exit. - */ -import { unlinkSync, watch, type FSWatcher } from 'node:fs' -import { basename, dirname, join } from 'node:path' -import { - ORCAD_STOP_REQUEST_FILENAME, - type OrcadManagedStopContext, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' -import { - orcadManagedStopRequestPath, - validateOrcadManagedStopRequest -} from './orcad-managed-stop-request' -import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision' - -export type OrcadStopRequestListener = { close(): void } - -const DEFAULT_POLL_INTERVAL_MS = 1_000 - -function isMissingFile(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' -} - -/** `consume` returns true when the request should stop orcad; a missing file throws ENOENT. */ -function listenForRequest( - requestPath: string, - consume: (path: string) => boolean, - onRequest: () => void, - pollIntervalMs: number -): OrcadStopRequestListener { - let closed = false - let lastFailure: string | null = null - const check = (): void => { - if (closed) { - return - } - try { - if (!consume(requestPath)) { - return - } - } catch (error) { - if (!isMissingFile(error)) { - // Polling retries every interval; report each distinct refusal once. - const failure = error instanceof Error ? error.message : String(error) - if (failure !== lastFailure) { - lastFailure = failure - console.error(`[orcad] refused stop request at ${requestPath}:`, error) - } - } - return - } - closed = true - stop() - onRequest() - } - let watcher: FSWatcher | null = null - try { - watcher = watch(dirname(requestPath), (_event, changed) => { - if (!changed || basename(String(changed)) === basename(requestPath)) { - check() - } - }) - watcher.on('error', (error) => console.error('[orcad] stop-request watcher failed:', error)) - watcher.unref() - } catch (error) { - // The poll below still delivers requests; a watcher only makes them prompt. - console.error('[orcad] stop-request watcher could not start:', error) - } - const poll = setInterval(check, pollIntervalMs) - poll.unref() - const stop = (): void => { - watcher?.close() - clearInterval(poll) - } - // Covers a request written before this listener was installed. - check() - return { - close: () => { - closed = true - stop() - } - } -} - -export function installOrcadStopRequestListeners( - onRequest: () => void, - options: { - installRoot: string - managedStop?: OrcadManagedStopContext - /** Runs once for a validated managed request before the stop; it cannot prevent it. */ - beforeManagedStop?: (request: OrcadManagedStopRequest) => Promise - pollIntervalMs?: number - } -): OrcadStopRequestListener { - const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS - const listeners = [ - listenForRequest( - join(options.installRoot, ORCAD_STOP_REQUEST_FILENAME), - (path) => { - unlinkSync(path) - return true - }, - onRequest, - pollIntervalMs - ) - ] - const managedStop = options.managedStop - if (managedStop) { - const prepare = options.beforeManagedStop ?? (async () => {}) - listeners.push( - listenForRequest( - orcadManagedStopRequestPath(managedStop.instance), - (path) => { - const request = validateOrcadManagedStopRequest(managedStop, path) - // A cancelled request is never acted on; keep listening for the next transaction. - if (claimOrcadManagedStopDecision(request, 'dispatched') === 'canceled') { - throw new Error('orcad_managed_stop_canceled') - } - close() - // Preparation is best effort: whatever it reports, the stop proceeds. - void prepare(request) - .catch((error: unknown) => - console.error('[orcad] managed stop preparation failed:', error) - ) - .finally(onRequest) - return false - }, - onRequest, - pollIntervalMs - ) - ) - } - const close = (): void => { - for (const listener of listeners) { - listener.close() - } - } - return { close } -} diff --git a/src/main/orcad/orcad-terminal-census.test.ts b/src/main/orcad/orcad-terminal-census.test.ts deleted file mode 100644 index e8067f8a2b1..00000000000 --- a/src/main/orcad/orcad-terminal-census.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { DaemonSessionInfo } from '../daemon/types' -import { collectOrcadTerminalCensus } from './orcad-terminal-census' - -function session(createdAt: number, protocolVersion = 7): DaemonSessionInfo { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only createdAt and protocolVersion. - return { sessionId: `s-${createdAt}`, createdAt, protocolVersion } as DaemonSessionInfo -} - -describe('orcad terminal census', () => { - it('counts live sessions, those since activation, and their single owning protocol', async () => { - await expect( - collectOrcadTerminalCensus(100, async () => [session(50), session(100), session(150)]) - ).resolves.toEqual({ liveSessions: 3, startedSinceActivation: 2, daemonProtocolVersion: 7 }) - }) - - it('reports zero, not unknown, for an answered empty daemon', async () => { - await expect(collectOrcadTerminalCensus(100, async () => [])).resolves.toEqual({ - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: null - }) - }) - - it('leaves the protocol unknown when sessions span daemon generations', async () => { - const census = await collectOrcadTerminalCensus(0, async () => [session(1, 7), session(2, 6)]) - expect(census.daemonProtocolVersion).toBeNull() - expect(census.liveSessions).toBe(2) - }) - - it('leaves the since-activation count unknown when a session has no creation time', async () => { - const census = await collectOrcadTerminalCensus(0, async () => [session(0)]) - expect(census).toMatchObject({ liveSessions: 1, startedSinceActivation: null }) - }) - - it.each([ - ['no inventory', async () => null], - [ - 'a failed inventory', - async () => { - throw new Error('daemon unreachable') - } - ] - ])('reads %s as unverifiable, never as zero', async (_label, list) => { - await expect(collectOrcadTerminalCensus(0, list)).resolves.toEqual({ - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null - }) - }) -}) diff --git a/src/main/orcad/orcad-terminal-census.ts b/src/main/orcad/orcad-terminal-census.ts deleted file mode 100644 index 63fd96e72bd..00000000000 --- a/src/main/orcad/orcad-terminal-census.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** The terminal census a managed orcad reports to the client planning an update or stop. */ -import { listLiveDaemonSessionsWithProtocol } from '../daemon/daemon-provider-state' -import type { DaemonSessionInfo } from '../daemon/types' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' - -const UNVERIFIABLE: OrcadTerminalCensus = { - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null -} - -export async function collectOrcadTerminalCensus( - activatedAt: number, - listSessions: () => Promise = listLiveDaemonSessionsWithProtocol -): Promise { - let sessions: DaemonSessionInfo[] | null - try { - sessions = await listSessions() - } catch { - sessions = null - } - if (!sessions) { - return UNVERIFIABLE - } - const timestampsKnown = sessions.every( - (session) => Number.isFinite(session.createdAt) && session.createdAt > 0 - ) - const protocols = new Set(sessions.map((session) => session.protocolVersion)) - const [protocol] = protocols - return { - liveSessions: sessions.length, - startedSinceActivation: timestampsKnown - ? sessions.filter((session) => session.createdAt >= activatedAt).length - : null, - // Why one protocol only: sessions split across daemon generations have no single owner to - // check an incoming build against, so that reads as unverifiable. - daemonProtocolVersion: protocols.size === 1 && protocol !== undefined ? protocol : null - } -} diff --git a/src/main/providers/ssh-filesystem-dispatch.ts b/src/main/providers/ssh-filesystem-dispatch.ts index b7171f75dc0..04ef209bef1 100644 --- a/src/main/providers/ssh-filesystem-dispatch.ts +++ b/src/main/providers/ssh-filesystem-dispatch.ts @@ -37,16 +37,6 @@ export function unregisterSshFilesystemProvider(connectionId: string): void { sshProviders.delete(connectionId) } -export function unregisterSshFilesystemProviderIfCurrent( - connectionId: string, - expected: IFilesystemProvider -): boolean { - if (sshProviders.get(connectionId) !== expected) { - return false - } - return sshProviders.delete(connectionId) -} - export function getSshFilesystemProvider(connectionId: string): IFilesystemProvider | undefined { return sshProviders.get(connectionId) } diff --git a/src/main/providers/ssh-git-dispatch.ts b/src/main/providers/ssh-git-dispatch.ts index 73f120bcb13..2a73d57f9c7 100644 --- a/src/main/providers/ssh-git-dispatch.ts +++ b/src/main/providers/ssh-git-dispatch.ts @@ -49,21 +49,6 @@ export function _getSshGitProviderGenerationCacheSize(): number { return sshProviderGenerations.size } -export function unregisterSshGitProviderIfCurrent( - connectionId: string, - expected: SshGitProvider, - expectedGeneration: number -): boolean { - if ( - sshProviders.get(connectionId) !== expected || - getSshGitProviderGeneration(connectionId) !== expectedGeneration - ) { - return false - } - unregisterSshGitProvider(connectionId) - return true -} - export function getSshGitProvider(connectionId: string): SshGitProvider | undefined { return sshProviders.get(connectionId) } diff --git a/src/main/providers/ssh-pty-notification-recovery-activation.test.ts b/src/main/providers/ssh-pty-notification-recovery-activation.test.ts deleted file mode 100644 index e2198f77027..00000000000 --- a/src/main/providers/ssh-pty-notification-recovery-activation.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { createSubscription, sourceActivation } from './ssh-pty-notification-routing-test-fixture' - -describe('subscribeSshPtyNotifications', () => { - it('accepts non-empty recovery from the activation checkpoint', () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) - ) - - handler('pty.data', { - id: 'pty-1', - data: 'next', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - expect(onData).not.toHaveBeenCalled() - lease.commit() - expect(onData).toHaveBeenCalledWith( - expect.objectContaining({ - data: 'next', - source: expect.objectContaining({ sourceStartSu: 4, sourceEndSu: 8 }) - }) - ) - }) - - it('routes held and later recovery frames only to the private sink until commit', () => { - const { handler, dataListeners, livePtyIds, installReceivingActivation } = createSubscription() - const onData = vi.fn() - const onRecoveryData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 12 }) - ) - const publishSource = (data: string, sourceEndSu: number): void => { - handler('pty.data', { - id: 'pty-1', - data, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu, - sourceLengthSu: 4 - }) - } - - publishSource('held', 8) - const recoveryLease = lease.transferToRecovery(onRecoveryData) - publishSource('next', 12) - - expect(onRecoveryData.mock.calls.map(([payload]) => payload.data)).toEqual(['held', 'next']) - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - - recoveryLease.commit() - expect(onData).not.toHaveBeenCalled() - publishSource('live', 16) - - expect(onRecoveryData).toHaveBeenCalledTimes(2) - expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'live' })) - expect(livePtyIds).toContain('ssh:conn@@pty-1') - }) - - it('retires an exited private recovery when its activation commits', () => { - const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = - createSubscription() - const onData = vi.fn() - const onRecoveryData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 4 })) - handler('pty.data', { - id: 'pty-1', - data: 'held', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 4, - sourceLengthSu: 4 - }) - const recoveryLease = lease.transferToRecovery(onRecoveryData) - - handler('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) - recoveryLease.commit() - handler('pty.data', { - id: 'pty-1', - data: 'late', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - expect(onRecoveryData).toHaveBeenCalledOnce() - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - expect(mux.request).toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ id: 'pty-1', deliveryToken: 'token-1' }) - ) - }) - - it('retires private recovery locally and restores the exact predecessor', () => { - const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - const onRecoveryData = vi.fn() - dataListeners.add(onData) - installReceivingActivation( - 'pty-1', - sourceActivation({ deliveryToken: 'token-old', recoveryEndSu: 3 }) - ).commit() - handler('pty.data', { - id: 'pty-1', - data: 'pre', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - const replacement = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new', - checkpointSourceEndSu: 3, - recoveryEndSu: 6 - }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - replacement.transferToRecovery(onRecoveryData).retire() - handler('pty.data', { - id: 'pty-1', - data: 'old', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - expect(onRecoveryData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) - expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) - expect(mux.request).not.toHaveBeenCalled() - }) - - it('rejects a stale activation without disturbing current continuity', () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit() - - expect(() => - installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 1, - ownerGeneration: 4, - deliveryToken: 'token-stale', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - ).toThrow('ssh_source_receiving_activation_stale') - - handler('pty.data', { - id: 'pty-1', - data: 'one', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - expect(onData).toHaveBeenCalledOnce() - }) - - it('drops provisional frames and settles cancellation before rollback completes', async () => { - const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = - createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'next', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - await expect(lease.rollback()).resolves.toBe(true) - - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - }) - - it('restores the exact prior cursor when a replacement rolls back after frames', async () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ deliveryToken: 'token-old' })).commit() - handler('pty.data', { - id: 'pty-1', - data: 'pre', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - const replacement = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - await replacement.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'old', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) - }) - - it('does not let an older lease rollback replace a newer activation', async () => { - const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const older = installReceivingActivation('pty-1', sourceActivation()) - const newer = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new' - }) - ) - - await older.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - newer.commit() - - expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - expect(mux.request).not.toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ deliveryToken: 'token-new' }) - ) - }) -}) diff --git a/src/main/providers/ssh-pty-notification-routing-recovery.test.ts b/src/main/providers/ssh-pty-notification-routing-recovery.test.ts deleted file mode 100644 index 2ad66f62b18..00000000000 --- a/src/main/providers/ssh-pty-notification-routing-recovery.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' -import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' - -type MockMux = { - onNotification: ReturnType - request: ReturnType -} - -function createSubscription() { - const mux: MockMux = { - onNotification: vi.fn(), - request: vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) - } - const dataListeners = new Set<(payload: { id: string; data: string }) => void>() - const replayListeners = new Set<(payload: { id: string; data: string }) => void>() - const exitListeners = new Set<(payload: { id: string; code: number }) => void>() - const livePtyIds = new Set() - const recordExit = vi.fn() - const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`) - const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`) - - const subscription = subscribeSshPtyNotifications({ - mux: mux as never, - toAppPtyId, - dataListeners: dataListeners as never, - replayListeners: replayListeners as never, - exitListeners: exitListeners as never, - livePtyIds, - recordExit, - providerGeneration: 7, - resolvePtyIncarnation, - peekPtyIncarnation: () => undefined - }) - const handler = mux.onNotification.mock.calls[0]?.[0] as ( - method: string, - params: Record - ) => void - if (!handler) { - throw new Error('notification handler was not registered') - } - return { - handler, - mux, - toAppPtyId, - dataListeners, - replayListeners, - exitListeners, - livePtyIds, - recordExit, - resolvePtyIncarnation, - installReceivingActivation: subscription.installReceivingActivation - } -} - -function sourceActivation( - overrides: Partial = {} -): PtySourceReceivingActivation { - return Object.freeze({ - status: 'pending', - clientGeneration: 2, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - checkpointSourceEndSu: 0, - recoveryEndSu: 0, - ...overrides - }) -} - -describe('SSH PTY notification recovery routing', () => { - it('rejects a stale activation without disturbing current continuity', () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit() - - expect(() => - installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 1, - ownerGeneration: 4, - deliveryToken: 'token-stale', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - ).toThrow('ssh_source_receiving_activation_stale') - - handler('pty.data', { - id: 'pty-1', - data: 'one', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - expect(onData).toHaveBeenCalledOnce() - }) - - it('drops provisional frames and settles cancellation before rollback completes', async () => { - const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = - createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const lease = installReceivingActivation( - 'pty-1', - sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'next', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 8, - sourceLengthSu: 4 - }) - - await expect(lease.rollback()).resolves.toBe(true) - - expect(onData).not.toHaveBeenCalled() - expect(livePtyIds).not.toContain('ssh:conn@@pty-1') - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - }) - - it('restores the exact prior cursor when a replacement rolls back after frames', async () => { - const { handler, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - installReceivingActivation('pty-1', sourceActivation({ deliveryToken: 'token-old' })).commit() - handler('pty.data', { - id: 'pty-1', - data: 'pre', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - const replacement = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new', - checkpointSourceEndSu: 3, - recoveryEndSu: 3 - }) - ) - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - await replacement.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'old', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-old', - clientGeneration: 2, - ownerGeneration: 3, - sourceEndSu: 6, - sourceLengthSu: 3 - }) - - expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) - }) - - it('does not let an older lease rollback replace a newer activation', async () => { - const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - const older = installReceivingActivation('pty-1', sourceActivation()) - const newer = installReceivingActivation( - 'pty-1', - sourceActivation({ - clientGeneration: 3, - ownerGeneration: 4, - deliveryToken: 'token-new' - }) - ) - - await older.rollback() - handler('pty.data', { - id: 'pty-1', - data: 'new', - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-new', - clientGeneration: 3, - ownerGeneration: 4, - sourceEndSu: 3, - sourceLengthSu: 3 - }) - newer.commit() - - expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) - expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { - id: 'pty-1', - clientGeneration: 2, - ownerGeneration: 3, - deliveryToken: 'token-1' - }) - expect(mux.request).not.toHaveBeenCalledWith( - 'pty.cancelDelivery', - expect.objectContaining({ deliveryToken: 'token-new' }) - ) - }) - - it('ignores PTY methods with missing ids', () => { - const { handler, toAppPtyId, dataListeners } = createSubscription() - const onData = vi.fn() - dataListeners.add(onData) - - expect(() => handler('pty.data', { data: 'orphan' })).not.toThrow() - expect(toAppPtyId).not.toHaveBeenCalled() - expect(onData).not.toHaveBeenCalled() - }) - - it('leaves recovery and cancellation control methods to their dedicated handlers', () => { - const { - handler, - mux, - toAppPtyId, - dataListeners, - replayListeners, - exitListeners, - livePtyIds, - recordExit, - resolvePtyIncarnation - } = createSubscription() - const onData = vi.fn() - const onReplay = vi.fn() - const onExit = vi.fn() - dataListeners.add(onData) - replayListeners.add(onReplay) - exitListeners.add(onExit) - livePtyIds.add('ssh:conn@@unrelated') - - for (const method of [ - 'pty.recoveryData', - 'pty.recoveryComplete', - 'pty.restoreRequired', - 'pty.deliveryCanceled' - ]) { - handler(method, { - id: 'pty-1', - data: 'control', - deliveryToken: 'token-1', - clientGeneration: 2, - ownerGeneration: 3 - }) - } - - expect(toAppPtyId).not.toHaveBeenCalled() - expect(resolvePtyIncarnation).not.toHaveBeenCalled() - expect(recordExit).not.toHaveBeenCalled() - expect(onData).not.toHaveBeenCalled() - expect(onReplay).not.toHaveBeenCalled() - expect(onExit).not.toHaveBeenCalled() - expect(livePtyIds).toEqual(new Set(['ssh:conn@@unrelated'])) - expect(mux.request).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/providers/ssh-pty-notification-routing-test-fixture.ts b/src/main/providers/ssh-pty-notification-routing-test-fixture.ts deleted file mode 100644 index 6d86d673037..00000000000 --- a/src/main/providers/ssh-pty-notification-routing-test-fixture.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { vi, type Mock } from 'vitest' -import { - subscribeSshPtyNotifications, - type SshPtyNotificationSubscription -} from './ssh-pty-notification-routing' -import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' - -type NotificationHandler = (method: string, params: Record) => void -type CancellationRequest = ( - method: string, - params: Record -) => Promise<{ canceled: boolean; sentEndSu: number; creditedEndSu: number }> - -type MockMux = { - onNotification: Mock<(handler: NotificationHandler) => void> - request: Mock -} - -export type SshPtyNotificationTestSubscription = { - handler: NotificationHandler - mux: MockMux - toAppPtyId: Mock<(id: string) => string> - dataListeners: Set<(payload: { id: string; data: string }) => void> - replayListeners: Set<(payload: { id: string; data: string }) => void> - exitListeners: Set<(payload: { id: string; code: number }) => void> - livePtyIds: Set - recordExit: Mock<(relayPtyId: string, incarnationId: unknown) => void> - resolvePtyIncarnation: Mock<(id: string) => string> - installReceivingActivation: SshPtyNotificationSubscription['installReceivingActivation'] -} - -export function createSubscription(): SshPtyNotificationTestSubscription { - const mux: MockMux = { - onNotification: vi.fn<(handler: NotificationHandler) => void>(), - request: vi.fn(async () => ({ - canceled: true, - sentEndSu: 0, - creditedEndSu: 0 - })) - } - const dataListeners = new Set<(payload: { id: string; data: string }) => void>() - const replayListeners = new Set<(payload: { id: string; data: string }) => void>() - const exitListeners = new Set<(payload: { id: string; code: number }) => void>() - const livePtyIds = new Set() - const recordExit = vi.fn<(relayPtyId: string, incarnationId: unknown) => void>() - const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`) - const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`) - - const subscription = subscribeSshPtyNotifications({ - mux: mux as never, - toAppPtyId, - dataListeners: dataListeners as never, - replayListeners: replayListeners as never, - exitListeners: exitListeners as never, - livePtyIds, - recordExit, - providerGeneration: 7, - resolvePtyIncarnation, - peekPtyIncarnation: () => undefined - }) - - const handler = mux.onNotification.mock.calls[0]?.[0] - if (!handler) { - throw new Error('notification handler was not registered') - } - - return { - handler, - mux, - toAppPtyId, - dataListeners, - replayListeners, - exitListeners, - livePtyIds, - recordExit, - resolvePtyIncarnation, - installReceivingActivation: subscription.installReceivingActivation - } -} - -export function sourceActivation( - overrides: Partial = {} -): PtySourceReceivingActivation { - return Object.freeze({ - status: 'pending', - clientGeneration: 2, - ownerGeneration: 3, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - checkpointSourceEndSu: 0, - recoveryEndSu: 0, - ...overrides - }) -} diff --git a/src/main/providers/ssh-pty-notification-routing.test.ts b/src/main/providers/ssh-pty-notification-routing.test.ts index 332e8c3cdd6..fdb6776bf36 100644 --- a/src/main/providers/ssh-pty-notification-routing.test.ts +++ b/src/main/providers/ssh-pty-notification-routing.test.ts @@ -1,5 +1,74 @@ import { describe, expect, it, vi } from 'vitest' -import { createSubscription, sourceActivation } from './ssh-pty-notification-routing-test-fixture' +import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' +import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' + +type MockMux = { + onNotification: ReturnType + request: ReturnType +} + +function createSubscription() { + const mux: MockMux = { + onNotification: vi.fn(), + request: vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) + } + const dataListeners = new Set<(payload: { id: string; data: string }) => void>() + const replayListeners = new Set<(payload: { id: string; data: string }) => void>() + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const livePtyIds = new Set() + const recordExit = vi.fn() + const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`) + const resolvePtyIncarnation = vi.fn((id: string) => `incarnation:${id}`) + + const subscription = subscribeSshPtyNotifications({ + mux: mux as never, + toAppPtyId, + dataListeners: dataListeners as never, + replayListeners: replayListeners as never, + exitListeners: exitListeners as never, + livePtyIds, + recordExit, + providerGeneration: 7, + resolvePtyIncarnation, + peekPtyIncarnation: () => undefined + }) + + const handler = mux.onNotification.mock.calls[0]?.[0] as ( + method: string, + params: Record + ) => void + if (!handler) { + throw new Error('notification handler was not registered') + } + + return { + handler, + mux, + toAppPtyId, + dataListeners, + replayListeners, + exitListeners, + livePtyIds, + recordExit, + resolvePtyIncarnation, + installReceivingActivation: subscription.installReceivingActivation + } +} + +function sourceActivation( + overrides: Partial = {} +): PtySourceReceivingActivation { + return Object.freeze({ + status: 'pending', + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + checkpointSourceEndSu: 0, + recoveryEndSu: 0, + ...overrides + }) +} describe('subscribeSshPtyNotifications', () => { it('ignores non-PTY notifications without mapping params.id', () => { @@ -411,6 +480,328 @@ describe('subscribeSshPtyNotifications', () => { expect(mux.request).not.toHaveBeenCalled() }) + it('accepts non-empty recovery from the activation checkpoint', () => { + const { handler, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation( + 'pty-1', + sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) + ) + + handler('pty.data', { + id: 'pty-1', + data: 'next', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 8, + sourceLengthSu: 4 + }) + + expect(onData).not.toHaveBeenCalled() + lease.commit() + expect(onData).toHaveBeenCalledWith( + expect.objectContaining({ + data: 'next', + source: expect.objectContaining({ sourceStartSu: 4, sourceEndSu: 8 }) + }) + ) + }) + + it('routes held and later recovery frames only to the private sink until commit', () => { + const { handler, dataListeners, livePtyIds, installReceivingActivation } = createSubscription() + const onData = vi.fn() + const onRecoveryData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation( + 'pty-1', + sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 12 }) + ) + const publishSource = (data: string, sourceEndSu: number): void => { + handler('pty.data', { + id: 'pty-1', + data, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu, + sourceLengthSu: 4 + }) + } + + publishSource('held', 8) + const recoveryLease = lease.transferToRecovery(onRecoveryData) + publishSource('next', 12) + + expect(onRecoveryData.mock.calls.map(([payload]) => payload.data)).toEqual(['held', 'next']) + expect(onData).not.toHaveBeenCalled() + expect(livePtyIds).not.toContain('ssh:conn@@pty-1') + + recoveryLease.commit() + expect(onData).not.toHaveBeenCalled() + publishSource('live', 16) + + expect(onRecoveryData).toHaveBeenCalledTimes(2) + expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'live' })) + expect(livePtyIds).toContain('ssh:conn@@pty-1') + }) + + it('retires an exited private recovery when its activation commits', () => { + const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = + createSubscription() + const onData = vi.fn() + const onRecoveryData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 4 })) + handler('pty.data', { + id: 'pty-1', + data: 'held', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 4, + sourceLengthSu: 4 + }) + const recoveryLease = lease.transferToRecovery(onRecoveryData) + + handler('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) + recoveryLease.commit() + handler('pty.data', { + id: 'pty-1', + data: 'late', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 8, + sourceLengthSu: 4 + }) + + expect(onRecoveryData).toHaveBeenCalledOnce() + expect(onData).not.toHaveBeenCalled() + expect(livePtyIds).not.toContain('ssh:conn@@pty-1') + expect(mux.request).toHaveBeenCalledWith( + 'pty.cancelDelivery', + expect.objectContaining({ id: 'pty-1', deliveryToken: 'token-1' }) + ) + }) + + it('retires private recovery locally and restores the exact predecessor', () => { + const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + const onRecoveryData = vi.fn() + dataListeners.add(onData) + installReceivingActivation( + 'pty-1', + sourceActivation({ deliveryToken: 'token-old', recoveryEndSu: 3 }) + ).commit() + handler('pty.data', { + id: 'pty-1', + data: 'pre', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + const replacement = installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'token-new', + checkpointSourceEndSu: 3, + recoveryEndSu: 6 + }) + ) + handler('pty.data', { + id: 'pty-1', + data: 'new', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-new', + clientGeneration: 3, + ownerGeneration: 4, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + replacement.transferToRecovery(onRecoveryData).retire() + handler('pty.data', { + id: 'pty-1', + data: 'old', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + expect(onRecoveryData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) + expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) + expect(mux.request).not.toHaveBeenCalled() + }) + + it('rejects a stale activation without disturbing current continuity', () => { + const { handler, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + installReceivingActivation('pty-1', sourceActivation({ recoveryEndSu: 3 })).commit() + + expect(() => + installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 1, + ownerGeneration: 4, + deliveryToken: 'token-stale', + checkpointSourceEndSu: 3, + recoveryEndSu: 3 + }) + ) + ).toThrow('ssh_source_receiving_activation_stale') + + handler('pty.data', { + id: 'pty-1', + data: 'one', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + expect(onData).toHaveBeenCalledOnce() + }) + + it('drops provisional frames and settles cancellation before rollback completes', async () => { + const { handler, mux, dataListeners, livePtyIds, installReceivingActivation } = + createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + const lease = installReceivingActivation( + 'pty-1', + sourceActivation({ checkpointSourceEndSu: 4, recoveryEndSu: 8 }) + ) + handler('pty.data', { + id: 'pty-1', + data: 'next', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 8, + sourceLengthSu: 4 + }) + + await expect(lease.rollback()).resolves.toBe(true) + + expect(onData).not.toHaveBeenCalled() + expect(livePtyIds).not.toContain('ssh:conn@@pty-1') + expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-1' + }) + }) + + it('restores the exact prior cursor when a replacement rolls back after frames', async () => { + const { handler, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + installReceivingActivation('pty-1', sourceActivation({ deliveryToken: 'token-old' })).commit() + handler('pty.data', { + id: 'pty-1', + data: 'pre', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + const replacement = installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'token-new', + checkpointSourceEndSu: 3, + recoveryEndSu: 3 + }) + ) + handler('pty.data', { + id: 'pty-1', + data: 'new', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-new', + clientGeneration: 3, + ownerGeneration: 4, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + await replacement.rollback() + handler('pty.data', { + id: 'pty-1', + data: 'old', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-old', + clientGeneration: 2, + ownerGeneration: 3, + sourceEndSu: 6, + sourceLengthSu: 3 + }) + + expect(onData.mock.calls.map(([payload]) => payload.data)).toEqual(['pre', 'old']) + }) + + it('does not let an older lease rollback replace a newer activation', async () => { + const { handler, mux, dataListeners, installReceivingActivation } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + const older = installReceivingActivation('pty-1', sourceActivation()) + const newer = installReceivingActivation( + 'pty-1', + sourceActivation({ + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'token-new' + }) + ) + + await older.rollback() + handler('pty.data', { + id: 'pty-1', + data: 'new', + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-new', + clientGeneration: 3, + ownerGeneration: 4, + sourceEndSu: 3, + sourceLengthSu: 3 + }) + newer.commit() + + expect(onData).toHaveBeenCalledWith(expect.objectContaining({ data: 'new' })) + expect(mux.request).toHaveBeenCalledWith('pty.cancelDelivery', { + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'token-1' + }) + expect(mux.request).not.toHaveBeenCalledWith( + 'pty.cancelDelivery', + expect.objectContaining({ deliveryToken: 'token-new' }) + ) + }) + it('ignores PTY methods with missing ids', () => { const { handler, toAppPtyId, dataListeners } = createSubscription() const onData = vi.fn() diff --git a/src/main/providers/ssh-pty-process-list.ts b/src/main/providers/ssh-pty-process-list.ts deleted file mode 100644 index 5c9c2fa8248..00000000000 --- a/src/main/providers/ssh-pty-process-list.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtyProcessInfo } from './types' -import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' -import { mapSshPtyProcessList } from './ssh-agent-session-process-list' -import type { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' - -export function createSshPtyProcessLister( - args: Pick< - Parameters[0], - 'mux' | 'connectionId' | 'livePtyIds' | 'outputState' - > -): IPtyProvider['listProcesses'] { - return (options) => - listSshPtyProcesses({ - ...args, - includeForegroundProcessEvidence: options?.includeForegroundProcessEvidence, - deadlineMs: options?.deadlineMs - }) -} - -export async function listSshPtyProcesses( - args: Readonly<{ - mux: SshChannelMultiplexer - connectionId: string - livePtyIds: Set - outputState: SshPtyProviderOutputState - includeForegroundProcessEvidence?: boolean - deadlineMs?: number - }> -): Promise { - const result = await args.mux.request( - 'pty.listProcesses', - args.includeForegroundProcessEvidence === undefined - ? undefined - : { includeForegroundProcessEvidence: args.includeForegroundProcessEvidence }, - args.deadlineMs === undefined - ? undefined - : { timeoutMs: Math.max(1, args.deadlineMs - Date.now()) } - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the relay answers pty.listProcesses with PtyProcessInfo rows; the mapper rejects unproven ownership. - const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => - toAppSshPtyId(args.connectionId, id) - ) - for (const process of processes) { - args.livePtyIds.add(process.id) - args.outputState.rememberPtyIncarnation( - toRelaySshPtyId(args.connectionId, process.id), - process.incarnationId - ) - } - return processes -} diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index dea156218a1..59088247e18 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -1,5 +1,5 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from './types' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' @@ -13,6 +13,7 @@ import type { } from './ssh-pty-provider-contract' import { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' import { spawnFreshSshPty } from './ssh-agent-session-create-operation' +import { mapSshPtyProcessList } from './ssh-agent-session-process-list' import { requestSshPtyAttach, reattachSshPtySessionForSpawn, @@ -25,7 +26,6 @@ import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' import type { PtyProcessInspection } from './pty-process-inspection' import { spawnWithTerminalRuntimeRepair, type TerminalRepairHook } from './ssh-pty-spawn-repair' import { createSshPtyProviderRpcOperations } from './ssh-pty-provider-rpc-operations' -import { createSshPtyProcessLister } from './ssh-pty-process-list' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { @@ -38,7 +38,6 @@ export class SshPtyProvider implements IPtyProvider { private connectionId: string private livePtyIds = new Set() readonly getAppliedSize: NonNullable - readonly listProcesses: IPtyProvider['listProcesses'] private readonly agentSessionCapabilities: SshAgentSessionCapabilities private spawnExitRaces = new SshPtySpawnExitRaceTracker() private readonly outputState: SshPtyProviderOutputState @@ -102,12 +101,6 @@ export class SshPtyProvider implements IPtyProvider { this.spawnExitRaces.recordExit(relayPtyId, incarnationId) } }) - this.listProcesses = createSshPtyProcessLister({ - mux, - connectionId, - livePtyIds: this.livePtyIds, - outputState: this.outputState - }) } dispose(): void { @@ -277,6 +270,26 @@ export class SshPtyProvider implements IPtyProvider { this.livePtyIds.delete(id) } + async listProcesses(opts?: { + deadlineMs?: number + includeForegroundProcessEvidence?: boolean + }): Promise { + const result = await this.mux.request( + 'pty.listProcesses', + opts?.includeForegroundProcessEvidence === undefined + ? undefined + : { includeForegroundProcessEvidence: opts.includeForegroundProcessEvidence }, + relayTimeoutOptions(opts?.deadlineMs) + ) + const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) + for (const process of processes) { + this.livePtyIds.add(process.id) + const relayPtyId = this.toRelayPtyId(process.id) + this.outputState.rememberPtyIncarnation(relayPtyId, process.incarnationId) + } + return processes + } + hasPty = (id: string): boolean => this.livePtyIds.has(id) onData = (callback: SshPtyDataCallback): (() => void) => this.outputState.onData(callback) diff --git a/src/main/providers/ssh-pty-spawn-env.test.ts b/src/main/providers/ssh-pty-spawn-env.test.ts deleted file mode 100644 index 9c43d7a502b..00000000000 --- a/src/main/providers/ssh-pty-spawn-env.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env' - -describe('buildSshPtySpawnEnv relay bridge', () => { - it('prepends the CLI bin dir once and publishes the Node relay bridge', () => { - const env = buildSshPtySpawnEnv({ - env: { PATH: '/home/me/.orca-relay/bin:/usr/bin' }, - remoteCliBridgeEnv: { - binDir: '/home/me/.orca-relay/bin', - relayDir: '/home/me/.orca-relay/relay-v1', - nodePath: '/usr/bin/node', - sockPath: '/home/me/.orca-relay/relay.sock' - } - }) - - expect(env).toMatchObject({ - PATH: '/home/me/.orca-relay/bin:/usr/bin', - ORCA_REMOTE_CLI_BIN_DIR: '/home/me/.orca-relay/bin', - ORCA_RELAY_DIR: '/home/me/.orca-relay/relay-v1', - ORCA_RELAY_NODE_PATH: '/usr/bin/node', - ORCA_RELAY_SOCKET_PATH: '/home/me/.orca-relay/relay.sock' - }) - expect(env).not.toHaveProperty('ORCA_RELAY_CREDENTIAL_FILE') - }) -}) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts index 795d68c019e..5ba0b853b60 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts @@ -7,6 +7,7 @@ import { type AgentStateRuleAnswer, type AgentStateRuleCondition, type AgentStateRulesFile, + type HookAuthority, type NamedScreenPredicate } from './agent-state-rules-schema' import { compileTextAnchor } from './agent-state-text-anchors' @@ -102,14 +103,15 @@ export function compileAgentRules(file: AgentStateRulesFile): CompiledRule[] { type RulesKey = TuiAgent | typeof UNKNOWN_PANE_RULES_ID -type CompiledFile = { rules: CompiledRule[]; readsTrustedScreen: boolean } +type CompiledFile = { rules: CompiledRule[]; readsTrustedScreen: boolean; hooks: HookAuthority } const FILES_BY_KEY: ReadonlyMap = new Map( BUNDLED_AGENT_STATE_RULE_FILES.map((file) => [ file.id, { rules: compileAgentRules(file), - readsTrustedScreen: file.profile?.screenSource === 'trusted' + readsTrustedScreen: file.profile?.screenSource === 'trusted', + hooks: file.profile?.hooks ?? 'identity-only' } ]) ) @@ -128,6 +130,11 @@ export function readsTrustedScreen(agent: TuiAgent | null | undefined): boolean return compiledFileFor(agent)?.readsTrustedScreen ?? false } +/** Whether a fresh hook row for the agent's main turn decides readiness ahead of its rules. */ +export function hooksAreAuthoritative(agent: TuiAgent | null | undefined): boolean { + return compiledFileFor(agent)?.hooks === 'authoritative' +} + function someRule( agent: TuiAgent | null | undefined, test: (rule: CompiledRule) => boolean diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts index de2dbae4cc5..f5aafc2db3a 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts @@ -218,9 +218,18 @@ const AnchorSchema = z 'a title anchor answers idle' ) +/** + * How far readiness may trust the agent's hooks. `authoritative`: they report every way the main + * agent's turn ends (done, cancelled, an approval granted or denied), so a fresh hook row decides + * ahead of the rules. `identity-only` (the default): some end sends no event (Claude's denied + * approval and Esc mid-tool), so hooks only name the agent and the rules decide. + */ +const HOOK_AUTHORITIES = ['authoritative', 'identity-only'] as const + /** Facts about the agent that are not detection rules. */ const ProfileSchema = z .object({ + hooks: z.enum(HOOK_AUTHORITIES).optional(), /** Text whose presence in a pane's tail makes a tui-idle wait read its visible screen once. */ screenProbeBanner: TailLiteral.optional(), /** The screen the rules read: the PTY's own grid, trusted only while the PTY still has that @@ -279,4 +288,5 @@ export type TitleAnchorCondition = z.infer export type NamedTextAnchor = (typeof NAMED_TEXT_ANCHORS)[number] export type NamedScreenPredicate = (typeof NAMED_SCREEN_PREDICATES)[number] export type NamedTitlePredicate = (typeof NAMED_TITLE_PREDICATES)[number] +export type HookAuthority = (typeof HOOK_AUTHORITIES)[number] export type AgentStateRulesFile = z.infer diff --git a/src/main/runtime/agent-state-rules/omp.json b/src/main/runtime/agent-state-rules/omp.json index a0311486cb7..08d06e198bc 100644 --- a/src/main/runtime/agent-state-rules/omp.json +++ b/src/main/runtime/agent-state-rules/omp.json @@ -1,6 +1,7 @@ { "id": "omp", "engineVersion": 1, + "profile": { "hooks": "authoritative" }, "anchors": [], "rules": [ { diff --git a/src/main/runtime/agent-state-rules/opencode.json b/src/main/runtime/agent-state-rules/opencode.json index 47316f368c9..8e89dbab62e 100644 --- a/src/main/runtime/agent-state-rules/opencode.json +++ b/src/main/runtime/agent-state-rules/opencode.json @@ -1,6 +1,7 @@ { "id": "opencode", "engineVersion": 1, + "profile": { "hooks": "authoritative" }, "anchors": [ { "id": "native_title", diff --git a/src/main/runtime/agent-state-rules/opencode2.json b/src/main/runtime/agent-state-rules/opencode2.json index d1d543d08fe..6588c13ce39 100644 --- a/src/main/runtime/agent-state-rules/opencode2.json +++ b/src/main/runtime/agent-state-rules/opencode2.json @@ -1,6 +1,7 @@ { "id": "opencode2", "engineVersion": 1, + "profile": { "hooks": "authoritative" }, "anchors": [], "rules": [ { diff --git a/src/main/runtime/agent-state-rules/pi.json b/src/main/runtime/agent-state-rules/pi.json index 67b9eb3221a..3e5067f6f24 100644 --- a/src/main/runtime/agent-state-rules/pi.json +++ b/src/main/runtime/agent-state-rules/pi.json @@ -1,6 +1,7 @@ { "id": "pi", "engineVersion": 1, + "profile": { "hooks": "authoritative" }, "anchors": [ { "id": "idle_title", diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 7a17aadfc3c..cbc916c975f 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -258,25 +258,31 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM sendWorktreeStartupFollowupWhenReady(this.getWorktreeStartupReadinessHost(), handle, followup) } - protected async provisionManagedWorktreeTerminals(args: { - worktreeSelector: string - worktreeId: string - worktreePath: string - setup?: CreateWorktreeResult['setup'] - defaultTabs?: CreateWorktreeResult['defaultTabs'] - primaryTerminalHandle?: string | null - hasStartupTerminal: boolean - setupCommandPlatform: 'windows' | 'posix' - observeSetupCompletion?: boolean - // Why: when the agent startup is sequenced to wait for setup - // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds - // the setup command. Pass that wrapped command through so the Setup tab runs - // the same script the agent is waiting on instead of a bare runner. - wrappedSetupCommand?: string - // Why: a workspace provisioned in the background must not pull the sidebar - // to itself; the user never asked to look at these tabs. - surfaceOwner?: false - }): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { - return provisionWorktreeTerminals(this.getWorktreeTerminalProvisioningHost(), args) + protected async provisionManagedWorktreeTerminals( + args: { + worktreeSelector: string + worktreeId: string + worktreePath: string + setup?: CreateWorktreeResult['setup'] + defaultTabs?: CreateWorktreeResult['defaultTabs'] + primaryTerminalHandle?: string | null + hasStartupTerminal: boolean + setupCommandPlatform: 'windows' | 'posix' + observeSetupCompletion?: boolean + // Why: when the agent startup is sequenced to wait for setup + // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds + // the setup command. Pass that wrapped command through so the Setup tab runs + // the same script the agent is waiting on instead of a bare runner. + wrappedSetupCommand?: string + // Why: a workspace provisioned in the background must not pull the sidebar + // to itself; the user never asked to look at these tabs. + surfaceOwner?: false + }, + createdWorktree?: Worktree + ): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { + return provisionWorktreeTerminals( + this.getWorktreeTerminalProvisioningHost(createdWorktree), + args + ) } } diff --git a/src/main/runtime/orca-runtime-automation-operations.ts b/src/main/runtime/orca-runtime-automation-operations.ts index 32d47c59951..5064944215b 100644 --- a/src/main/runtime/orca-runtime-automation-operations.ts +++ b/src/main/runtime/orca-runtime-automation-operations.ts @@ -198,10 +198,6 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg return this.legacyWorkerRecovery.reconcile(options) } - stopLegacyWorkerTerminalRecovery(): Promise { - return this.legacyWorkerRecovery.stop() - } - protected updateLegacyWorkerTerminalRecoveryRetry( plan: LegacyWorkerTerminalRecoveryPlan, deferredDispatchIds: ReadonlySet, diff --git a/src/main/runtime/orca-runtime-create-base-prefetch.test.ts b/src/main/runtime/orca-runtime-create-base-prefetch.test.ts index d65209b9cc4..4390ded16d6 100644 --- a/src/main/runtime/orca-runtime-create-base-prefetch.test.ts +++ b/src/main/runtime/orca-runtime-create-base-prefetch.test.ts @@ -118,7 +118,8 @@ describe('prefetchManagedWorktreeCreateBase (orca-runtime-get-worktree-terminal- expect(mocks.prepareWorktreeCreateForRepo).toHaveBeenCalledWith( expect.anything(), repo, - 'origin/main' + 'origin/main', + undefined ) }) }) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index fce08f1c053..49eb69da7bc 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -242,10 +242,10 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork warning, ports: { canSpawn: Boolean(this.ptyController?.spawn), - createTerminal: (selector, options) => this.createTerminal(selector, options), + createTerminal: (selector, options) => this.createTerminal(selector, options, worktree), pasteDraft: (handle, draft) => this.pasteStartupDraftWhenReady(handle, draft), sendFollowup: (handle, followup) => this.sendStartupFollowupWhenReady(handle, followup), - provision: (options) => this.provisionManagedWorktreeTerminals(options), + provision: (options) => this.provisionManagedWorktreeTerminals(options, worktree), activate: (repoId, worktreeId, activationSetup, startup, activationDefaultTabs) => this.notifyActivateWorktree( repoId, diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 20172740460..9175fa8dec5 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -1,14 +1,17 @@ export type { TerminalCreateOptions } from './runtime-terminal-contracts' +export type { Worktree } from '../../shared/worktree/types' export type { RuntimeTerminalCreate } from '../../shared/runtime-types' export { createTerminalRevealWarning, ownerSurfacing, resolveTerminalPresentation } from './orca-runtime-core' -export { isValidHostTerminalTabId } from '../../shared/terminal-tab-id' -export { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' +export { makePaneKey } from '../../shared/stable-pane-id' export { randomUUID } from 'node:crypto' -export { admitStablePaneAdoption } from './runtime-terminal-pane-identity' +export { + admitStablePaneAdoption, + allocateTerminalPaneIdentity +} from './runtime-terminal-pane-identity' export { copySleepingAgentLaunchConfig, inferCapturedClaudeAgentTeamsMode, diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 9cd955428ee..6a56bff56b0 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -2,18 +2,20 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies' import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal' import type { RuntimeTerminalPresentation } from '../../shared/runtime-types' +import type { Worktree } from '../../shared/worktree/types' export async function createDesktopTerminal( runtime: OrcaRuntimeWithCreateTerminal, worktreeSelector: string | undefined, opts: dependencies.TerminalCreateOptions, presentation: RuntimeTerminalPresentation | undefined, - rendererWindow: Electron.BrowserWindow | null + rendererWindow: Electron.BrowserWindow | null, + createdWorktree?: Worktree ): Promise { runtime.assertGraphReady() const win = rendererWindow ?? runtime.getAuthoritativeWindow() const workspace = worktreeSelector - ? await runtime.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + ? await runtime.resolveTerminalWorkspaceLaunchScope(worktreeSelector, createdWorktree) : null const launchOpts = workspace ? await runtime.resolveAgentTerminalCreateOptions(workspace, opts) diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 242e0f7f454..c8f965fcf7e 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -9,14 +9,15 @@ import { recordPtySurface, spawnSurfaceClaimSequence } from './pty-recorded-surf export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication { async createTerminal( worktreeSelector?: string, - opts: dependencies.TerminalCreateOptions = {} + opts: dependencies.TerminalCreateOptions = {}, + // Internal creation evidence; RPC and preload callers never supply it. + created?: dependencies.Worktree ): Promise { if (opts.startupAgent && worktreeSelector === undefined) { throw new Error(`startupAgent ${opts.startupAgent} requires a workspace selector.`) } const callerColors = dependencies.normalizeColorQueryReplyColors(opts.terminalColorQueryReplies) - // Why: only a paired client sends colours here; a client that predates - // terminal.setViewerColors reports its theme to a headless host only this way. + // Older paired clients report their theme only at creation. if (callerColors) { dependencies.setPairedViewerColors(callerColors) } @@ -33,22 +34,14 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') } - const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) + const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector, created) const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) const reportPtySpawnCommitted = createPtySpawnCommitReporter(launchOpts.onPtySpawnCommitted) const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path let preAllocatedHandle = launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle() - const hintedTabId = launchOpts.tabId?.trim() - const canAdoptPaneIdentity = - hintedTabId !== undefined && - dependencies.isValidHostTerminalTabId(hintedTabId) && - launchOpts.leafId !== undefined && - dependencies.isTerminalLeafId(launchOpts.leafId) - let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : dependencies.randomUUID() - let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : dependencies.randomUUID() - let paneKey = dependencies.makePaneKey(tabId, leafId) + let { tabId, leafId, paneKey } = dependencies.allocateTerminalPaneIdentity(launchOpts) const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({ worktreeId: workspace.id, connectionId: workspace.connectionId, @@ -299,6 +292,13 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate } // The renderer owns this spawn, so this process cannot see when it is requested. opts.onPtySpawnDispatched?.() - return createDesktopTerminal(this, worktreeSelector, opts, presentation, rendererWindow) + return createDesktopTerminal( + this, + worktreeSelector, + opts, + presentation, + rendererWindow, + created + ) } } diff --git a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts index 7f42304eb8b..30542bc6ff8 100644 --- a/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts +++ b/src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts @@ -1,23 +1,21 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithActivateManagedWorktree } from './orca-runtime-activate-managed-worktree' -import type { - WorktreeProvisionTerminalOptions, - WorktreeTerminalProvisioningHost -} from './runtime-worktree-terminal-provisioning' -import type { TerminalCreateOptions } from './runtime-terminal-contracts' +import type { WorktreeTerminalProvisioningHost } from './runtime-worktree-terminal-provisioning' import type { WorktreeStartupReadinessHost } from './runtime-worktree-startup-readiness' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { prepareWorktreeCreateForRepo } from '../worktree-create-preparation' import { getWorktreeCreatePrefetchGitOptions } from '../project-runtime-git-options' +import type { Worktree } from '../../shared/worktree/types' export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRuntimeWithActivateManagedWorktree { - protected getWorktreeTerminalProvisioningHost(): WorktreeTerminalProvisioningHost { + protected getWorktreeTerminalProvisioningHost( + createdWorktree?: Worktree + ): WorktreeTerminalProvisioningHost { return { canSpawn: () => Boolean(this.ptyController?.spawn), createTerminal: (selector, options) => - this.createTerminal(selector, options as TerminalCreateOptions), - splitTerminal: (handle, options) => - this.splitTerminal(handle, options as WorktreeProvisionTerminalOptions), + this.createTerminal(selector, options, createdWorktree), + splitTerminal: (handle, options) => this.splitTerminal(handle, options, createdWorktree), setTabColor: async (worktreeId, tabId, color) => { await this.setMobileSessionTabProps(`id:${worktreeId}`, { tabId, color }) }, @@ -55,7 +53,8 @@ export class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends OrcaRunt baseBranch: args.baseBranch, runtime: this, gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo), - prepareCheckout: (base) => prepareWorktreeCreateForRepo(store, repo, base) + prepareCheckout: (base, beforeMaterialization) => + prepareWorktreeCreateForRepo(store, repo, base, beforeMaterialization) }) } } diff --git a/src/main/runtime/orca-runtime-headless-terminal-close.test.ts b/src/main/runtime/orca-runtime-headless-terminal-close.test.ts deleted file mode 100644 index 425a84c7bf9..00000000000 --- a/src/main/runtime/orca-runtime-headless-terminal-close.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - PTY_ID, - TAB_ID, - WORKTREE_ID, - createHarness, - type CloseContinuityHarness -} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' - -async function closeByPty(harness: CloseContinuityHarness): Promise { - const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( - (candidate) => candidate.ptyId === PTY_ID - ) - if (!terminal) { - throw new Error('fixture pane has no terminal handle') - } - return harness.runtime.closeTerminal(terminal.handle) -} - -/** A PTY-backed tab with no paired-viewer surface: the path orcad serves to the CLI. */ -function headlessPtyTab(): CloseContinuityHarness { - const harness = createHarness({ registerPtyBacked: true }) - harness.syncFixtureTabWithoutLeaf() - harness.setVerifiedStopResult(true) - return harness -} - -describe('closing a terminal by PTY on a host without a renderer tab', () => { - it('stops the PTY without asking a missing renderer to close the tab', async () => { - const harness = headlessPtyTab() - harness.syncEmptyGraph() - - await expect(closeByPty(harness)).resolves.toMatchObject({ tabId: TAB_ID }) - - expect(harness.closeTerminalTab).not.toHaveBeenCalled() - expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, expect.anything()) - }) - - it('does not fail the close when the advisory renderer notification throws', async () => { - const harness = headlessPtyTab() - harness.syncEmptyGraph() - harness.closeTerminal.mockImplementation(() => { - throw new Error('renderer gone') - }) - - await expect(closeByPty(harness)).resolves.toMatchObject({ tabId: TAB_ID }) - }) -}) diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index 04cb91e0281..a86c95d8742 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -5,7 +5,7 @@ import type { AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' import { isOpenCodeNativeTitle } from '../../shared/agent-detection' -import type { AgentStatusEntry } from '../../shared/agent-status-types' +import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' @@ -14,6 +14,7 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import type { TuiAgent } from '../../shared/tui-agent' import type { AgentPromptActivity } from './agent-prompt-submission-verification' +import { readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends OrcaRuntimeWithAgentPromptRequestCorrelation { protected resolveAuthoritativeTerminalWaitPermission( @@ -49,6 +50,52 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O return newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt ? blockedByWaitText : null } + /** The pane's main-agent turn from the hook server's store, for tui-idle's hook lane. */ + protected readTuiIdleHookTurnForPty(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null { + const pty = this.ptysById.get(ptyId) + const hookRows = this.getAgentStatusSnapshotFn?.() + if (!pty || !hookRows) { + return null + } + const handles = this.getExistingTerminalHandlesForPtyId(ptyId) + const paneKeys = this.collectPaneKeysForPty(ptyId) + if (pty.paneKey) { + paneKeys.add(pty.paneKey) + } + return readTuiIdleHookTurn({ + agent, + handles, + paneKeys, + hookRows, + respawnedAt: this.agentPromptExplicitStatusFloorByPtyId.get(ptyId), + lastInputAt: this.terminalRunFacts.readLastInputAt(ptyId), + resolveBlockedText: (state, row) => + this.resolveTuiIdleHookBlockedText(ptyId, handles, state, row) + }) + } + + private resolveTuiIdleHookBlockedText( + ptyId: string, + handles: readonly string[], + state: 'done' | 'working' | 'permission', + row: AgentStatusIpcPayload + ): RuntimeTerminalWaitBlockedReason | null { + const handle = this.handleByPtyId.get(ptyId) ?? handles[0] + if (!handle) { + return null + } + try { + return this.resolveAuthoritativeTerminalWaitPermission( + this.getTerminalAgentStatusSnapshot(handle, ptyId), + { status: state === 'done' ? 'idle' : state, updatedAt: row.receivedAt }, + this.agentPromptLifecycleByPtyId.get(ptyId) + ) + } catch { + // A handle that no longer targets this PTY has no text to judge. + return null + } + } + renewMobileAgentStatusFromPtyTitle( status: AgentStatusEntry | null, pty: RuntimePtyWorktreeRecord | null, diff --git a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts index decc28aa45b..960e36bd78d 100644 --- a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts +++ b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts @@ -25,6 +25,8 @@ import { getExplicitWorktreeIdSelector } from './runtime-worktree-selection' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import { WorktreeIdRequiresFullPathError } from './runtime-worktree-lineage-resolution' import { triggerTerminalSpawnPushTargetMaterialization } from './runtime-terminal-spawn-push-target-materialization' +import type { Worktree } from '../../shared/worktree/types' +import { resolveCreatedWorktreeTerminalTarget } from './runtime-created-worktree-terminal-target' export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extends OrcaRuntimeWithTransitionGraphReloadToTerminalState { protected resolveBrowserNetworkExecutionHostForWorktree(worktree?: { @@ -88,13 +90,15 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend } protected async resolveTerminalWorkspaceLaunchScope( - selector: string + selector: string, + createdWorktree?: Worktree ): Promise { - return (await this.resolveTerminalWorkspaceLaunchTarget(selector)).scope + return (await this.resolveTerminalWorkspaceLaunchTarget(selector, createdWorktree)).scope } protected async resolveTerminalWorkspaceLaunchTarget( - selector: string + selector: string, + createdWorktree?: Worktree ): Promise { const floatingTerminalSelector = selector === FLOATING_TERMINAL_WORKTREE_ID || @@ -124,7 +128,9 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector const parsed = parseWorkspaceKey(workspaceSelector) const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector - const worktree = await this.resolveWorktreeSelector(worktreeSelector) + const worktree = + resolveCreatedWorktreeTerminalTarget(this.store, selector, createdWorktree) ?? + (await this.resolveWorktreeSelector(worktreeSelector)) // Why: `getRepo(id)` is host-blind and the same repo id can exist on local, SSH and runtime // hosts. Reading `connectionId` off an arbitrary row reports "local" for a remote worktree and // spawns its PTY on the client with the remote cwd (#11163). Loss of a usable answer is diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 3a16c95a553..dbdec24d0fc 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -348,6 +348,7 @@ export class OrcaRuntimeWithRuntimeId { getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), getFirstPartyAgentStatus: (ptyId) => (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, + getHookTurn: (ptyId, agent) => this.readTuiIdleHookTurnForPty(ptyId, agent), readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId), readScreenRuledLines: (ptyId) => this.readScreenRuledLines(ptyId) } diff --git a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts index 8fe43de6950..6b9fd364b04 100644 --- a/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts +++ b/src/main/runtime/orca-runtime-split-pty-backed-terminal.ts @@ -7,6 +7,7 @@ import { makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { recordPtySurface, spawnSurfaceClaimSequence } from './pty-recorded-surface-topology' import { randomUUID } from 'node:crypto' import { REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, ownerSurfacing } from './orca-runtime-core' +import type { Worktree } from '../../shared/worktree/types' export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitTerminal { protected async splitPtyBackedTerminal( @@ -21,7 +22,8 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT // workspace, for splits the user never asked to see. surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource - } = {} + } = {}, + createdWorktree?: Worktree ): Promise { if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') @@ -35,7 +37,10 @@ export class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeWithSplitT throw new Error('terminal_handle_stale') } const direction = opts.direction ?? 'horizontal' - const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${pty.worktreeId}`) + const workspace = await this.resolveTerminalWorkspaceLaunchScope( + `id:${pty.worktreeId}`, + createdWorktree + ) const sourceAuthority = this.resolveTerminalSplitSourceAuthority( workspace.id, parentTabId, diff --git a/src/main/runtime/orca-runtime-split-terminal.ts b/src/main/runtime/orca-runtime-split-terminal.ts index f259f56e1a6..7dc006ef24e 100644 --- a/src/main/runtime/orca-runtime-split-terminal.ts +++ b/src/main/runtime/orca-runtime-split-terminal.ts @@ -3,6 +3,7 @@ import { OrcaRuntimeWithStopExplicitlyClosedTabPtys } from './orca-runtime-stop- import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { RuntimeTerminalSplit } from '../../shared/runtime-types' import { randomUUID } from 'node:crypto' +import type { Worktree } from '../../shared/worktree/types' export class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopExplicitlyClosedTabPtys { async splitTerminal( @@ -17,11 +18,13 @@ export class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopExplicitlyC // workspace, for splits the user never asked to see. surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource - } = {} + } = {}, + // Internal creation evidence; RPC and preload callers never supply it. + createdWorktree?: Worktree ): Promise { const livePty = this.getLivePtyForHandle(handle) if (livePty) { - return await this.splitPtyBackedTerminal(livePty.pty, opts) + return await this.splitPtyBackedTerminal(livePty.pty, opts, createdWorktree) } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) diff --git a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts b/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts index ea954fbce8b..a243d5ea0a6 100644 --- a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts +++ b/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts @@ -130,7 +130,7 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier.closeTerminal?.(tabId) } const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) @@ -147,35 +147,17 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF throw error } const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier?.closeTerminal(tabId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } - // Why the tabs guard: a headless host has no renderer tab to close through the notifier. - if ( - closesTab && - !surface && - pty.pty.tabId && - this.tabs.has(tabId) && - this.notifier?.closeTerminalTab - ) { + if (closesTab && !surface && pty.pty.tabId && this.notifier?.closeTerminalTab) { const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId) - let tabClosed = true - try { - await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true }) - } catch (error) { - // The tab went away concurrently; fall through and close the PTY alone. - if (!(error instanceof Error) || error.message !== 'tab_not_found') { - throw error - } - tabClosed = false - } - if (tabClosed) { - const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) - return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) - } + await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true }) + const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) if (!closesTab) { @@ -194,10 +176,10 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier?.closeTerminal(tabId) } } else { - this.notifyRendererOfHeadlessTerminalClose(tabId) + this.notifier?.closeTerminal(tabId) } return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 6d57069feab..79ff0be6698 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -6,6 +6,7 @@ import { closeLocalWatcherForWorktreePathMock, computeWorktreePathMock, deleteWorktreeHistoryDirMock, + describeCreatedWorktree, ensurePathWithinWorkspaceMock, findExistingWorktreeSymlinkPathsMock, forgetLocalWatcherRemovalSnapshotMock, @@ -330,7 +331,7 @@ describe('OrcaRuntimeService', () => { } computeWorktreePathMock.mockReturnValue(createdWorktree.path) ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) - vi.mocked(listWorktrees).mockResolvedValue([createdWorktree]) + vi.mocked(describeCreatedWorktree).mockResolvedValue(createdWorktree) const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { if (args[0] === 'symbolic-ref') { return { stdout: 'refs/remotes/origin/main\n', stderr: '' } @@ -353,6 +354,7 @@ describe('OrcaRuntimeService', () => { return { stdout: '', stderr: '' } }) + const inventoryCallsBefore = vi.mocked(listWorktrees).mock.calls.length try { const result = await runtime.createManagedWorktree({ repoSelector: 'id:repo-1', @@ -446,7 +448,13 @@ describe('OrcaRuntimeService', () => { branchName: 'contributor/runtime-wsl', remoteUrl: 'git@github.com:contributor/orca.git' }) - expect(listWorktrees).toHaveBeenCalledWith(TEST_REPO_PATH, { wslDistro: 'Ubuntu' }) + expect(describeCreatedWorktree).toHaveBeenCalledWith( + TEST_REPO_PATH, + createdWorktree.path, + 'runtime-wsl', + { wslDistro: 'Ubuntu' } + ) + expect(listWorktrees).toHaveBeenCalledTimes(inventoryCallsBefore) } finally { gitSpy.mockRestore() } diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts index 6a0a99f4ee8..eca4bd12514 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts @@ -94,7 +94,13 @@ describe('OrcaRuntimeService', () => { expect(runHook).not.toHaveBeenCalled() expect(createTerminal).toHaveBeenCalledWith( `id:${result.worktree.id}`, - expect.objectContaining({ viewMode: 'chat' }) + expect.objectContaining({ viewMode: 'chat' }), + expect.objectContaining({ + id: result.worktree.id, + path: result.worktree.path, + repoId: result.worktree.repoId, + identity: result.worktree.identity + }) ) // Why: setup is provisioned fire-and-forget; the wait-for-setup guarantee comes from the shell nonce/marker, not JS spawn ordering. await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) diff --git a/src/main/runtime/orchestration-cli-subprocess.test.ts b/src/main/runtime/orchestration-cli-subprocess.test.ts index d8306f9399d..4f10ccba7e1 100644 --- a/src/main/runtime/orchestration-cli-subprocess.test.ts +++ b/src/main/runtime/orchestration-cli-subprocess.test.ts @@ -71,6 +71,14 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { const runtime = new OrcaRuntimeService() const db = new OrchestrationDb(':memory:') runtime.setOrchestrationDb(db) + // A consuming inbox check requires a live pane, even when no messages exist. + const paneKey = 'tab_keepalive:11111111-1111-4111-8111-111111111111' + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_nobody' ? paneKey : null + ) + vi.spyOn(runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + runtime.getTerminalPaneKey(handle) + ) const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) await server.start() @@ -120,10 +128,9 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { child.once('error', rejectExit) }) - expect(exitCode).toBe(0) - const stderr = stderrChunks.map((c) => c.data).join('') const stdout = stdoutChunks.map((c) => c.data).join('') + expect(exitCode, stderr).toBe(0) const keepaliveLines = stderr .split('\n') @@ -188,6 +195,8 @@ describeIfBuilt('orca orchestration check --wait subprocess (§3.4)', () => { } finally { db.close() await server.stop() + vi.restoreAllMocks() + rmSync(userDataPath, { recursive: true, force: true }) } }, 30_000) }) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 762d61cf3aa..cbf6e0d8e0c 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,5 +1,4 @@ import { STATUS_METHODS } from './status' -import { ORCAD_TERMINAL_CENSUS_METHODS } from './orcad-terminal-census' import { AI_VAULT_METHODS } from './ai-vault' import { AUTOMATION_METHODS } from './automations' import { REPO_METHODS } from './repo' @@ -55,7 +54,6 @@ import { AGENT_LAUNCH_METHODS } from './agent-launch' // auditing the security boundary or wiring new CLI commands. export const ALL_RPC_METHODS = [ ...STATUS_METHODS, - ...ORCAD_TERMINAL_CENSUS_METHODS, ...AGENT_HOOK_METHODS, ...AI_VAULT_METHODS, ...ARTIFACT_METHODS, diff --git a/src/main/runtime/rpc/methods/orcad-terminal-census.ts b/src/main/runtime/rpc/methods/orcad-terminal-census.ts deleted file mode 100644 index a35156c721b..00000000000 --- a/src/main/runtime/rpc/methods/orcad-terminal-census.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineMethod } from '../core' -import { - ORCAD_TERMINAL_CENSUS_METHOD, - OrcadTerminalCensusParamsSchema -} from '../../../../shared/orcad-terminal-census' - -export const ORCAD_TERMINAL_CENSUS_METHODS = [ - defineMethod({ - name: ORCAD_TERMINAL_CENSUS_METHOD, - params: OrcadTerminalCensusParamsSchema, - handler: async (params) => { - // Why lazy: the census reaches the daemon modules, whose xterm polyfill defines a global - // `window`; importing them statically would load it into every process with the dispatcher. - const { collectOrcadTerminalCensus } = await import('../../../orcad/orcad-terminal-census') - return collectOrcadTerminalCensus(params.activatedAt) - } - }) -] diff --git a/src/main/runtime/rpc/node-websocket-lifecycle.ts b/src/main/runtime/rpc/node-websocket-lifecycle.ts deleted file mode 100644 index e37e38375da..00000000000 --- a/src/main/runtime/rpc/node-websocket-lifecycle.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { Server as HttpServer } from 'node:http' -import type { Server as HttpsServer } from 'node:https' -import type { WebSocket, WebSocketServer } from 'ws' -import type { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat' - -type WebSocketMessagePayload = string | Uint8Array -export type WebSocketMessageHandler = { - bivarianceHack( - msg: WebSocketMessagePayload, - reply: (response: string) => void, - ws: WebSocket - ): void -}['bivarianceHack'] - -export type WebSocketConnectionCloseHandler = ( - clientId: string | null, - ws: WebSocket, - hasOtherConnections: boolean -) => void - -// Why: WS connections are long-lived and multiplex many RPCs by `id`; auth and dispatch are delegated to the message handler. -export function attachNodeWebSocketLifecycle(args: { - ws: WebSocket - heartbeat: RemoteRuntimeServerHeartbeat - preAuthTimeoutMs: number - preAuthTimers: WeakMap> - clientIds: Map - heartbeatConnections: Set - // Why: read lazily so a handler registered after start still reaches sockets accepted earlier. - getMessageHandler: () => WebSocketMessageHandler | null - getConnectionCloseHandler: () => WebSocketConnectionCloseHandler | null -}): void { - const { ws } = args - let finalized = false - const onPong = (): void => args.heartbeat.noteAlive(ws) - const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => { - // Why: any inbound frame counts as proof of life, so an actively-talking client isn't reaped mid-request. - args.heartbeat.noteAlive(ws) - const message = - typeof data === 'string' ? data : isBinary ? toBinaryPayload(data) : data.toString() - args.getMessageHandler()?.( - message, - (response) => { - // Why: mobile clients disconnect often; guard the write so we don't throw on a dead socket. - if (ws.readyState === ws.OPEN) { - ws.send(response) - } - }, - ws - ) - } - const finalize = (): void => { - if (finalized) { - return - } - finalized = true - ws.off('pong', onPong) - ws.off('message', onMessage) - ws.off('close', finalize) - ws.off('error', onError) - clearNodeWebSocketPreAuthTimer(ws, args.preAuthTimers) - args.heartbeatConnections.delete(ws) - if (args.heartbeatConnections.size === 0) { - args.heartbeat.stop() - } - const clientId = args.clientIds.get(ws) ?? null - args.clientIds.delete(ws) - const hasOtherConnections = - clientId !== null && Array.from(args.clientIds.values()).includes(clientId) - args.getConnectionCloseHandler()?.(clientId, ws, hasOtherConnections) - } - const onError = (): void => { - // Why: close isn't guaranteed after every error path; finalize here too so pre-auth E2EE state and connection ids can't leak. - finalize() - ws.close() - } - const preAuthTimer = setTimeout(() => { - if (!args.clientIds.has(ws)) { - // Why: a silent auto-ponging client would otherwise hold a finite mobile slot forever without starting the E2EE handshake. - ws.terminate() - } - }, args.preAuthTimeoutMs) - preAuthTimer.unref?.() - args.preAuthTimers.set(ws, preAuthTimer) - ws.on('pong', onPong) - ws.on('message', onMessage) - // Why: clean up connection-scoped state (e.g. mobile-fit overrides) so a dropped phone doesn't leave orphaned phone-fit on desktop. - ws.on('close', finalize) - ws.on('error', onError) - // Why: install lifecycle ownership before periodic heartbeat ticks can observe this socket. - args.heartbeatConnections.add(ws) - args.heartbeat.noteAlive(ws) - if (args.heartbeatConnections.size === 1) { - // Unauthenticated sockets are protected by the pre-auth timeout; heartbeat probes begin only - // after E2EE binds a client id, avoiding control frames during the handshake. - args.heartbeat.start(() => args.clientIds.keys()) - } -} - -function toBinaryPayload(data: Exclude): Uint8Array { - return Array.isArray(data) ? Buffer.concat(data) : new Uint8Array(data) -} - -export function clearNodeWebSocketPreAuthTimer( - ws: WebSocket, - preAuthTimers: WeakMap> -): void { - const timer = preAuthTimers.get(ws) - if (timer) { - clearTimeout(timer) - preAuthTimers.delete(ws) - } -} - -export async function stopNodeWebSocketTransport(args: { - wss: WebSocketServer | null - httpServer: HttpServer | HttpsServer | null - heartbeat: RemoteRuntimeServerHeartbeat - heartbeatConnections: Set -}): Promise { - args.heartbeat.stop() - args.heartbeatConnections.clear() - if (args.wss) { - for (const client of args.wss.clients) { - // Why: a half-open mobile socket may never answer a close frame, which keeps httpServer.close pending. - client.terminate() - } - args.wss.close() - } - const httpServer = args.httpServer - if (httpServer) { - await new Promise((resolve, reject) => { - httpServer.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - // Why: idle keep-alive static-web connections would otherwise hold close() open. - httpServer.closeAllConnections() - }) - } -} - -// Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing. -export function rejectNodeWebSocketOverCapacity(ws: WebSocket): void { - ws.on('error', () => {}) - ws.close(1013, 'Maximum connections reached') - const terminateTimer = setTimeout(() => ws.terminate(), 1_000) - terminateTimer.unref?.() - ws.once('close', () => clearTimeout(terminateTimer)) -} diff --git a/src/main/runtime/rpc/websocket-transport-limits.ts b/src/main/runtime/rpc/websocket-transport-limits.ts deleted file mode 100644 index e2e0b3d16ce..00000000000 --- a/src/main/runtime/rpc/websocket-transport-limits.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES = 1024 * 1024 -// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs. -export const WEBSOCKET_TRANSPORT_MAX_CONNECTIONS = 128 -// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound. -export const WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS = WEBSOCKET_TRANSPORT_MAX_CONNECTIONS * 2 diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 90be4229d42..45798a89528 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -1,12 +1,10 @@ import { EventEmitter } from 'node:events' import { mkdtempSync } from 'node:fs' -import { connect } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, afterEach, vi } from 'vitest' import WebSocket from 'ws' import { WebSocketTransport } from './ws-transport' -import { rejectNodeWebSocketOverCapacity } from './node-websocket-lifecycle' import { loadOrCreateTlsCertificate } from '../tls-certificate' // Why: disable TLS verification for self-signed certs in tests. @@ -435,7 +433,9 @@ describe('WebSocketTransport', () => { vi.useFakeTimers() try { - rejectNodeWebSocketOverCapacity(serverSocket!) + ;(transport as unknown as { rejectOverCapacity(ws: WebSocket): void }).rejectOverCapacity( + serverSocket! + ) vi.advanceTimersByTime(1_000) } finally { vi.useRealTimers() @@ -455,24 +455,6 @@ describe('WebSocketTransport', () => { await transport.stop() }) - it('stops while an HTTP client holds an unanswered request open', async () => { - const transport = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) - transports.push(transport) - await transport.start() - const socket = connect(transport.resolvedPort, '127.0.0.1') - await new Promise((resolve) => socket.once('connect', () => resolve())) - socket.on('error', () => {}) - // Why: the server's automatic 100 Continue proves the request is parsed and in flight. - const continued = new Promise((resolve) => socket.once('data', () => resolve())) - socket.write( - 'POST /unanswered HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 1\r\nExpect: 100-continue\r\n\r\n' - ) - await continued - - await transport.stop() - socket.destroy() - }) - it('is safe to stop without starting', async () => { const { transport } = await createTransport() await transport.stop() @@ -687,23 +669,6 @@ describe('WebSocketTransport', () => { expect(transport.resolvedPort).toBe(fallbackPort) }) - it('fails closed when a managed tunnel requires the configured port', async () => { - const preferredHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) - transports.push(preferredHolder) - await preferredHolder.start() - const transport = new WebSocketTransport({ - host: '127.0.0.1', - port: preferredHolder.resolvedPort, - fallbackPort: await reserveFreePort(), - preferPinnedPort: true, - strictPort: true - }) - transports.push(transport) - - await expect(transport.start()).rejects.toMatchObject({ code: 'EADDRINUSE' }) - expect(transport.resolvedHost).toBeNull() - }) - it('binds the preferred port when the persisted fallback is taken', async () => { const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) transports.push(fallbackHolder) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 81e76b1bb09..3ba8a17fe60 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -4,22 +4,22 @@ import { createServer as createHttpServer, type Server as HttpServer } from 'nod import { WebSocketServer, type WebSocket } from 'ws' import type { RpcTransport } from './transport' import { createStaticWebClientHandler } from './static-web-client-handler' -import { - attachNodeWebSocketLifecycle, - clearNodeWebSocketPreAuthTimer, - rejectNodeWebSocketOverCapacity, - stopNodeWebSocketTransport, - type WebSocketConnectionCloseHandler, - type WebSocketMessageHandler -} from './node-websocket-lifecycle' import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat' -import { - WEBSOCKET_TRANSPORT_MAX_CONNECTIONS, - WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES, - WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS -} from './websocket-transport-limits' +const MAX_WS_MESSAGE_BYTES = 1024 * 1024 +// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs. +const MAX_WS_CONNECTIONS = 128 +// Why: bound pre-upgrade descriptor use above the WS cap so raw sockets can't grow without bound. +const MAX_TCP_CONNECTIONS = MAX_WS_CONNECTIONS * 2 const PRE_AUTH_TIMEOUT_MS = 10_000 +type WebSocketMessagePayload = string | Uint8Array +type WebSocketMessageHandler = { + bivarianceHack( + msg: WebSocketMessagePayload, + reply: (response: string) => void, + ws: WebSocket + ): void +}['bivarianceHack'] // Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~60s (clients auto-pong per RFC 6455), since a reap needs consecutive unanswered probes rather than one (STA-3320). const HEARTBEAT_INTERVAL_MS = 15_000 @@ -41,8 +41,6 @@ export type WebSocketTransportOptions = { fallbackPort?: number // Why: serve --port clients dial the pinned port; prefer it first so a stale fallback can't steal the pin (issue #8535). Default keeps fallback-first (STA-1511). preferPinnedPort?: boolean - // Why: managed SSH tunnels dial one configured remote port and cannot follow a fallback. - strictPort?: boolean } export class WebSocketTransport implements RpcTransport { @@ -55,11 +53,12 @@ export class WebSocketTransport implements RpcTransport { private readonly staticRoot: string | undefined private readonly fallbackPort: number | undefined private readonly preferPinnedPort: boolean - private readonly strictPort: boolean private httpServer: HttpsServer | HttpServer | null = null private wss: WebSocketServer | null = null private messageHandler: WebSocketMessageHandler | null = null - private connectionCloseHandler: WebSocketConnectionCloseHandler | null = null + private connectionCloseHandler: + | ((clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void) + | null = null // Why: maps each socket to its authenticated clientId so close can report which device disconnected. private wsClientIds = new Map() private heartbeatConnections = new Set() @@ -75,8 +74,7 @@ export class WebSocketTransport implements RpcTransport { preAuthTimeoutMs, staticRoot, fallbackPort, - preferPinnedPort, - strictPort + preferPinnedPort }: WebSocketTransportOptions) { this.host = host this.port = port @@ -85,13 +83,12 @@ export class WebSocketTransport implements RpcTransport { this.heartbeat = new RemoteRuntimeServerHeartbeat( heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS, heartbeatNow, - WEBSOCKET_TRANSPORT_MAX_CONNECTIONS + MAX_WS_CONNECTIONS ) this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS this.staticRoot = staticRoot this.fallbackPort = fallbackPort this.preferPinnedPort = preferPinnedPort === true - this.strictPort = strictPort === true } onMessage(handler: WebSocketMessageHandler): void { @@ -99,13 +96,15 @@ export class WebSocketTransport implements RpcTransport { } // Why: pass the closing `ws` and whether other sockets share its deviceToken, so client-scoped teardown fires only on the last disconnect. - onConnectionClose(handler: WebSocketConnectionCloseHandler): void { + onConnectionClose( + handler: (clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void + ): void { this.connectionCloseHandler = handler } setClientId(ws: WebSocket, clientId: string): void { this.wsClientIds.set(ws, clientId) - clearNodeWebSocketPreAuthTimer(ws, this.preAuthTimers) + this.clearPreAuthTimer(ws) } terminateClientConnections(clientId: string): number { @@ -138,10 +137,6 @@ export class WebSocketTransport implements RpcTransport { if (this.wss) { return } - if (this.strictPort) { - await this.tryListen(this.port) - return - } // Why: bind a persisted fallback first so devices paired to it aren't stranded (STA-1511); serve --port flips to pinned-first (issue #8535); on failure each candidate falls through to OS-assigned port 0. const persistedFallbackPort = @@ -197,16 +192,16 @@ export class WebSocketTransport implements RpcTransport { }) // Why: the WS cap applies only post-upgrade; a separate TCP cap bounds raw/pre-upgrade descriptor use. - httpServer.maxConnections = WEBSOCKET_TRANSPORT_MAX_TCP_CONNECTIONS + httpServer.maxConnections = MAX_TCP_CONNECTIONS const wss = new WebSocketServer({ server: httpServer, - maxPayload: WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES + maxPayload: MAX_WS_MESSAGE_BYTES }) wss.on('connection', (ws) => { - if (wss.clients.size > WEBSOCKET_TRANSPORT_MAX_CONNECTIONS) { - rejectNodeWebSocketOverCapacity(ws) + if (wss.clients.size > MAX_WS_CONNECTIONS) { + this.rejectOverCapacity(ws) return } this.handleConnection(ws) @@ -216,30 +211,130 @@ export class WebSocketTransport implements RpcTransport { this.wss = wss } + // Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing. + private rejectOverCapacity(ws: WebSocket): void { + ws.on('error', () => {}) + ws.close(1013, 'Maximum connections reached') + const terminateTimer = setTimeout(() => ws.terminate(), 1_000) + terminateTimer.unref?.() + ws.once('close', () => clearTimeout(terminateTimer)) + } + async stop(): Promise { const wss = this.wss const httpServer = this.httpServer this.wss = null this.httpServer = null - await stopNodeWebSocketTransport({ - wss, - httpServer, - heartbeat: this.heartbeat, - heartbeatConnections: this.heartbeatConnections - }) + this.heartbeat.stop() + this.heartbeatConnections.clear() + + if (wss) { + for (const client of wss.clients) { + // Why: a half-open mobile socket may never answer a close frame, which keeps httpServer.close pending. + client.terminate() + } + wss.close() + } + + if (httpServer) { + await new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } } + // Why: WS connections are long-lived and multiplex many RPCs by `id`; auth and dispatch are delegated to the message handler. private handleConnection(ws: WebSocket): void { - attachNodeWebSocketLifecycle({ - ws, - heartbeat: this.heartbeat, - preAuthTimeoutMs: this.preAuthTimeoutMs, - preAuthTimers: this.preAuthTimers, - clientIds: this.wsClientIds, - heartbeatConnections: this.heartbeatConnections, - getMessageHandler: () => this.messageHandler, - getConnectionCloseHandler: () => this.connectionCloseHandler - }) + let finalized = false + const onPong = (): void => { + this.heartbeat.noteAlive(ws) + } + const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => { + // Why: any inbound frame counts as proof of life, so an actively-talking client isn't reaped mid-request. + this.heartbeat.noteAlive(ws) + const msg = + typeof data === 'string' + ? data + : isBinary + ? new Uint8Array(data as Buffer) + : data.toString() + this.messageHandler?.( + msg, + (response) => { + // Why: mobile clients disconnect often; guard the write so we don't throw on a dead socket. + if (ws.readyState === ws.OPEN) { + ws.send(response) + } + }, + ws + ) + } + const onError = (): void => { + // Why: close isn't guaranteed after every error path; finalize here too so pre-auth E2EE state and connection ids can't leak. + finalizeConnection() + ws.close() + } + const finalizeConnection = (): void => { + if (finalized) { + return + } + finalized = true + ws.off('pong', onPong) + ws.off('message', onMessage) + ws.off('close', finalizeConnection) + ws.off('error', onError) + this.clearPreAuthTimer(ws) + this.heartbeatConnections.delete(ws) + if (this.heartbeatConnections.size === 0) { + this.heartbeat.stop() + } + const clientId = this.wsClientIds.get(ws) ?? null + this.wsClientIds.delete(ws) + const hasOtherConnections = + clientId !== null && Array.from(this.wsClientIds.values()).includes(clientId) + this.connectionCloseHandler?.(clientId, ws, hasOtherConnections) + } + + const preAuthTimer = setTimeout(() => { + if (!this.wsClientIds.has(ws)) { + // Why: a silent auto-ponging client would otherwise hold a finite mobile slot forever without starting the E2EE handshake. + ws.terminate() + } + }, this.preAuthTimeoutMs) + if (typeof preAuthTimer.unref === 'function') { + preAuthTimer.unref() + } + this.preAuthTimers.set(ws, preAuthTimer) + + ws.on('pong', onPong) + ws.on('message', onMessage) + + // Why: clean up connection-scoped state (e.g. mobile-fit overrides) so a dropped phone doesn't leave orphaned phone-fit on desktop. + ws.on('close', finalizeConnection) + ws.on('error', onError) + + // Why: install lifecycle ownership before periodic heartbeat ticks can observe this socket. + this.heartbeatConnections.add(ws) + this.heartbeat.noteAlive(ws) + if (this.heartbeatConnections.size === 1) { + // Unauthenticated sockets are protected by the pre-auth timeout; heartbeat probes begin only + // after E2EE binds a client id, avoiding control frames during the handshake. + this.heartbeat.start(() => this.wsClientIds.keys()) + } + } + + private clearPreAuthTimer(ws: WebSocket): void { + const timer = this.preAuthTimers.get(ws) + if (timer) { + clearTimeout(timer) + this.preAuthTimers.delete(ws) + } } } diff --git a/src/main/runtime/runtime-created-worktree-terminal-target.test.ts b/src/main/runtime/runtime-created-worktree-terminal-target.test.ts new file mode 100644 index 00000000000..dd7162e98c1 --- /dev/null +++ b/src/main/runtime/runtime-created-worktree-terminal-target.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { Worktree } from '../../shared/worktree/types' +import type { WorktreeLineage } from '../../shared/worktree/lineage-types' +import type { RuntimeStore } from './runtime-store-contract' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import { mergeWorktree } from '../ipc/worktree-metadata-merge' +import { resolveCreatedWorktreeTerminalTarget } from './runtime-created-worktree-terminal-target' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } +})) + +import { OrcaRuntimeService } from './orca-runtime' + +class StartupRuntime extends OrcaRuntimeService { + readonly lookup = vi.fn<(selector: string) => Promise>() + + protected override resolveWorktreeSelector(selector: string): Promise { + return this.lookup(selector) + } +} + +function owner(hostId: ExecutionHostId = 'local'): Repo { + return { + id: 'repo-1', + path: '/repos/app', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + executionHostId: hostId + } +} + +function makeFixture(repos: Repo[] = [owner()], hostId: ExecutionHostId = 'local') { + const meta: WorktreeMeta = { + instanceId: 'new-instance', + hostId, + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + const worktree = mergeWorktree( + 'repo-1', + { + path: '/workspaces/new', + head: 'abc123', + branch: 'refs/heads/new', + isBare: false, + isMainWorktree: false + }, + meta + ) + const metadata: Record = { [worktree.id]: meta } + const lineageById: Record = {} + const store: RuntimeStore = { + getRepos: () => repos, + getRepo: (id) => repos.find((repo) => repo.id === id), + addRepo: vi.fn(), + updateRepo: vi.fn(), + getAllWorktreeMeta: () => metadata, + getWorktreeMeta: (id) => metadata[id], + getAllWorktreeLineage: () => lineageById, + setWorktreeMeta: (id, patch) => (metadata[id] = { ...meta, ...metadata[id], ...patch }), + removeWorktreeMeta: (id) => { + delete metadata[id] + }, + getGitHubCache: vi.fn(), + getSettings: () => ({ + workspaceDir: '/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) + } + const runtime = new StartupRuntime(store) + const fallback: ResolvedWorktree = { + ...worktree, + git: worktree, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + } + runtime.lookup.mockResolvedValue(fallback) + let ptySequence = 0 + const spawn = vi.fn(async () => ({ id: `new-pty-${++ptySequence}` })) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + return { runtime, store, worktree, meta, metadata, lineageById, spawn } +} + +describe('created worktree terminal evidence', () => { + it('starts a terminal in the new checkout without querying the worktree inventory', async () => { + const { runtime, worktree, spawn } = makeFixture() + await runtime.createTerminal(`id:${worktree.id}`, { presentation: 'background' }, worktree) + expect(runtime.lookup).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: worktree.path, + worktreeId: worktree.id, + connectionId: null, + initiallyHidden: true + }) + ) + }) + + it('still resolves normally without internal creation evidence', async () => { + const { runtime, worktree } = makeFixture() + await runtime.createTerminal(`id:${worktree.id}`, { presentation: 'background' }) + expect(runtime.lookup).toHaveBeenCalledOnce() + }) + + it('starts a setup split without another worktree inventory query', async () => { + const { runtime, worktree, spawn } = makeFixture() + const terminal = await runtime.createTerminal( + `id:${worktree.id}`, + { presentation: 'background' }, + worktree + ) + await runtime.splitTerminal(terminal.handle, { surfaceOwner: false }, worktree) + expect(runtime.lookup).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenLastCalledWith( + expect.objectContaining({ cwd: worktree.path, worktreeId: worktree.id, connectionId: null }) + ) + }) + + it('uses ordinary resolution when the created instance has been replaced', async () => { + const { runtime, worktree, metadata } = makeFixture() + metadata[worktree.id] = { ...metadata[worktree.id]!, instanceId: 'replacement' } + await runtime.createTerminal(`id:${worktree.id}`, { presentation: 'background' }, worktree) + expect(runtime.lookup).toHaveBeenCalledOnce() + }) + + it('does not reuse creation evidence for a different selector or a replaced instance', async () => { + const { store, worktree, metadata } = makeFixture() + expect(resolveCreatedWorktreeTerminalTarget(store, 'branch:new', worktree)).toBeNull() + expect( + resolveCreatedWorktreeTerminalTarget(store, 'id:repo-1::/elsewhere', worktree) + ).toBeNull() + metadata[worktree.id] = { ...metadata[worktree.id]!, instanceId: 'replacement' } + expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull() + }) + + it('routes an SSH-owned created checkout through its host when repo ids collide', async () => { + const { runtime, worktree, spawn } = makeFixture([owner(), owner('ssh:builder')], 'ssh:builder') + await runtime.createTerminal(`id:${worktree.id}`, { presentation: 'background' }, worktree) + expect(runtime.lookup).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'builder' })) + }) + + it('checks the owner host metadata when a rival host shares the same locator', async () => { + const { runtime, store, worktree, meta, metadata, spawn } = makeFixture( + [owner(), owner('ssh:builder')], + 'ssh:builder' + ) + Object.assign(store, { + getWorktreeMetaForHost: (id: string, hostId: ExecutionHostId) => + id === worktree.id && hostId === 'ssh:builder' ? meta : undefined + }) + metadata[worktree.id] = { ...meta, hostId: 'local', instanceId: 'rival-instance' } + await runtime.createTerminal(`id:${worktree.id}`, { presentation: 'background' }, worktree) + expect(runtime.lookup).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ connectionId: 'builder' })) + }) + + it('rejects another host metadata and ambiguous legacy ownership', async () => { + const { store, worktree, metadata } = makeFixture([owner(), owner('ssh:builder')]) + metadata[worktree.id] = { ...metadata[worktree.id]!, hostId: 'ssh:builder' } + expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull() + metadata[worktree.id] = { ...metadata[worktree.id]!, hostId: undefined } + const unstamped: Worktree = { ...worktree, hostId: undefined } + expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, unstamped)).toBeNull() + expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull() + }) + + it('keeps folder workspaces on their existing launch lookup', () => { + const { store, worktree } = makeFixture([{ ...owner(), kind: 'folder' }]) + expect(resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree)).toBeNull() + }) + + it('preserves stored lineage while rejecting stale parents and cycles', () => { + const { store, worktree, meta, metadata, lineageById } = makeFixture() + const parentId = 'repo-1::/workspaces/parent' + const childId = 'repo-1::/workspaces/child' + metadata[parentId] = { ...meta, instanceId: 'parent-instance' } + metadata[childId] = { ...meta, instanceId: 'child-instance' } + const lineage: WorktreeLineage = { + worktreeId: worktree.id, + worktreeInstanceId: 'new-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + lineageById[worktree.id] = lineage + lineageById[childId] = { + ...lineage, + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: worktree.id, + parentWorktreeInstanceId: 'new-instance' + } + const resolve = () => resolveCreatedWorktreeTerminalTarget(store, `id:${worktree.id}`, worktree) + expect(resolve()).toEqual( + expect.objectContaining({ parentWorktreeId: parentId, childWorktreeIds: [childId], lineage }) + ) + metadata[parentId] = { ...meta, instanceId: 'replacement' } + expect(resolve()).toEqual(expect.objectContaining({ parentWorktreeId: null, lineage: null })) + metadata[parentId] = { ...meta, instanceId: 'parent-instance' } + lineageById[parentId] = { + ...lineage, + worktreeId: parentId, + worktreeInstanceId: 'parent-instance', + parentWorktreeId: worktree.id, + parentWorktreeInstanceId: 'new-instance' + } + expect(resolve()).toEqual(expect.objectContaining({ parentWorktreeId: null, lineage: null })) + }) +}) diff --git a/src/main/runtime/runtime-created-worktree-terminal-target.ts b/src/main/runtime/runtime-created-worktree-terminal-target.ts new file mode 100644 index 00000000000..7f24f9261e0 --- /dev/null +++ b/src/main/runtime/runtime-created-worktree-terminal-target.ts @@ -0,0 +1,106 @@ +import type { Store } from '../persistence' +import type { Worktree } from '../../shared/worktree/types' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' +import { resolveWorktreeHostRouting } from './worktree-launch-host-repo' +import { isWorktreeMetaOwnedByRepo } from '../worktree-metadata-ownership' +import { isFolderRepo } from '../../shared/repo-kind' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { Repo } from '../../shared/repo-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { mergeWorktree } from '../ipc/worktree-metadata-merge' +import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' + +type CreationEvidenceStore = Pick & + Partial> + +function readOwnedMetadata( + store: CreationEvidenceStore, + worktreeId: string, + repo: Repo, + hostId: ExecutionHostId, + repoOwnerCount: number +): WorktreeMeta | undefined { + const qualifiedMeta = readWorktreeMetaForHost(store, worktreeId, hostId) + const meta = qualifiedMeta ?? store.getWorktreeMeta(worktreeId) + return meta && + (!meta.hostId || meta.hostId === hostId) && + (qualifiedMeta || isWorktreeMetaOwnedByRepo(repo, meta, repoOwnerCount)) + ? meta + : undefined +} + +/** Reuse same-operation creation evidence only while its persisted instance and host still agree. */ +export function resolveCreatedWorktreeTerminalTarget( + store: CreationEvidenceStore | null | undefined, + selector: string, + worktree: Worktree | undefined +): ResolvedWorktree | null { + if (!store || !worktree || selector !== `id:${worktree.id}` || !worktree.instanceId) { + return null + } + const parsed = splitWorktreeIdForFilesystem(worktree.id) + if (parsed?.repoId !== worktree.repoId || parsed.worktreePath !== worktree.path) { + return null + } + const repos = store.getRepos() + const routing = resolveWorktreeHostRouting(repos, worktree) + if (routing.kind !== 'resolved' || !routing.repo || isFolderRepo(routing.repo)) { + return null + } + const repoOwnerCount = repos.filter((repo) => repo.id === worktree.repoId).length + const meta = readOwnedMetadata(store, worktree.id, routing.repo, routing.hostId, repoOwnerCount) + if (meta?.instanceId !== worktree.instanceId) { + return null + } + const target = mergeWorktree( + worktree.repoId, + worktree, + { ...meta, hostId: routing.hostId }, + routing.repo.displayName + ) + const lineageById = store.getAllWorktreeLineage?.() ?? {} + const relatedIds = new Set([target.id]) + for (const lineage of Object.values(lineageById)) { + if (lineage.parentWorktreeId === target.id) { + relatedIds.add(lineage.worktreeId) + } + } + // Include ancestors so the existing projection can reject stale edges and cycles. + for (const id of relatedIds) { + const parentId = lineageById[id]?.parentWorktreeId + if (parentId) { + relatedIds.add(parentId) + } + } + const rows = [target] + for (const id of relatedIds) { + if (id === target.id) { + continue + } + const parsedRelated = splitWorktreeIdForFilesystem(id) + if (parsedRelated?.repoId !== target.repoId) { + continue + } + const relatedMeta = readOwnedMetadata(store, id, routing.repo, routing.hostId, repoOwnerCount) + if (!relatedMeta) { + continue + } + rows.push( + mergeWorktree( + target.repoId, + { + path: parsedRelated.worktreePath, + head: '', + branch: '', + isBare: false, + isMainWorktree: false + }, + { ...relatedMeta, hostId: routing.hostId } + ) + ) + } + const projected = projectResolvedWorktreeLineage(rows, lineageById)[0] + return projected ? { ...projected, git: worktree } : null +} diff --git a/src/main/runtime/runtime-environment-identity-verification.ts b/src/main/runtime/runtime-environment-identity-verification.ts deleted file mode 100644 index b23f0658706..00000000000 --- a/src/main/runtime/runtime-environment-identity-verification.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - getPreferredPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { verifyRemotePairingRuntimeStatus } from '../../shared/remote-pairing-verification' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' -import type { RuntimeStatus } from '../../shared/runtime-types' - -export async function verifyRuntimeEnvironmentIdentity( - environment: KnownRuntimeEnvironment, - options: { endpoint?: string; signal?: AbortSignal } = {} -) { - options.signal?.throwIfAborted() - const verifiedPairing = { - ...getPreferredPairingOffer(environment), - ...(options.endpoint ? { endpoint: options.endpoint } : {}) - } - const response = await sendRemoteRuntimeRequest( - verifiedPairing, - 'status.get', - undefined, - 15_000, - undefined, - options.signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - options.signal?.throwIfAborted() - if (!response.ok) { - throw new Error(`Runtime identity verification failed: ${response.error.message}`) - } - const status = verifyRemotePairingRuntimeStatus(response.result) - if (!status.ok) { - throw new Error(status.message) - } - const runtimeId = status.runtimeStatus.runtimeId - if ( - response._meta.runtimeId !== runtimeId || - (environment.runtimeId !== null && runtimeId !== environment.runtimeId) || - (environment.pairedDeviceId !== undefined && - status.runtimeStatus.pairedDeviceId !== undefined && - status.runtimeStatus.pairedDeviceId !== environment.pairedDeviceId) - ) { - throw new Error('The endpoint does not match this paired runtime identity.') - } - return { verifiedPairing, verifiedRuntimeId: runtimeId, runtimeStatus: status.runtimeStatus } -} diff --git a/src/main/runtime/runtime-hook-agent-row-selection.ts b/src/main/runtime/runtime-hook-agent-row-selection.ts index 67c1b698a1d..fcea4898b07 100644 --- a/src/main/runtime/runtime-hook-agent-row-selection.ts +++ b/src/main/runtime/runtime-hook-agent-row-selection.ts @@ -28,6 +28,43 @@ function isLiveObservation(row: AgentStatusIpcPayload): boolean { return row.restoredUnconfirmed !== true && row.providerSessionOnly !== true } +type HookRowJoin = { + handles: Iterable + paneKeys: Iterable + hookRows: readonly AgentStatusIpcPayload[] +} + +function isPermissionState(state: AgentStatusEntry['state']): boolean { + return mapExplicitAgentStateToRuntimeTerminalStatus(state) === 'permission' +} + +/** The freshest live hook row for a terminal, joined on any of its handles or pane keys. */ +export function selectFreshExplicitAgentStatusRow(args: HookRowJoin): AgentStatusIpcPayload | null { + const now = Date.now() + const handles = new Set(args.handles) + const paneKeys = new Set(args.paneKeys) + let best: AgentStatusIpcPayload | null = null + for (const row of args.hookRows) { + if (!(row.terminalHandle && handles.has(row.terminalHandle)) && !paneKeys.has(row.paneKey)) { + continue + } + if (!row.state || !isLiveObservation(row) || typeof row.receivedAt !== 'number') { + continue + } + if (now - (row.evidenceObservedAt ?? row.receivedAt) > AGENT_STATUS_STALE_AFTER_MS) { + continue + } + if ( + !best || + row.receivedAt > best.receivedAt || + (row.receivedAt === best.receivedAt && isPermissionState(row.state)) + ) { + best = row + } + } + return best +} + /** The freshest explicit state for a terminal, matched on its handle or its pane key. */ export function selectFreshExplicitAgentStatus(args: { handle: string @@ -38,49 +75,16 @@ export function selectFreshExplicitAgentStatus(args: { updatedAt: number stateStartedAt: number } | null { - const now = Date.now() - let bestStatus: NonNullable | null = null - let bestUpdatedAt = -1 - let bestStateStartedAt = -1 - const consider = ( - state: AgentStatusEntry['state'] | undefined, - updatedAt: number | null | undefined, - evidenceObservedAt: number | null | undefined, - restoredUnconfirmed = false, - providerSessionOnly = false, - stateStartedAt?: number | null - ): void => { - if (!state || restoredUnconfirmed || providerSessionOnly || typeof updatedAt !== 'number') { - return - } - if (now - (evidenceObservedAt ?? updatedAt) > AGENT_STATUS_STALE_AFTER_MS) { - return - } - const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) - if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { - bestStatus = status - bestUpdatedAt = updatedAt - bestStateStartedAt = typeof stateStartedAt === 'number' ? stateStartedAt : updatedAt - } - } - for (const row of args.hookRows) { - if (row.terminalHandle !== args.handle && (!args.paneKey || row.paneKey !== args.paneKey)) { - continue - } - consider( - row.state, - row.receivedAt, - row.evidenceObservedAt, - row.restoredUnconfirmed, - row.providerSessionOnly, - row.stateStartedAt - ) - } - return bestStatus + const row = selectFreshExplicitAgentStatusRow({ + handles: [args.handle], + paneKeys: args.paneKey ? [args.paneKey] : [], + hookRows: args.hookRows + }) + return row ? { - status: bestStatus, - updatedAt: bestUpdatedAt, - stateStartedAt: bestStateStartedAt + status: mapExplicitAgentStateToRuntimeTerminalStatus(row.state), + updatedAt: row.receivedAt, + stateStartedAt: typeof row.stateStartedAt === 'number' ? row.stateStartedAt : row.receivedAt } : null } diff --git a/src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts b/src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts deleted file mode 100644 index 9a950a47354..00000000000 --- a/src/main/runtime/runtime-legacy-worker-recovery-lifetime.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RuntimeLegacyWorkerTerminalRecoveryController } from './runtime-legacy-worker-terminal-recovery-controller' -import type { LegacyWorkerRecoveryPorts } from './runtime-legacy-worker-terminal-recovery-types' -import type { - LegacyWorkerTerminalRecoveryCandidate, - LegacyWorkerTerminalRecoveryPlan -} from './orchestration/orchestration-legacy-worker-terminal-recovery' - -function candidate(ptyId: string, dispatchId: string): LegacyWorkerTerminalRecoveryCandidate { - return { - dispatchId, - dispatchStatus: 'dispatched', - contractVersion: 1, - taskId: 'task', - worktreeId: 'worktree', - terminalHandle: 'handle', - paneKey: 'tab:leaf', - tabId: 'tab', - leafId: 'leaf', - processIncarnation: `${ptyId}:incarnation`, - ptyId, - incarnationId: 'incarnation' - } -} - -function setup() { - const ports = { - preparePlan: vi.fn((): LegacyWorkerTerminalRecoveryPlan => ({ - candidates: [], - ambiguousDispatchIds: [] - })), - resolveWorkspace: vi.fn(async () => { - throw new Error('unused') - }), - refreshInventory: vi.fn(async () => null), - runMutation: (_worktreeId: string, operation: () => Promise) => operation(), - getActivation: () => ({}), - hasExactPersistedSurface: () => false, - hasExactSurface: () => false, - adopt: vi.fn(async () => {}), - getRendererEpoch: () => 0, - reveal: vi.fn(async () => null), - onPtyExit: vi.fn(), - persist: vi.fn(async (): Promise> => new Set()), - rollback: vi.fn(), - reconcileMissing: () => false, - notifyResolution: vi.fn(), - canRecoverPersistentLocalPtys: () => true, - reconcileRequestedReleases: vi.fn(async (): Promise => undefined), - reconcile: vi.fn(async () => ({ - adoptedDispatchIds: [], - exitedDispatchIds: [], - deferredDispatchIds: [] - })), - updateRetry: vi.fn() - } satisfies LegacyWorkerRecoveryPorts - const controller = new RuntimeLegacyWorkerTerminalRecoveryController(ports) - return { controller, ports } -} -afterEach(() => vi.useRealTimers()) - -it('cancels both local and SSH retry timers and refuses later recovery work', async () => { - vi.useFakeTimers() - const { controller, ports } = setup() - const plan: LegacyWorkerTerminalRecoveryPlan = { - ambiguousDispatchIds: [], - candidates: [ - candidate('local-pty', 'local-dispatch'), - candidate('ssh:host@@remote-pty', 'remote-dispatch') - ] - } - const deferred = new Set(['local-dispatch', 'remote-dispatch']) - controller.updateRetry(plan, deferred, {}) - controller.updateRetry(plan, deferred, { connectionId: 'host' }) - expect(vi.getTimerCount()).toBe(2) - await controller.stop() - controller.updateRetry(plan, deferred, {}) - await vi.advanceTimersByTimeAsync(60_000) - expect(vi.getTimerCount()).toBe(0) - expect(ports.reconcile).not.toHaveBeenCalled() - await expect(controller.reconcile()).rejects.toThrow('recovery_stopped') -}) - -it('drains an active persistence pass but refuses a queued pass after shutdown', async () => { - const { controller, ports } = setup() - let finish!: (value: Set) => void - ports.persist.mockImplementationOnce( - () => - new Promise>((resolve) => { - finish = resolve - }) - ) - const first = controller.reconcile() - await vi.waitFor(() => expect(ports.persist).toHaveBeenCalledOnce()) - const queued = expect(controller.reconcile()).rejects.toThrow('recovery_stopped') - const settled = vi.fn() - const stopping = controller.stop().then(settled) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - finish(new Set()) - await Promise.all([first, queued, stopping]) - expect(ports.preparePlan).toHaveBeenCalledOnce() - expect(ports.persist).toHaveBeenCalledOnce() -}) - -it('holds shutdown until requested-release reconciliation finishes', async () => { - const { controller, ports } = setup() - let finish!: () => void - ports.reconcileRequestedReleases.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = () => resolve(undefined) - }) - ) - const recovery = controller.reconcile() - await vi.waitFor(() => expect(ports.reconcileRequestedReleases).toHaveBeenCalledOnce()) - const settled = vi.fn() - const stopping = controller.stop().then(settled) - await Promise.resolve() - expect(settled).not.toHaveBeenCalled() - finish() - await Promise.all([recovery, stopping]) - expect(settled).toHaveBeenCalledOnce() -}) diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts index 44a535c8127..59e29a1858d 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts @@ -32,31 +32,12 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { private readonly retries = new Map() private readonly receiptEpochByPane = new Map() private readonly recoveredPtys = new Set() - private readonly backgroundWork = new Set>() - private stopped = false constructor(private readonly ports: LegacyWorkerRecoveryPorts) {} - /** Cancels retries and refuses new passes; resolves once running and released work drained. */ - async stop(): Promise { - this.stopped = true - this.cancelAllRetries() - await this.queue - await Promise.all(this.backgroundWork) - } - - /** Work a pass starts without awaiting; shutdown still waits for it. */ - trackBackgroundWork(work: Promise): void { - this.backgroundWork.add(work) - void work.finally(() => this.backgroundWork.delete(work)) - } - reconcile( options: LegacyWorkerRecoveryOptions = {} ): Promise { - if (this.stopped) { - return Promise.reject(new Error('worker_terminal_recovery_stopped')) - } let resolveResult!: (result: LegacyWorkerTerminalRecoveryResult) => void let rejectResult!: (error: unknown) => void const result = new Promise((resolve, reject) => { @@ -65,9 +46,6 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { }) const run = this.queue.then(async () => { try { - if (this.stopped) { - throw new Error('worker_terminal_recovery_stopped') - } resolveResult(await runLegacyWorkerTerminalRecovery(this, this.ports, options)) } catch (error) { rejectResult(error) @@ -99,9 +77,6 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { deferredDispatchIds: ReadonlySet, options: LegacyWorkerRecoveryOptions ): void { - if (this.stopped) { - return - } const scopeKey = options.connectionId ? `ssh:${options.connectionId}` : 'local' const hasDeferredWorker = plan.candidates.some((candidate) => { const sshPty = parseAppSshPtyId(candidate.ptyId) @@ -150,7 +125,7 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { } private armRetry(scopeKey: string, retry: RecoveryRetry): void { - if (this.stopped || retry.timer) { + if (retry.timer) { return } const delayMs = Math.min(1_000 * 2 ** retry.attempt, 30_000) diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts index adbb89512d2..6bbe3b2ed2f 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts @@ -110,10 +110,8 @@ export async function runLegacyWorkerTerminalRecovery( } ports.updateRetry(plan, deferredDispatchIds, options) // Why: releases may only finish after the owning provider's terminals are rediscovered. - controller.trackBackgroundWork( - ports.reconcileRequestedReleases().catch((error) => { - console.warn('[orchestration] worker terminal release reconciliation failed', { error }) - }) - ) + void ports.reconcileRequestedReleases().catch((error) => { + console.warn('[orchestration] worker terminal release reconciliation failed', { error }) + }) return result } diff --git a/src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts b/src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts deleted file mode 100644 index 80f30b64342..00000000000 --- a/src/main/runtime/runtime-mobile-session-incarnation-projection.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { projectRuntimeMobileSessionTabs } from './runtime-mobile-session-projection' -import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' - -it.each(['pty', 'leaf', 'unknown', 'disconnected'] as const)( - 'publishes only the handle-owning incarnation for %s', - (kind) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: projection reads only ptyId, connected and incarnationId from the record. - const pty = { - ptyId: 'live-pty', - connected: kind !== 'disconnected', - incarnationId: kind === 'unknown' ? null : 'live-incarnation' - } as RuntimePtyWorktreeRecord - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every host member this projection path calls. - const host = { - tabs: new Map(), - leaves: new Map(kind === 'leaf' ? [['leaf', { ptyId: pty.ptyId, connected: true }]] : []), - ptysById: new Map([[pty.ptyId, pty]]), - getLiveBrowserTabs: () => new Map(), - getProviderSessionRows: () => [], - getProviderSessionSnapshot: () => [], - getLeafKey: () => 'leaf', - findPty: () => pty, - getRetainedStatus: () => null, - getTrackedTitle: () => null, - issuePtyHandle: vi.fn(() => 'handle'), - recordPty: vi.fn(() => pty), - buildPtyStatus: () => ({}), - sanitizeGroups: () => [], - pruneGroupLayout: () => null, - collectTabIds: () => new Set() - } as unknown as RuntimeMobileSessionProjectionHost - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: one terminal tab is the only snapshot field this projection path reads. - const snapshot = { - worktree: 'workspace', - publicationEpoch: 'headless:epoch', - tabs: [ - { - type: 'terminal', - id: 'tab::leaf', - parentTabId: 'tab', - leafId: 'leaf', - ptyId: 'stale-pty', - incarnationId: 'stale-incarnation', - title: 'Shell', - isActive: true - } - ] - } as RuntimeMobileSessionTabsSnapshot - - const tab = projectRuntimeMobileSessionTabs(snapshot, host).tabs[0] - - if (kind === 'unknown' || kind === 'disconnected') { - expect(tab).not.toHaveProperty('incarnationId') - } else { - expect(tab).toHaveProperty('incarnationId', 'live-incarnation') - } - expect(tab).toHaveProperty('status', kind === 'disconnected' ? 'pending-handle' : 'ready') - if (kind !== 'disconnected') { - expect(host.issuePtyHandle).toHaveBeenCalledWith(pty) - } - } -) diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index 969a2946c9a..db1ef0619ca 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -247,14 +247,17 @@ export function projectRuntimeMobileSessionTabs( } : null // Why: web/mobile clients hold handles across renderer graph syncs; leaf handles are epoch-bound but PTY handles stay streamable. - const terminalPty = liveLeafPtyId - ? host.recordPty(liveLeafPtyId, snapshot.worktree, { - tabId: tab.parentTabId, - paneKey, - connected: true - }) + const terminalHandle = liveLeafPtyId + ? host.issuePtyHandle( + host.recordPty(liveLeafPtyId, snapshot.worktree, { + tabId: tab.parentTabId, + paneKey, + connected: true + }) + ) : livePty - const terminalHandle = terminalPty ? host.issuePtyHandle(terminalPty) : null + ? host.issuePtyHandle(livePty) + : null const projectedAgentStatus = agentStatus ?? host.buildPtyStatus( @@ -287,8 +290,6 @@ export function projectRuntimeMobileSessionTabs( leafId: tab.leafId, title, ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), - // Bind identity to the handle's live owner, never a stale persisted surface. - ...(terminalPty?.incarnationId ? { incarnationId: terminalPty.incarnationId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), ...(launchAgent ? { launchAgent } : {}), ...clientAgentStatus, diff --git a/src/main/runtime/runtime-rpc-required-ws-port.test.ts b/src/main/runtime/runtime-rpc-required-ws-port.test.ts deleted file mode 100644 index 5a264fcc0c0..00000000000 --- a/src/main/runtime/runtime-rpc-required-ws-port.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { mkdtempSync, readdirSync } from 'node:fs' -import { createServer, type Server } from 'node:net' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from './orca-runtime' -import { OrcaRuntimeRpcServer } from './runtime-rpc' -import { readWsFallbackPort } from './rpc/ws-fallback-port-store' - -vi.mock('../git/worktree', () => ({ - listWorktrees: vi.fn().mockResolvedValue([]), - listWorktreesStrict: vi.fn().mockResolvedValue([]) -})) - -const holders: Server[] = [] - -afterEach(async () => { - await Promise.all( - holders.splice(0).map((holder) => new Promise((resolve) => holder.close(() => resolve()))) - ) -}) - -async function occupyLoopbackPort(): Promise { - const holder = createServer() - holders.push(holder) - await new Promise((resolve) => holder.listen(0, '127.0.0.1', resolve)) - const address = holder.address() - if (!address || typeof address === 'string') { - throw new Error('holder did not bind a TCP port') - } - return address.port -} - -function boundWsPort(server: OrcaRuntimeRpcServer): number | null { - const endpoint = server.getWebSocketEndpoint() - return endpoint ? Number(new URL(endpoint).port) : null -} - -describe('OrcaRuntimeRpcServer required WebSocket port', () => { - it('fails startup instead of falling back when the required port is taken', async () => { - const port = await occupyLoopbackPort() - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) - const server = new OrcaRuntimeRpcServer({ - runtime: new OrcaRuntimeService(), - userDataPath, - enableWebSocket: true, - wsPort: port, - preferPinnedWsPort: true, - requirePinnedWsPort: true - }) - - try { - await expect(server.start()).rejects.toMatchObject({ code: 'EADDRINUSE' }) - expect(boundWsPort(server)).toBeNull() - expect(readWsFallbackPort(userDataPath)).toBeUndefined() - // Why: the failed start must also release the Unix socket it opened first, or the endpoint leaks. - expect(readdirSync(userDataPath).filter((name) => name.endsWith('.sock'))).toEqual([]) - } finally { - await server.stop() - } - }) - - it('still falls back for a pinned but not required port', async () => { - const port = await occupyLoopbackPort() - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) - const server = new OrcaRuntimeRpcServer({ - runtime: new OrcaRuntimeService(), - userDataPath, - enableWebSocket: true, - wsPort: port, - preferPinnedWsPort: true - }) - - await server.start() - try { - const resolvedPort = boundWsPort(server) - expect(resolvedPort).not.toBe(port) - expect(readWsFallbackPort(userDataPath)).toBe(resolvedPort) - } finally { - await server.stop() - } - }) -}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 4c595b3bbab..9cfe31251b6 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -90,9 +90,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { port: this.wsPort, preferPinnedPort: this.preferPinnedWsPort, // Why: stable fallback port across restarts keeps paired devices' endpoints valid (STA-1511); wsPort 0 = random (E2E). - ...(this.wsPort !== 0 && !this.requirePinnedWsPort - ? { fallbackPort: readWsFallbackPort(this.userDataPath) } - : {}) + ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) }) if (this.wsPort !== 0 && transport.resolvedPort !== this.wsPort) { writeWsFallbackPort(this.userDataPath, transport.resolvedPort) @@ -100,10 +98,6 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { activeTransports.push(transport) transportsMeta.push({ kind: 'websocket', endpoint }) } catch (error) { - if (this.requirePinnedWsPort) { - await socketTransport.stop().catch(() => {}) - throw error - } // Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only. console.error('[runtime] Failed to start WebSocket transport:', error) this.mobileSocketWiring = null @@ -180,7 +174,6 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { host: options.host, port: options.port, staticRoot: this.webClientRoot, - ...(this.requirePinnedWsPort ? { strictPort: true } : {}), ...(options.fallbackPort !== undefined ? { fallbackPort: options.fallbackPort } : {}), ...(options.preferPinnedPort ? { preferPinnedPort: true } : {}) }) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts index da517dd9d92..4923dec1dec 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts @@ -36,8 +36,6 @@ export type OrcaRuntimeRpcServerOptions = { wsPort?: number // Why: true when the caller pinned a port (`orca serve --port`) so bind order prefers it over a stale STA-1511 fallback (#8535). preferPinnedWsPort?: boolean - // Why: a managed SSH tunnel forwards exactly this port, so a fallback bind must fail startup instead. - requirePinnedWsPort?: boolean // 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 diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index 20ddbf70e38..e7f56ceed13 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -38,7 +38,6 @@ export class RuntimeRpcState { protected readonly enableWebSocket: boolean protected readonly wsPort: number protected readonly preferPinnedWsPort: boolean - protected readonly requirePinnedWsPort: boolean protected readonly exposeNetworkByDefault: boolean protected readonly pinnedBindHost: string | null protected readonly webClientRoot: string | undefined @@ -101,7 +100,6 @@ export class RuntimeRpcState { enableWebSocket = false, wsPort = DEFAULT_WS_PORT, preferPinnedWsPort = false, - requirePinnedWsPort = false, exposeNetworkByDefault = false, pinnedBindHost, webClientRoot, @@ -118,7 +116,6 @@ export class RuntimeRpcState { this.enableWebSocket = enableWebSocket this.wsPort = wsPort this.preferPinnedWsPort = preferPinnedWsPort - this.requirePinnedWsPort = requirePinnedWsPort this.exposeNetworkByDefault = exposeNetworkByDefault this.pinnedBindHost = pinnedBindHost ?? null this.webClientRoot = webClientRoot diff --git a/src/main/runtime/runtime-terminal-pane-identity.ts b/src/main/runtime/runtime-terminal-pane-identity.ts index 90909f6ef35..225ac564262 100644 --- a/src/main/runtime/runtime-terminal-pane-identity.ts +++ b/src/main/runtime/runtime-terminal-pane-identity.ts @@ -1,5 +1,24 @@ import { AgentLaunchPaneAlreadyLiveError } from '../../shared/agent-launch-pane-already-live' -import { parsePaneKey } from '../../shared/stable-pane-id' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { isValidHostTerminalTabId } from '../../shared/terminal-tab-id' +import { randomUUID } from 'node:crypto' + +export function allocateTerminalPaneIdentity(opts: { tabId?: string; leafId?: string }): { + tabId: string + leafId: string + paneKey: string +} { + const hintedTabId = opts.tabId?.trim() + const hintedLeafId = opts.leafId + const canAdopt = + hintedTabId !== undefined && + isValidHostTerminalTabId(hintedTabId) && + hintedLeafId !== undefined && + isTerminalLeafId(hintedLeafId) + const tabId = canAdopt ? hintedTabId : randomUUID() + const leafId = canAdopt ? hintedLeafId : randomUUID() + return { tabId, leafId, paneKey: makePaneKey(tabId, leafId) } +} /** A caller-minted `tabId:leafId` as the pair `createTerminal` adopts; an unparsable key yields * nothing, so the runtime mints its own and the reported `paneKey` shows the caller it lost. */ diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts index e1982ebc968..85460b9fc27 100644 --- a/src/main/runtime/terminal-run-facts.test.ts +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -39,14 +39,49 @@ describe('terminal run facts', () => { expect(facts.read('pty-1', 'inc-1').freshSpawn).toBe(false) }) - it('starts clean when a commit carries no incarnation to tell it from a new process', () => { + it('starts clean when a new process commits without an incarnation', () => { const facts = new TerminalRunFactsRegister() facts.recordSpawnCommit({ id: 'pty-1' }) facts.recordInput('pty-1', 'driving', 'ls\r', 100) - facts.recordSpawnCommit({ id: 'pty-1', isReattach: true }) + facts.recordSpawnCommit({ id: 'pty-1' }) - expect(facts.read('pty-1', null)).toEqual({ freshSpawn: false, firstUserInputAt: null }) + expect(facts.read('pty-1', null)).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(facts.readLastInputAt('pty-1')).toBeNull() + }) + + it.each([ + ['a reattach', { isReattach: true }], + ['an adoption', { agentSessionEnsure: { disposition: 'adopted' } }] + ])('keeps the input of the running process through %s without an incarnation', (_l, commit) => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1' }) + facts.recordInput('pty-1', 'driving', 'next prompt\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', ...commit }) + + expect(facts.read('pty-1', null)).toEqual({ freshSpawn: false, firstUserInputAt: 100 }) + expect(facts.readLastInputAt('pty-1')).toBe(100) + }) + + it('keeps input recorded before main adopted the process with its first commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pty-1', 'driving', 'next prompt\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + + expect(facts.readLastInputAt('pty-1')).toBe(100) + }) + + it('starts clean when a reattach names a different process than the one recorded', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-2', isReattach: true }) + + expect(facts.read('pty-1', 'inc-2')).toEqual({ freshSpawn: false, firstUserInputAt: null }) + expect(facts.readLastInputAt('pty-1')).toBeNull() }) it.each([ @@ -62,4 +97,29 @@ describe('terminal run facts', () => { expect(facts.read('pty-1', 'inc-1').firstUserInputAt).toBeNull() }) + + it('records the last input, a launch write included, but no terminal reply', () => { + const facts = new TerminalRunFactsRegister() + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1' }) + facts.recordInput('pty-1', 'driving', 'ls\r', 100) + facts.recordInput('pty-1', 'launch', 'next task\r', 200) + facts.recordInput('pty-1', 'query-reply', 'answer', 300) + facts.recordInput('pty-1', 'driving', '\x1b[I', 400) + + expect(facts.read('pty-1', 'inc-1').firstUserInputAt).toBe(100) + expect(facts.readLastInputAt('pty-1')).toBe(200) + + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-1', isReattach: true }) + expect(facts.readLastInputAt('pty-1')).toBe(200) + facts.recordSpawnCommit({ id: 'pty-1', incarnationId: 'inc-2' }) + expect(facts.readLastInputAt('pty-1')).toBeNull() + }) + + it('records the last input on a PTY main adopted without a spawn commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pty-1', 'driving', 'next task\r', 100) + + expect(facts.readLastInputAt('pty-1')).toBe(100) + expect(facts.read('pty-1', null).firstUserInputAt).toBeNull() + }) }) diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts index 1fb3e411b6c..b20f64985e4 100644 --- a/src/main/runtime/terminal-run-facts.ts +++ b/src/main/runtime/terminal-run-facts.ts @@ -40,22 +40,27 @@ type TerminalRunRecord = { /** Main's per-process facts about one PTY run, keyed by the incarnation they describe. */ export class TerminalRunFactsRegister { private readonly runsByPtyId = new Map() + // Why apart from the run record: input must count on a PTY main adopted without a commit. + private readonly lastInputAtByPtyId = new Map() - /** Once per process: a re-registration of the same incarnation keeps its facts. Without an - * incarnation a commit cannot be told from a new process, so it starts clean. */ + /** Once per process: a re-registration of the same incarnation keeps its facts, and so does a + * reattach or adoption of the running process unless its incarnation shows another process. */ recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { const incarnationId = commit.incarnationId ?? null - if ( - incarnationId !== null && - this.runsByPtyId.get(commit.id)?.incarnationId === incarnationId - ) { + const previous = this.runsByPtyId.get(commit.id) + if (incarnationId !== null && previous?.incarnationId === incarnationId) { return } const origin = spawnCommitBindingOrigin(commit, expectedSourceBinding) + const sameProcess = + origin === 'reattach' && (incarnationId === null || !previous?.incarnationId) + if (!sameProcess) { + this.lastInputAtByPtyId.delete(commit.id) + } this.runsByPtyId.set(commit.id, { incarnationId, spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, - firstUserInputAt: null + firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null }) } @@ -63,15 +68,22 @@ export class TerminalRunFactsRegister { * such as `exit` can end the process before the write returns. The payload check backs up a * writer that labels a reply or focus report as driving. */ recordInput(ptyId: string, inputKind: TerminalInputKind, data: string, now = Date.now()): void { - if (inputKind !== 'driving') { + if (inputKind === 'query-reply' || isUntypedTerminalInput(data)) { return } + this.lastInputAtByPtyId.set(ptyId, now) const run = this.runsByPtyId.get(ptyId) - if (run && run.firstUserInputAt === null && !isUntypedTerminalInput(data)) { - run.firstUserInputAt = now + if (run && inputKind === 'driving') { + run.firstUserInputAt ??= now } } + /** When input other than a terminal reply last reached the PTY's current process, launch writes + * included; null if none has. */ + readLastInputAt(ptyId: string): number | null { + return this.lastInputAtByPtyId.get(ptyId) ?? null + } + /** A run main never saw committed reads as not fresh, which keeps today's close-on-exit. */ read(ptyId: string, incarnationId: string | null | undefined): TerminalRunFacts { const run = this.runsByPtyId.get(ptyId) @@ -86,5 +98,6 @@ export class TerminalRunFactsRegister { delete(ptyId: string): void { this.runsByPtyId.delete(ptyId) + this.lastInputAtByPtyId.delete(ptyId) } } diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index b2616b14436..fc388781c7f 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -23,6 +23,7 @@ import { readsTrustedScreen, type AgentStateVerdict } from './agent-state-rules/agent-state-rules-engine' +import { evaluateHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' /** * Ranking the evidence that a `tui-idle` wait may settle on. @@ -33,7 +34,10 @@ import { * stale spinner (#1437) — so a busy Codex/Devin pane is routinely titled idle, and * accepting it satisfied a wait in ~0s mid-turn (#6011). * - * 0. BLOCKED — the tail shows a prompt waiting on the user. + * 0. HOOKS — for an agent whose hooks are authoritative (agent-state-rules/ profile), a fresh + * hook row for the main agent's turn: done, working, or a permission wait, with the tail's + * blocked text judged by the permission arbiter against it (tui-idle-hook-lane.ts). + * 0b. BLOCKED — otherwise, the tail shows a prompt waiting on the user. * 1. STRONG READY — the agent states it is ready: an explicit idle marker in its own * title, or a known ready-prompt body. * 1b. QUIET READY SCREEN — Muse titles no rest signal, and agents whose rules @@ -212,6 +216,8 @@ export type TuiIdleEvaluationInput = { readAgentRuleVerdict: () => AgentStateVerdict | null agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus + /** Tier 0: the hook server's fresh row for the pane, read only for an authoritative agent. */ + readHookTurn?: () => TuiIdleHookTurn | null quiescenceMs: number /** Waiting for a just-launched agent to open its composer, where a name-only title proves * nothing until the stream goes quiet. */ @@ -262,6 +268,10 @@ export function hasQuietReadyScreen( /** The one place the tiers are combined; every settle site branches only on the verdict. */ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { + const hookVerdict = input.readHookTurn ? evaluateHookTurn(input.agent, input.readHookTurn) : null + if (hookVerdict) { + return hookVerdict + } const blockedReason = input.readTailBlockedReason() if (blockedReason) { return { kind: 'blocked', reason: blockedReason } @@ -355,6 +365,8 @@ export type TuiIdleEvidenceSource = { getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null getPaneAgent(ptyId: string | null | undefined): TuiAgent | null getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus + /** The hook server's fresh row for the pane's main agent; absent on a host with no store. */ + getHookTurn?(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null readScreenLines(ptyId: string | null | undefined): readonly string[] | null /** The painted rows on the PTY's own grid, which only agents whose rules read the trusted * screen use. Absent, they have no trustworthy screen. */ @@ -389,6 +401,16 @@ function readAgentRuleVerdict( }) } +function hookTurnReader( + source: TuiIdleEvidenceSource, + agent: TuiAgent | null, + ptyId: string | null | undefined +): (() => TuiIdleHookTurn | null) | undefined { + return source.getHookTurn && agent && ptyId + ? () => source.getHookTurn?.(ptyId, agent) ?? null + : undefined +} + function lazyWaitText(readWaitText: () => string): () => string { let waitText: string | null = null return () => (waitText ??= readWaitText()) @@ -412,6 +434,7 @@ export function leafTuiIdleEvidence( readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText), agent, firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), + readHookTurn: hookTurnReader(source, agent, leaf.ptyId), quiescenceMs: source.quiescenceMs } } @@ -434,6 +457,7 @@ export function ptyTuiIdleEvidence( readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), agent, firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), + readHookTurn: hookTurnReader(source, agent, pty.ptyId), quiescenceMs: source.quiescenceMs } } diff --git a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts new file mode 100644 index 00000000000..312bd3b8e67 --- /dev/null +++ b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { TuiAgent } from '../../shared/tui-agent' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import type { OrcaRuntimeService } from './orca-runtime' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// The harness pane's identity (agent-transcript-pane-test-harness.ts). +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const OTHER_PANE_KEY = makePaneKey('tab-9', '99999999-9999-4999-8999-999999999999') +// A Pi turn in progress: no ready sign the other lanes could settle on. +const BUSY_SCREEN = '⠋ Working...\r\n' +// A Codex turn in progress, which Codex's screen rules read as busy. +const CODEX_BUSY_SCREEN = '• Working (4s • esc to interrupt)\r\n' +// OpenCode 1.18's permission dialog, as the line tail keeps it after the prompt is answered. +const OPENCODE_PERMISSION_DIALOG = [ + '△ Permission required', + '# Shell command', + '$ echo hi', + ' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm', + '' +].join('\r\n') +// Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. +const WAIT_MS = 150 + +function row(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE_KEY, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'pi', + receivedAt: now, + stateStartedAt: now, + ...overrides + } +} + +async function waitOutcome(options: { + rows: (handle: string) => AgentStatusIpcPayload[] + launchAgent?: TuiAgent + data?: string + afterCreate?: (runtime: OrcaRuntimeService, handle: string) => unknown +}): Promise { + let handle = '' + const pane = await createTranscriptPane( + { + paneTitle: 'Terminal', + foregroundProcess: options.launchAgent ?? 'pi', + data: options.data ?? BUSY_SCREEN, + launchAgent: options.launchAgent ?? 'pi' + }, + { getAgentStatusSnapshot: () => options.rows(handle) } + ) + handle = pane.handle + await options.afterCreate?.(pane.runtime, pane.handle) + try { + const result = await pane.runtime.waitForTerminal(pane.handle, { + condition: 'tui-idle', + timeoutMs: WAIT_MS + }) + return result.blockedReason ? `blocked:${result.blockedReason}` : 'ready' + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('tui-idle hook lane through the runtime', () => { + it('settles a mid-turn screen on the hook row alone, with no ready title (headless serve)', async () => { + expect(await waitOutcome({ rows: () => [row()] })).toBe('ready') + }) + + it('without a row, the same pane does not settle', async () => { + expect(await waitOutcome({ rows: () => [] })).toBe('timeout') + }) + + it('joins a row on the terminal handle when its pane key is not the pane', async () => { + expect( + await waitOutcome({ + rows: (handle) => [row({ paneKey: OTHER_PANE_KEY, terminalHandle: handle })] + }) + ).toBe('ready') + }) + + it('falls back to the other lanes for a row no pane key or handle joins', async () => { + expect(await waitOutcome({ rows: () => [row({ paneKey: OTHER_PANE_KEY })] })).toBe('timeout') + }) + + it('ignores the row the PTY id held before a respawn', async () => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + expect(await waitOutcome({ rows })).toBe('ready') + expect( + await waitOutcome({ + rows, + afterCreate: (runtime) => + runtime.synchronizePtyOutputSequenceFromProvider(TRANSCRIPT_PANE_PTY_ID, { + value: 0, + generation: 'reset' + }) + }) + ).toBe('timeout') + }) + + // Both write funnels record input (terminal-run-facts-input.test.ts), the user's keys included: + // typing `pi` to restart the agent in the same shell must not read the old process's done. + it.each([ + ['a prompt Orca sent', 'next task\r'], + ['the user restarting the agent in the same shell', 'pi\r'] + ])('reads no done from before %s', async (_label, input) => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + const typeAt = (at: number) => (runtime: OrcaRuntimeService) => { + runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', input, at) + } + expect(await waitOutcome({ rows, afterCreate: typeAt(before - 1) })).toBe('ready') + expect(await waitOutcome({ rows, afterCreate: typeAt(before + 1) })).toBe('timeout') + }) + + it('reads no done from before a prompt sent just ahead of an adoption of the running agent', async () => { + const before = Date.now() - 1000 + const rows = (): AgentStatusIpcPayload[] => [ + row({ receivedAt: before, stateStartedAt: before }) + ] + expect( + await waitOutcome({ + rows, + afterCreate: (runtime) => { + runtime.terminalRunFacts.recordInput( + TRANSCRIPT_PANE_PTY_ID, + 'driving', + 'next\r', + before + 1 + ) + runtime.noteTerminalSpawnCommit({ + id: TRANSCRIPT_PANE_PTY_ID, + agentSessionEnsure: { disposition: 'adopted' } + }) + } + }) + ).toBe('timeout') + }) + + // Pi brackets each message in OSC 133 zones itself, so a command marker is no process boundary. + it('keeps the row while the agent paints its own shell-integration markers', async () => { + expect( + await waitOutcome({ + rows: () => [row({ receivedAt: Date.now() - 1000 })], + data: `\x1b]133;A\x07OK\x1b]133;C\x07${BUSY_SCREEN}` + }) + ).toBe('ready') + }) + + // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so its row can stay + // working; its title and screen rules decide instead. + it('leaves Codex to its screen rules, whatever its hook row says', async () => { + const codex = { launchAgent: 'codex' as const, data: CODEX_BUSY_SCREEN } + expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe( + 'timeout' + ) + }) + + it("settles past a denied prompt's dialog text once the hook says the turn ended", async () => { + const options = { launchAgent: 'opencode' as const, data: OPENCODE_PERMISSION_DIALOG } + expect(await waitOutcome({ ...options, rows: () => [] })).toBe( + 'blocked:agent-interactive-prompt' + ) + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) + ).toBe('blocked:agent-interactive-prompt') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + }) + ).toBe('ready') + }) +}) diff --git a/src/main/runtime/tui-idle-hook-lane.test.ts b/src/main/runtime/tui-idle-hook-lane.test.ts new file mode 100644 index 00000000000..87683f266cf --- /dev/null +++ b/src/main/runtime/tui-idle-hook-lane.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusIpcPayload +} from '../../shared/agent-status-types' +import { hooksAreAuthoritative } from './agent-state-rules/agent-state-rules-engine' +import { parseAgentStateRuleFiles } from './agent-state-rules/agent-state-rules-catalog' +import { hookLeadTurnState, readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' +import { + evaluateTuiIdle, + type TuiIdleEvaluationInput, + type TuiIdleEvidenceRecord +} from './tui-idle-evidence' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' +const OTHER_PANE_KEY = 'tab-2:22222222-2222-4222-8222-222222222222' +const HANDLE = 'term_hook_lane' +const QUIESCENCE_MS = 3000 +const DONE: TuiIdleHookTurn = { state: 'done', blockedReason: null } +const WORKING: TuiIdleHookTurn = { state: 'working', blockedReason: null } + +function row(overrides: Partial = {}): AgentStatusIpcPayload { + const now = Date.now() + return { + paneKey: PANE_KEY, + connectionId: null, + state: 'done', + prompt: '', + agentType: 'pi', + receivedAt: now, + stateStartedAt: now, + ...overrides + } +} + +describe('hookLeadTurnState', () => { + it('reads the main agent, so a subagent finishing or running does not decide the lead turn', () => { + // A child still runs after the lead ended: the combined row works, the lead is done. + expect( + hookLeadTurnState({ state: 'working', mainAgent: { state: 'done', stateStartedAt: 1 } }) + ).toBe('done') + // A child's Stop while the lead still works leaves the lead working. + expect( + hookLeadTurnState({ state: 'done', mainAgent: { state: 'working', stateStartedAt: 1 } }) + ).toBe('working') + }) + + it('reads a cancelled turn as ended', () => { + expect( + hookLeadTurnState({ + state: 'done', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1 } + }) + ).toBe('done') + }) + + it("blocks on any agent's permission wait, a child's included", () => { + expect(hookLeadTurnState({ state: 'waiting' })).toBe('permission') + expect(hookLeadTurnState({ state: 'blocked' })).toBe('permission') + expect( + hookLeadTurnState({ state: 'waiting', mainAgent: { state: 'working', stateStartedAt: 1 } }) + ).toBe('permission') + }) + + it('falls back to the row state for agents that publish no main agent', () => { + expect(hookLeadTurnState({ state: 'done' })).toBe('done') + expect(hookLeadTurnState({ state: 'working' })).toBe('working') + }) + + it('leaves a session start to the startup rules', () => { + expect(hookLeadTurnState({ state: 'done', sessionBoundary: true })).toBeNull() + }) +}) + +describe('readTuiIdleHookTurn', () => { + const base = { + agent: 'pi' as const, + handles: [HANDLE], + paneKeys: [PANE_KEY], + resolveBlockedText: () => null + } + + it('reads a fresh row joined on the pane key', () => { + expect(readTuiIdleHookTurn({ ...base, hookRows: [row()] })).toEqual(DONE) + }) + + it('joins a row on the terminal handle when the pane key differs', () => { + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ paneKey: OTHER_PANE_KEY, terminalHandle: HANDLE, state: 'working' })] + }) + ).toEqual(WORKING) + }) + + it('has no answer for a pane no row joins', () => { + expect( + readTuiIdleHookTurn({ + ...base, + handles: [], + paneKeys: [], + hookRows: [row({ terminalHandle: HANDLE })] + }) + ).toBeNull() + expect( + readTuiIdleHookTurn({ ...base, hookRows: [row({ paneKey: OTHER_PANE_KEY })] }) + ).toBeNull() + }) + + it('refuses stale, restored and identity-only rows', () => { + const staleAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + for (const stale of [ + row({ receivedAt: staleAt }), + row({ evidenceObservedAt: staleAt }), + row({ restoredUnconfirmed: true }), + row({ providerSessionOnly: true }) + ]) { + expect(readTuiIdleHookTurn({ ...base, hookRows: [stale] })).toBeNull() + } + }) + + it("refuses a row another agent wrote, and one from the pane's previous process", () => { + expect(readTuiIdleHookTurn({ ...base, hookRows: [row({ agentType: 'claude' })] })).toBeNull() + const receivedAt = Date.now() - 1000 + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ receivedAt })], + respawnedAt: receivedAt + 1 + }) + ).toBeNull() + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ receivedAt })], + respawnedAt: receivedAt + }) + ).toEqual(DONE) + }) + + it('reads no done from before the latest input, whose turn may not have reported yet', () => { + const receivedAt = Date.now() - 1000 + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ receivedAt })], + lastInputAt: receivedAt + 1 + }) + ).toBeNull() + expect( + readTuiIdleHookTurn({ ...base, hookRows: [row({ receivedAt })], lastInputAt: receivedAt }) + ).toEqual(DONE) + // A working row keeps the pane busy whatever its age against the input. + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ receivedAt, state: 'working' })], + lastInputAt: receivedAt + 1 + }) + ).toEqual(WORKING) + }) + + it('takes the newest joined row', () => { + const now = Date.now() + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [ + row({ state: 'done', receivedAt: now }), + row({ state: 'working', receivedAt: now - 10, terminalHandle: HANDLE }) + ] + }) + ).toEqual(DONE) + }) + + it("hands the permission arbiter the lead turn as the pane's explicit status", () => { + const resolveBlockedText = vi.fn(() => 'agent-approval-prompt' as const) + const waiting = row({ state: 'waiting' }) + expect(readTuiIdleHookTurn({ ...base, resolveBlockedText, hookRows: [waiting] })).toEqual({ + state: 'permission', + blockedReason: 'agent-approval-prompt' + }) + expect(resolveBlockedText).toHaveBeenCalledWith('permission', waiting) + const childRunning = row({ state: 'working', mainAgent: { state: 'done', stateStartedAt: 1 } }) + readTuiIdleHookTurn({ ...base, resolveBlockedText, hookRows: [childRunning] }) + expect(resolveBlockedText).toHaveBeenLastCalledWith('done', childRunning) + }) +}) + +describe('profile.hooks', () => { + it('marks the agents whose hooks report every turn end as authoritative', () => { + for (const agent of ['opencode', 'opencode2', 'pi', 'omp'] as const) { + expect(hooksAreAuthoritative(agent)).toBe(true) + } + for (const agent of ['codex', 'claude', 'cursor', 'gemini', 'grok', null] as const) { + expect(hooksAreAuthoritative(agent)).toBe(false) + } + }) + + it('rejects an authority outside the closed list', () => { + expect(() => + parseAgentStateRuleFiles([ + { id: 'pi', engineVersion: 1, profile: { hooks: 'partial' }, anchors: [], rules: [] } + ]) + ).toThrow(/hooks/) + }) +}) + +function record(overrides: Partial = {}): TuiIdleEvidenceRecord { + return { lastAgentStatus: null, lastOutputAt: Date.now(), lastOscTitle: null, ...overrides } +} + +function input(overrides: Partial = {}): TuiIdleEvaluationInput { + return { + record: record(), + readTailBlockedReason: () => null, + readPositiveBodyEvidence: () => false, + readQuietReadyBodyEvidence: () => false, + readAgentRuleVerdict: () => null, + agent: 'pi', + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS, + ...overrides + } +} + +describe('evaluateTuiIdle hook lane', () => { + it('settles at once on a done turn, with no title and a streaming pane (headless)', () => { + expect(evaluateTuiIdle(input({ readHookTurn: () => DONE }))).toEqual({ + kind: 'ready-strong' + }) + }) + + it('holds a working turn over ready text and an idle title', () => { + expect( + evaluateTuiIdle( + input({ + record: record({ lastAgentStatus: 'idle', lastOscTitle: 'Pi ready' }), + readPositiveBodyEvidence: () => true, + readHookTurn: () => WORKING + }) + ) + ).toEqual({ kind: 'working' }) + }) + + it('blocks with the arbiter reason, and leaves an unconfirmed wait to the screen read', () => { + expect( + evaluateTuiIdle( + input({ + readHookTurn: () => ({ state: 'permission', blockedReason: 'agent-approval-prompt' }) + }) + ) + ).toEqual({ kind: 'blocked', reason: 'agent-approval-prompt' }) + expect( + evaluateTuiIdle( + input({ + readPositiveBodyEvidence: () => true, + readHookTurn: () => ({ state: 'permission', blockedReason: null }) + }) + ) + ).toEqual({ kind: 'pending', quietForeground: 'closed' }) + }) + + it('lets the arbiter, not the raw tail, judge blocked text once a fresh row exists', () => { + // A denied prompt's dialog text lingers in the tail after the hook says the turn ended. + expect( + evaluateTuiIdle( + input({ readTailBlockedReason: () => 'agent-interactive-prompt', readHookTurn: () => DONE }) + ) + ).toEqual({ kind: 'ready-strong' }) + expect( + evaluateTuiIdle( + input({ + readTailBlockedReason: () => 'agent-interactive-prompt', + readHookTurn: () => ({ state: 'done', blockedReason: 'agent-trust-workspace' }) + }) + ) + ).toEqual({ kind: 'blocked', reason: 'agent-trust-workspace' }) + // With no fresh row, the raw tail still blocks. + expect( + evaluateTuiIdle( + input({ readTailBlockedReason: () => 'agent-trust-workspace', readHookTurn: () => null }) + ) + ).toEqual({ kind: 'blocked', reason: 'agent-trust-workspace' }) + }) + + it('falls back to the other lanes with no fresh row (startup, before the first prompt)', () => { + expect( + evaluateTuiIdle(input({ readPositiveBodyEvidence: () => true, readHookTurn: () => null })) + ).toEqual({ kind: 'ready-strong' }) + expect( + evaluateTuiIdle(input({ readPositiveBodyEvidence: () => false, readHookTurn: () => null })) + ).toEqual({ kind: 'pending', quietForeground: 'closed' }) + }) + + it.each(['claude', 'codex'] as const)('never reads hooks for identity-only %s', (agent) => { + const readHookTurn = vi.fn(() => ({ state: 'done' as const, blockedReason: null })) + expect(evaluateTuiIdle(input({ agent, readHookTurn }))).toEqual({ + kind: 'pending', + quietForeground: 'closed' + }) + expect(readHookTurn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/tui-idle-hook-lane.ts b/src/main/runtime/tui-idle-hook-lane.ts new file mode 100644 index 00000000000..04bfaa1bbdd --- /dev/null +++ b/src/main/runtime/tui-idle-hook-lane.ts @@ -0,0 +1,110 @@ +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' +import type { TuiAgent } from '../../shared/tui-agent' +import { hooksAreAuthoritative } from './agent-state-rules/agent-state-rules-engine' +import { selectFreshExplicitAgentStatusRow } from './runtime-hook-agent-row-selection' + +type HookTurnState = 'done' | 'working' | 'permission' + +/** + * The main agent's turn as the hook server's store last saw it for one pane, and the permission + * arbiter's verdict on the pane's blocked text with that turn as its explicit status. + */ +export type TuiIdleHookTurn = { + state: HookTurnState + blockedReason: RuntimeTerminalWaitBlockedReason | null +} + +/** + * Reads the main agent's turn off its row. Why the main agent's own state: the row's combined + * state folds child work in, and a subagent finishing must not end the lead turn (#6011); a + * child's permission wait still blocks, since its prompt is on the pane. A lead turn that ended + * reads done even while a subagent runs: its composer takes input then, as the screen rules read it. + */ +export function hookLeadTurnState( + row: Pick +): HookTurnState | null { + // Why no answer: a session start is not a turn end, and some agents post it before their + // composer accepts input, so startup readiness stays with the screen and text rules. + if (row.state === 'done' && row.sessionBoundary === true) { + return null + } + if (row.state === 'waiting' || row.state === 'blocked') { + return 'permission' + } + const lead = row.mainAgent?.state ?? row.state + return lead === 'done' || lead === 'working' ? lead : 'permission' +} + +export type TuiIdleHookTurnRead = { + agent: TuiAgent + handles: Iterable + paneKeys: Iterable + hookRows: readonly AgentStatusIpcPayload[] + /** When the PTY respawned: every row from before it is the previous process's. */ + respawnedAt?: number + /** When input last reached the pane, typed or sent: a `done` from before it cannot speak for + * the turn that input may have started (or the agent it restarted), whose first hook can still + * be in flight. */ + lastInputAt?: number | null + /** The existing permission arbiter, given the turn as the pane's explicit status. */ + resolveBlockedText( + status: HookTurnState, + row: AgentStatusIpcPayload + ): RuntimeTerminalWaitBlockedReason | null +} + +/** + * The pane's freshest hook row, joined on its pane keys and terminal handles. A pane neither + * reaches (a PTY created with no pane key whose agent never posted under one) has no answer, and + * neither does a row another agent or an earlier process wrote: the caller's other lanes decide. + */ +export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn | null { + const row = selectFreshExplicitAgentStatusRow(read) + if (!row || row.agentType !== read.agent || row.receivedAt < (read.respawnedAt ?? -1)) { + return null + } + const state = hookLeadTurnState(row) + if (state === null || (state === 'done' && row.receivedAt < (read.lastInputAt ?? -1))) { + return null + } + return { state, blockedReason: read.resolveBlockedText(state, row) } +} + +/** The tui-idle verdicts the hook lane can reach (a subset of `TuiIdleVerdict`). */ +export type TuiIdleHookVerdict = + | { kind: 'ready-strong' } + | { kind: 'working' } + | { kind: 'blocked'; reason: RuntimeTerminalWaitBlockedReason } + | { kind: 'pending'; quietForeground: 'closed' } + +/** + * Tier 0 of tui-idle-evidence.ts for an agent whose hooks are authoritative. Why ahead of every + * rule: its hooks report each way a turn ends, and they reach a headless host, where the + * ` ready` titles the window writes never appear (#16095). Why the arbiter judges the blocked + * text: a denied prompt's dialog lingers in the line tail after the hook says the turn moved on. + * No fresh row (startup, before the first prompt, or an unjoinable pane) leaves the other tiers. + */ +export function evaluateHookTurn( + agent: TuiAgent | null | undefined, + readHookTurn: () => TuiIdleHookTurn | null +): TuiIdleHookVerdict | null { + if (!hooksAreAuthoritative(agent)) { + return null + } + const turn = readHookTurn() + if (turn?.blockedReason) { + return { kind: 'blocked', reason: turn.blockedReason } + } + switch (turn?.state) { + case undefined: + return null + case 'done': + return { kind: 'ready-strong' } + case 'working': + return { kind: 'working' } + case 'permission': + // Why pending: the arbiter saw no prompt in the tail, so the poll's screen read decides. + return { kind: 'pending', quietForeground: 'closed' } + } +} diff --git a/src/main/ssh/orcad-activation-crash-recovery.test.ts b/src/main/ssh/orcad-activation-crash-recovery.test.ts deleted file mode 100644 index 8a61515f1a7..00000000000 --- a/src/main/ssh/orcad-activation-crash-recovery.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as DeployHelpers from './ssh-relay-deploy-helpers' -import type * as RecordFile from './orcad-remote-record-file' -import type * as InstallLock from './ssh-relay-install-lock' -import type * as VersionedInstall from './ssh-relay-versioned-install' - -vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ - ...(await importOriginal()), - execCommand: vi.fn() -})) -vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn() -})) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn() -})) -vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ - ...(await importOriginal()), - readLocalFullVersion: () => '0.2.0+bb01' -})) -vi.mock('./orcad-remote-install', () => ({ installOrcadBundle: vi.fn() })) -vi.mock('./orcad-remote-preflight', () => ({ preflightInstalledOrcad: vi.fn() })) -vi.mock('./orcad-local-build-hash', () => ({ - computeLocalOrcadBuildHash: () => 'abc123def4567890' -})) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' -import { deployOrcad } from './orcad-remote-deploy' -import { rollbackOrcad } from './orcad-remote-rollback' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' -import { - BUILD_HASH, - FakeOrcadHost, - NEW, - OLD, - type CrashMode -} from './orcad-activation-host-test-harness' - -let host = new FakeOrcadHost() - -const slot = { - conn: {} as SshConnection, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/u', - nodePath: '/usr/bin/node', - userDataDir: '/home/u/.orca', - bindHost: '127.0.0.1', - port: 7777, - readinessTimeoutMs: 50, - sleep: async () => {}, - now: () => new Date('2026-02-02T00:00:00.000Z') -} -const census = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } - -const deploy = (): Promise => - deployOrcad({ ...slot, localOrcadDir: '/local/out/orcad', target: 'linux-x64-glibc', census }) -const rollback = (): Promise => - rollbackOrcad({ - ...slot, - record: FakeOrcadHost.newRecord(), - census, - targetBuildHash: BUILD_HASH, - targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, - terminalsStartedSince: async () => 0 - }) - -beforeEach(() => { - vi.clearAllMocks() - vi.mocked(execCommand).mockImplementation(async (_conn, command) => host.exec(command)) - vi.mocked(acquireInstallLock).mockImplementation(async () => host.acquireFence()) - vi.mocked(writeAtomicOrcadRemoteRecord).mockImplementation(async (_target, path, contents) => - host.write(path, contents) - ) -}) - -/** The invariant: the host serves exactly the slot its record names, on state that slot reads. */ -function expectExactlyTheRecordedSlot(): void { - const active = host.activeVersion() - expect([...host.alive]).toEqual(active ? [active] : []) - expect(host.isReadableBy(active)).toBe(true) - expect(host.journal).toBeNull() - expect(host.fence).toBe(false) -} - -/** Unconfirmed termination never deletes a slot directory or a snapshot. */ -function expectNoSlotOrSnapshotRemoved(): void { - const removals = host.commands.filter((command) => /\brm -r?f\b/u.test(command)) - for (const command of removals) { - // A file inside a slot (a consumed stop request) may go; the slot directory never does. - expect(command).not.toMatch(/rm -r?f '[^']*\/orcad-\d[^'/]*\/?'/u) - expect(command).not.toMatch(/rm -r?f '[^']*orcad-state-snapshots/u) - } -} - -function countMutations( - scenario: () => FakeOrcadHost, - run: () => Promise -): Promise { - host = scenario() - return run().then(() => host.mutations) -} - -const scenarios: [string, () => FakeOrcadHost, () => Promise][] = [ - ['an update over an incumbent', FakeOrcadHost.deployedOld, deploy], - ['a first activation', () => new FakeOrcadHost(), deploy], - ['a rollback', FakeOrcadHost.activatedNew, rollback] -] - -describe.each(scenarios)('%s interrupted at every mutation', (_name, scenario, run) => { - it('completes cleanly when nothing interrupts it', async () => { - host = scenario() - await run() - expectExactlyTheRecordedSlot() - expect(host.activeVersion()).toBe(run === rollback ? OLD : NEW) - }) - - it.each(['before', 'after'])( - 'recovers to exactly one recorded slot when the host answer is lost %s applying it', - async (mode) => { - const total = await countMutations(scenario, run) - expect(total).toBeGreaterThan(3) - for (let crashAt = 1; crashAt <= total; crashAt += 1) { - host = scenario() - host.crashAt = crashAt - host.crashMode = mode - await run().catch(() => undefined) - host.crashAt = null - const result = await recoverInterruptedOrcadActivation({ - ...slot, - terminalsStartedSince: async () => 0 - }) - expect(['recovered', 'none'], `mutation ${crashAt}`).toContain(result.outcome) - expectExactlyTheRecordedSlot() - expectNoSlotOrSnapshotRemoved() - } - } - ) -}) - -describe('recovery refusals keep the fence', () => { - async function interruptedAfterCandidateLaunch(): Promise { - host = FakeOrcadHost.deployedOld() - const total = await countMutations(FakeOrcadHost.deployedOld, deploy) - host = FakeOrcadHost.deployedOld() - // The last three mutations are: candidate-ready journal, record, fence release. - host.crashAt = total - 2 - host.crashMode = 'before' - await deploy().catch(() => undefined) - host.crashAt = null - expect(host.alive.has(NEW)).toBe(true) - } - - it('does not restore state over terminals started since the interrupted change', async () => { - await interruptedAfterCandidateLaunch() - const result = await recoverInterruptedOrcadActivation({ - ...slot, - terminalsStartedSince: async () => 2 - }) - expect(result).toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_recovery_orphans_live_terminals' - }) - expect(host.fence).toBe(true) - expect(host.journal).not.toBeNull() - expect(host.alive.size).toBe(0) - }) - - it.each([undefined, null])('keeps changed state when the fresh census is %s', async (started) => { - await interruptedAfterCandidateLaunch() - const result = await recoverInterruptedOrcadActivation({ - ...slot, - ...(started === undefined ? {} : { terminalsStartedSince: async () => started }) - }) - expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_recovery_census_required' }) - expect(host.journal).not.toBeNull() - }) - - it('never treats an unverifiable incumbent as exited', async () => { - host = FakeOrcadHost.deployedOld() - host.crashAt = 3 - await deploy().catch(() => undefined) - host.crashAt = null - host.pidFiles.delete(OLD) - host.alive.delete(OLD) - const result = await recoverInterruptedOrcadActivation({ ...slot }) - expect(result).toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) - expect(host.fence).toBe(true) - expect(host.alive.size).toBe(0) - }) - - it('reports a fresh fence as pending instead of taking it over', async () => { - host = FakeOrcadHost.deployedOld() - host.crashAt = 3 - await deploy().catch(() => undefined) - host.crashAt = null - const journal = host.journal - expect(journal).not.toBeNull() - const { RemoteInstallLockBusyError } = await vi.importActual( - './ssh-relay-install-lock' - ) - vi.mocked(acquireInstallLock).mockRejectedValueOnce(new RemoteInstallLockBusyError('/l', 0)) - expect(await recoverInterruptedOrcadActivation({ ...slot })).toMatchObject({ - outcome: 'pending' - }) - expect(host.journal).toBe(journal) - }) - - it('keeps a journal this client cannot read', async () => { - host = FakeOrcadHost.deployedOld() - host.journal = JSON.stringify({ schemaVersion: 1, operation: 'decommission' }) - host.fence = true - expect(await recoverInterruptedOrcadActivation({ ...slot })).toMatchObject({ - outcome: 'refused', - code: 'orcad_recovery_unverifiable' - }) - expect(host.fence).toBe(true) - }) - - it('drops a fence whose release was cut short after the journal went', async () => { - host = FakeOrcadHost.deployedOld() - host.fence = true - expect(await recoverInterruptedOrcadActivation({ ...slot })).toEqual({ outcome: 'none' }) - expect(host.fence).toBe(false) - }) -}) diff --git a/src/main/ssh/orcad-activation-host-test-harness.ts b/src/main/ssh/orcad-activation-host-test-harness.ts deleted file mode 100644 index 2ce1353529d..00000000000 --- a/src/main/ssh/orcad-activation-host-test-harness.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * A stateful fake orcad host for crash-recovery tests: slot processes, PID files, the shared - * profile state, snapshots, the activation record, the journal and its fence. It can drop the - * connection at any numbered mutation, before or after the host applied it. - */ -import { - emptyOrcadActivationRecord, - parseOrcadActivationRecord, - withActivatedVersion, - type OrcadActivationRecord -} from './orcad-activation-record' - -export const OLD = '0.1.0+aa01' -export const NEW = '0.2.0+bb01' -export const BUILD_HASH = 'abc123def4567890' -/** State the incoming build migrates on load; the outgoing build cannot read it. */ -const MIGRATION = '|migrated-by-new' - -export type CrashMode = 'before' | 'after' - -export class FakeOrcadHost { - record: string | null = null - journal: string | null = null - fence = false - readonly alive = new Set() - readonly pidFiles = new Set() - data = 'profiles-v1' - readonly snapshots = new Map() - readonly commands: string[] = [] - mutations = 0 - crashAt: number | null = null - crashMode: CrashMode = 'before' - /** How the slot's managed-stop command behaves: orcad exits, or keeps serving. */ - managedStop: 'exits' | 'stays' | 'stays-dispatched' = 'exits' - readonly dispatched = new Set() - - static deployedOld(): FakeOrcadHost { - const host = new FakeOrcadHost() - host.record = JSON.stringify( - withActivatedVersion(emptyOrcadActivationRecord(), OLD, null, new Date(0)) - ) - host.alive.add(OLD) - host.pidFiles.add(OLD) - return host - } - - static activatedNew(): FakeOrcadHost { - const host = FakeOrcadHost.deployedOld() - host.alive.clear() - host.pidFiles.add(NEW) - host.alive.add(NEW) - host.snapshots.set('pre-0.2.0+bb01-1000', host.data) - host.data += MIGRATION - host.record = JSON.stringify(FakeOrcadHost.newRecord()) - return host - } - - static newRecord(): OrcadActivationRecord { - return withActivatedVersion( - { - ...emptyOrcadActivationRecord(), - active: OLD, - activatedAt: new Date(0).toISOString() - }, - NEW, - { - dirName: 'pre-0.2.0+bb01-1000', - takenBeforeVersion: NEW, - readableByVersion: OLD, - takenAt: new Date(1000).toISOString() - }, - new Date(1000) - ) - } - - activeVersion(): string | null { - const parsed = parseOrcadActivationRecord(this.record) - return parsed.state === 'ok' ? parsed.record.active : null - } - - /** Every state the old build can read: the pre-migration profile. */ - isReadableBy(version: string | null): boolean { - return version !== OLD || !this.data.includes(MIGRATION) - } - - private mutate(apply: () => T): T { - this.mutations += 1 - if (this.crashAt !== this.mutations) { - return apply() - } - if (this.crashMode === 'after') { - apply() - } - throw Object.assign(new Error(`connection lost at mutation ${this.mutations}`), { - sshChannelCloseConfirmed: false - }) - } - - write(path: string, contents: string): void { - this.mutate(() => { - if (path.endsWith('transaction.json')) { - this.journal = contents - } else if (path.endsWith('orcad-active.json')) { - this.record = contents - } - }) - } - - acquireFence(): void { - this.fence = true - } - - exec(command: string): string { - this.commands.push(command) - const version = /\/orcad-(\d+\.\d+\.\d+\+[0-9a-f]+)/u.exec(command)?.[1] ?? null - const snapshot = /orcad-state-snapshots\/([A-Za-z0-9][A-Za-z0-9.+-]*)/u.exec(command)?.[1] - if (command.includes('__ORCAD_RECORD_PRESENT__') && command.includes('orcad.lock')) { - const owner = [...this.alive][0] - return owner - ? `__ORCAD_RECORD_PRESENT__\n${JSON.stringify(lockRecord(owner))}` - : '__ORCAD_RECORD_ABSENT__\n' - } - if (command.includes('-managed-stop ') && version) { - return this.runManagedStopCommand(command, version) - } - if (command.includes('__ORCAD_RECORD_PRESENT__')) { - const value = command.includes('transaction.json') ? this.journal : this.record - return value === null ? '__ORCAD_RECORD_ABSENT__\n' : `__ORCAD_RECORD_PRESENT__\n${value}` - } - if (command.includes('echo LOCKED || echo OPEN')) { - return this.fence ? 'LOCKED' : 'OPEN' - } - if (command.startsWith('rm -f') && command.includes('transaction.json')) { - return this.mutate(() => { - this.journal = null - this.fence = false - return '' - }) - } - if (command.includes('__ORCAD_BUILD_HASH__')) { - return `__ORCAD_BUILD_HASH__ ${BUILD_HASH}\n` - } - if (command.includes('orca-runtime.json')) { - return this.alive.size > 0 ? 'LIVE orcad.lock 1' : 'CLEAR' - } - if (command.includes('echo LIVE;') && version) { - if (this.alive.has(version)) { - return 'LIVE' - } - return this.pidFiles.has(version) ? 'DEAD' : 'UNKNOWN' - } - if (command.includes('kill -TERM') && version) { - if (!this.pidFiles.has(version)) { - return 'NO_PID' - } - return this.mutate(() => (this.alive.delete(version) ? 'STOPPED' : 'ALREADY_EXITED')) - } - if (command.includes('nohup') && version) { - return this.mutate(() => { - this.pidFiles.add(version) - // The instance lock and port admit one owner; a second launch never becomes ready. - if (this.alive.size === 0) { - this.alive.add(version) - if (version === NEW && !this.data.includes(MIGRATION)) { - this.data += MIGRATION - } - } - return '9999' - }) - } - if (command.startsWith('head -c ') && version) { - return this.alive.has(version) ? readyLine(version) : '' - } - if (command.includes('echo PRESENT') && snapshot) { - return this.snapshots.has(snapshot) ? 'PRESENT' : 'ABSENT' - } - if (command.includes('verdict=UNCHANGED') && snapshot) { - return this.snapshots.get(snapshot) === this.data ? 'UNCHANGED' : 'CHANGED' - } - if (command.includes('-cf') && snapshot) { - return this.mutate(() => { - if (this.data === '') { - return 'EMPTY' - } - this.snapshots.set(snapshot, this.data) - return 'CAPTURED' - }) - } - if (command.includes('.orcad-state-restore-stage') && snapshot) { - return this.mutate(() => { - const restored = this.snapshots.get(snapshot) - if (restored === undefined) { - return 'MISSING' - } - this.data = restored - return 'RESTORED' - }) - } - if (command.includes('then echo RESTORED')) { - return this.mutate(() => { - this.data = '' - return 'RESTORED' - }) - } - if (command.includes('stat -c %Y')) { - return 'UNKNOWN' - } - return '' - } - - /** The slot's `--complete-managed-stop` / `--cancel-managed-stop`, as orcad answers them. */ - private runManagedStopCommand(command: string, version: string): string { - const request = JSON.parse(command.slice(command.lastIndexOf(" '{") + 2, -1)) - if (command.includes('--cancel-managed-stop')) { - const outcome = this.dispatched.has(request.transactionId) ? 'dispatched' : 'canceled' - return JSON.stringify({ ...request, kind: 'orcad_managed_stop_cancellation', outcome }) - } - const verdict = this.mutate(() => { - if (this.managedStop === 'exits') { - this.dispatched.add(request.transactionId) - this.alive.delete(version) - } else if (this.managedStop === 'stays-dispatched') { - this.dispatched.add(request.transactionId) - } - return this.alive.has(version) ? 'live' : 'exited' - }) - return JSON.stringify({ - ...request, - kind: 'orcad_managed_stop_completion', - verdict, - receiptPersisted: verdict === 'exited', - ...(verdict === 'exited' ? { retirement: 'retired' } : {}) - }) - } -} - -function lockRecord(version: string) { - return { - pid: 1, - startedAtMs: null, - identity: 'uid-1000', - version, - acquiredAt: new Date(0).toISOString(), - nonce: `nonce-${version}` - } -} - -export function readyLine(version: string): string { - return JSON.stringify({ - type: 'orca_server_ready', - schemaVersion: 1, - runtimeId: 'r1', - boundEndpoint: 'ws://127.0.0.1:7777', - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, - health: { - buildHash: BUILD_HASH, - buildVersion: version, - nodeVersion: '24.21.0', - nodeAbi: '137', - platform: 'linux', - arch: 'x64', - pid: 1, - stopRequests: 1, - terminalDaemon: { - state: 'live', - ownsFreshSessions: true, - pid: 2, - buildVersion: version, - entryPath: '/x/daemon-entry.js', - protocolVersion: 3, - selfTest: { ok: true, coverage: 'pty-spawn', verdict: 'healthy', durationMs: 5 } - } - } - }) -} diff --git a/src/main/ssh/orcad-activation-lock.ts b/src/main/ssh/orcad-activation-lock.ts deleted file mode 100644 index 7a0a5e9a6e0..00000000000 --- a/src/main/ssh/orcad-activation-lock.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * One activation or rollback per host, and the fence an interrupted one leaves behind. - * - * The lock lives in the transaction root, so releasing it also removes the journal. A run - * that cannot prove the host is back to one serving slot retains both; only recovery, after - * the install lock's stale window, may take a retained fence over. - */ -import { - execOrcadRemote, - withoutAbortSignal, - type OrcadRemoteExecTarget -} from './orcad-remote-runtime-control' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { acquireInstallLock, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' -import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { removeRemoteFileCommand, removeRemoteTreeCommand } from './ssh-remote-commands' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { - ORCAD_ACTIVATION_TRANSACTION_DIRNAME, - ORCAD_ACTIVATION_TRANSACTION_FILENAME -} from './orcad-activation-transaction' - -const ORCAD_ACTIVATION_MAX_READINESS_TIMEOUT_MS = 5 * 60_000 - -type OrcadActivationLockOptions = OrcadRemoteExecTarget & { remoteHome: string } - -export type OrcadActivationLockControl = { - /** Keep the fence if the run throws: a journal now describes host state. */ - retainOnError(): void - /** Keep the fence even on return: the host is not proven back to one serving slot. */ - retain(): void - /** The host is proven back on its recorded slot: release even if the run then throws. */ - recovered(): void -} - -export function orcadActivationTransactionRoot( - host: RemoteHostPlatform, - remoteHome: string -): string { - return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, ORCAD_ACTIVATION_TRANSACTION_DIRNAME) -} - -/** Bounded so a crashed holder's lock goes stale long before a live holder could still be waiting. */ -export function resolveOrcadActivationReadinessTimeout( - configured: number | undefined, - fallback: number -): number { - const timeout = configured ?? fallback - if ( - !Number.isSafeInteger(timeout) || - timeout <= 0 || - timeout > ORCAD_ACTIVATION_MAX_READINESS_TIMEOUT_MS - ) { - throw new Error( - `orcad readiness timeout must be an integer from 1 to ${ORCAD_ACTIVATION_MAX_READINESS_TIMEOUT_MS}ms` - ) - } - return timeout -} - -export async function withOrcadActivationLock( - options: OrcadActivationLockOptions, - run: (control: OrcadActivationLockControl) => Promise -): Promise { - const lockRoot = orcadActivationTransactionRoot(options.host, options.remoteHome) - await acquireInstallLock(options.conn, lockRoot, options.host, { - signal: options.signal, - relayGcClaim: false, - // A retained fence means state ownership is unresolved. Age cannot make it safe. - allowStaleTakeover: false - }) - let retainOnError = false - let retain = false - try { - const result = await run({ - retainOnError: () => { - retainOnError = true - }, - retain: () => { - retain = true - }, - recovered: () => { - retainOnError = false - } - }) - if (!retain) { - await releaseActivationFence(options, lockRoot) - } - return result - } catch (error) { - // A remote mutation whose teardown is unconfirmed may still be running: keep its fence. - if (!retainOnError && !isUnconfirmedSshCommandTermination(error)) { - await releaseActivationFence(options, lockRoot).catch((releaseError: unknown) => { - console.warn( - `[orcad] Failed to release activation lock after an error: ${releaseError instanceof Error ? releaseError.message : String(releaseError)}` - ) - }) - } - throw error - } -} - -/** Takes over only a stale or previous-boot fence; a fresh one throws `RemoteInstallLockBusyError`. */ -export async function withStaleOrcadActivationRecoveryLock( - options: OrcadActivationLockOptions, - run: (control: Pick) => Promise -): Promise { - const lockRoot = orcadActivationTransactionRoot(options.host, options.remoteHome) - await acquireInstallLock(options.conn, lockRoot, options.host, { - signal: options.signal, - relayGcClaim: false, - allowStaleTakeover: true, - waitTimeoutMs: 0 - }) - let retain = false - // Any throw keeps the fence: recovery failed to prove one serving slot. - const result = await run({ retain: () => (retain = true) }) - if (!retain) { - await releaseActivationFence(options, lockRoot) - } - return result -} - -/** Whether any lock is held; a lost probe throws rather than reading as open. */ -export async function orcadActivationFenceExists( - options: OrcadActivationLockOptions -): Promise { - const lockDir = joinRemotePath( - options.host, - orcadActivationTransactionRoot(options.host, options.remoteHome), - RELAY_INSTALL_LOCK_NAME - ) - const answer = ( - await execOrcadRemote(options, probeInstallLockExistsCommand(options.host, lockDir)) - ).trim() - if (answer !== 'LOCKED' && answer !== 'OPEN') { - throw new Error('The activation fence probe returned no verifiable answer.') - } - return answer === 'LOCKED' -} - -function releaseActivationFence( - options: OrcadActivationLockOptions, - lockRoot: string -): Promise { - // Journal first: a release cut short must leave a lock without a journal, never the reverse. - const journal = joinRemotePath(options.host, lockRoot, ORCAD_ACTIVATION_TRANSACTION_FILENAME) - // Why no signal: a cancelled run must still be able to drop a fence it proved unnecessary. - return execOrcadRemote( - withoutAbortSignal(options), - `${removeRemoteFileCommand(options.host, journal)} && ${removeRemoteTreeCommand(options.host, lockRoot)}` - ).then(() => undefined) -} diff --git a/src/main/ssh/orcad-activation-record-store.ts b/src/main/ssh/orcad-activation-record-store.ts index a36dcc728e0..d60634ec525 100644 --- a/src/main/ssh/orcad-activation-record-store.ts +++ b/src/main/ssh/orcad-activation-record-store.ts @@ -6,50 +6,32 @@ * activated" would deploy over a live install and lose its rollback target. */ import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' import { RELAY_REMOTE_DIR } from './relay-protocol' import { ORCAD_ACTIVATION_FILENAME, emptyOrcadActivationRecord, parseOrcadActivationRecord, - serializeOrcadActivationRecord, - type OrcadActivationReadResult, type OrcadActivationRecord } from './orcad-activation-record' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' -import { - readBoundedOrcadRemoteRecord, - writeAtomicOrcadRemoteRecord -} from './orcad-remote-record-file' - -const ORCAD_ACTIVATION_RECORD_MAX_BYTES = 64 * 1024 - -type ActivationRecordTarget = { - conn: SshConnection - host: RemoteHostPlatform - remoteHome: string - signal?: AbortSignal -} export function orcadActivationPath(host: RemoteHostPlatform, remoteHome: string): string { return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, ORCAD_ACTIVATION_FILENAME) } -/** A failed read rejects; it never reads as absent, because absence would admit a fresh deploy. */ -async function readActivationRecordState( - options: ActivationRecordTarget -): Promise { - const read = await readBoundedOrcadRemoteRecord( - options, - orcadActivationPath(options.host, options.remoteHome), - ORCAD_ACTIVATION_RECORD_MAX_BYTES - ) - return read.state === 'absent' ? read : parseOrcadActivationRecord(read.raw) -} - -export async function readOrcadActivationRecord( - options: ActivationRecordTarget -): Promise { - const parsed = await readActivationRecordState(options) +export async function readOrcadActivationRecord(options: { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + signal?: AbortSignal +}): Promise { + const path = orcadActivationPath(options.host, options.remoteHome) + const raw = await execCommand(options.conn, `cat ${shellQuote(path)} 2>/dev/null || true`, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }).catch(() => '') + const parsed = parseOrcadActivationRecord(raw) if (parsed.state === 'ok') { return parsed.record } @@ -61,18 +43,6 @@ export async function readOrcadActivationRecord( return emptyOrcadActivationRecord() } -/** Refuses to replace a record this client cannot read, e.g. one a newer client wrote. */ -export async function writeOrcadActivationRecord( - options: ActivationRecordTarget, - record: OrcadActivationRecord -): Promise { - const existing = await readActivationRecordState(options) - if (existing.state === 'unreadable') { - throw new Error(`Refusing to overwrite this host's orcad activation record: ${existing.reason}`) - } - await writeAtomicOrcadRemoteRecord( - options, - orcadActivationPath(options.host, options.remoteHome), - serializeOrcadActivationRecord(record) - ) +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` } diff --git a/src/main/ssh/orcad-activation-record.ts b/src/main/ssh/orcad-activation-record.ts index 08f83a6970b..42b1e6703a8 100644 --- a/src/main/ssh/orcad-activation-record.ts +++ b/src/main/ssh/orcad-activation-record.ts @@ -154,19 +154,6 @@ export function withRolledBackVersion( } } -/** The record after decommissioning `active`: nothing serves; the stopped build stays pinned. */ -export function withDeactivatedVersion(record: OrcadActivationRecord): OrcadActivationRecord { - return { - schemaVersion: ORCAD_ACTIVATION_SCHEMA_VERSION, - active: null, - // Kept so GC leaves the slot whose daemon may still own terminals, and a redeploy can find it. - previous: record.active, - activatedAt: null, - // No version is active, so there is nothing a pre-activation snapshot could roll back to. - snapshot: null - } -} - /** * Version dirs GC must not remove, as directory names. * diff --git a/src/main/ssh/orcad-activation-recovery.ts b/src/main/ssh/orcad-activation-recovery.ts deleted file mode 100644 index 06cd63a2287..00000000000 --- a/src/main/ssh/orcad-activation-recovery.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Finishing or undoing an activation, rollback or decommission that a crash, a lost connection or an - * unverifiable failure left fenced. Either way the host ends serving exactly the slot its - * activation record names, or the fence stays for an operator. - */ -import type { ServeReadiness } from '../server/serve-readiness' -import { - planOrcadTransactionRecovery, - type OrcadActivationTransaction -} from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { - orcadActivationFenceExists, - withStaleOrcadActivationRecoveryLock -} from './orcad-activation-lock' -import { RemoteInstallLockBusyError } from './ssh-relay-install-lock' -import { ensureOrcadSlotServing, resolveOrcadSlotIdentity } from './orcad-recovery-slot' -import { reconcileOrcadDecommission } from './orcad-decommission-recovery' -import { - recoverOrcadIncumbent, - type OrcadIncumbentRecoveryOptions -} from './orcad-incumbent-recovery' - -export type OrcadActivationRecoveryResult = - | { outcome: 'none' } - | { outcome: 'pending'; code: string; reason: string } - | { - outcome: 'recovered' - resolution: 'committed' | 'restored-incumbent' - activeVersion: string | null - readiness: ServeReadiness | null - } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export type OrcadActivationRecoveryOptions = OrcadIncumbentRecoveryOptions - -export async function recoverInterruptedOrcadActivation( - options: OrcadActivationRecoveryOptions -): Promise { - try { - if ( - !(await readOrcadActivationTransaction(options)) && - !(await orcadActivationFenceExists(options)) - ) { - return { outcome: 'none' } - } - return await withStaleOrcadActivationRecoveryLock(options, async (lock) => { - const transaction = await readOrcadActivationTransaction(options) - if (!transaction) { - // A release cut short after removing the journal; dropping the lock finishes it. - return { outcome: 'none' } - } - const result = await reconcileOrcadTransaction(options, transaction) - if (result.outcome !== 'recovered') { - lock.retain() - } - return result - }) - } catch (error) { - if (error instanceof RemoteInstallLockBusyError) { - return { - outcome: 'pending', - code: 'orcad_recovery_transaction_still_fresh', - reason: - 'The activation fence is held by a run that may still be working. Retry after the ' + - 'install lock recovery window.' - } - } - return { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_unverifiable', - reason: - `The interrupted activation could not be reconciled safely: ${errorMessage(error)} ` + - 'The host remains fenced.' - } - } -} - -/** Runs under a held fence. Throws when a step is unverifiable. */ -export async function reconcileOrcadTransaction( - options: OrcadActivationRecoveryOptions, - transaction: OrcadActivationTransaction -): Promise { - const plan = planOrcadTransactionRecovery(transaction, await readOrcadActivationRecord(options)) - if (plan.action === 'refuse') { - return { outcome: 'refused', verdict: 'unverifiable', code: plan.code, reason: plan.reason } - } - if ( - plan.action === 'confirm-decommissioned' || - plan.action === 'resume-stop' || - plan.action === 'keep-serving' - ) { - return reconcileOrcadDecommission(options, plan) - } - if (plan.action === 'finish-commit') { - await writeOrcadActivationRecord(options, plan.record) - } - if (plan.action === 'stabilize-committed' || plan.action === 'finish-commit') { - const activeVersion = plan.action === 'finish-commit' ? plan.record.active : plan.activeVersion - if (!activeVersion) { - throw new Error('The committed activation record has no active version.') - } - const identity = await resolveOrcadSlotIdentity(options, activeVersion) - return { - outcome: 'recovered', - resolution: 'committed', - activeVersion, - readiness: await ensureOrcadSlotServing(options, identity) - } - } - const recovery = await recoverOrcadIncumbent(options, { - transactionStartedAt: transaction.startedAt, - launchedVersion: plan.launchedVersion, - incumbent: plan.activeVersion - ? await resolveOrcadSlotIdentity(options, plan.activeVersion) - : null, - restoreState: plan.restoreState - }) - return recovery.outcome === 'refused' - ? recovery - : { - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: plan.activeVersion, - readiness: recovery.readiness - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-activation-transaction-schema.ts b/src/main/ssh/orcad-activation-transaction-schema.ts deleted file mode 100644 index 14c776b3a66..00000000000 --- a/src/main/ssh/orcad-activation-transaction-schema.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { z } from 'zod' -import { OrcadManagedStopRequestSchema } from '../../shared/orcad-stop-request' -import { - ORCAD_INSTALL_MODEL, - remoteInstallDirName, - remoteInstallVersionDirRegex -} from './remote-install-model' - -export const ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION = 1 - -const RemoteVersionSchema = z - .string() - .refine( - (version) => - remoteInstallVersionDirRegex(ORCAD_INSTALL_MODEL).test( - remoteInstallDirName(ORCAD_INSTALL_MODEL, version) - ), - 'Expected a safe remote install version' - ) -const SafeSnapshotNameSchema = z - .string() - .min(1) - .max(255) - .regex(/^[A-Za-z0-9][A-Za-z0-9.+-]*$/u) -const SnapshotVerdictSchema = z.object({ - dirName: SafeSnapshotNameSchema, - state: z.enum(['pending', 'captured', 'empty']) -}) -const OrcadActivationTransactionCommonFields = { - schemaVersion: z.literal(ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION), - transactionId: z.uuid(), - startedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - recordBefore: z.unknown() -} - -// Why strict operations: an older client reading a newer operation must keep the fence. -export const OrcadActivationTransactionSchema = z - .discriminatedUnion('operation', [ - z.object({ - ...OrcadActivationTransactionCommonFields, - operation: z.literal('activate'), - phase: z.enum(['prepared', 'incumbent-stopped', 'snapshot-captured', 'candidate-ready']), - candidateVersion: RemoteVersionSchema, - recordAfter: z.unknown().nullable(), - snapshot: SnapshotVerdictSchema - }), - z.object({ - ...OrcadActivationTransactionCommonFields, - operation: z.literal('rollback'), - phase: z.enum([ - 'prepared', - 'incumbent-stopped', - 'rescue-captured', - 'rollback-state-restored', - 'target-ready' - ]), - incumbentVersion: RemoteVersionSchema, - targetVersion: RemoteVersionSchema, - recordAfter: z.unknown(), - rescue: SnapshotVerdictSchema - }), - z.object({ - ...OrcadActivationTransactionCommonFields, - operation: z.literal('decommission'), - phase: z.enum(['prepared', 'stop-dispatched', 'process-exited']), - activeVersion: RemoteVersionSchema, - recordAfter: z.unknown(), - /** The instance-bound stop request; durable before it can reach the host. */ - request: OrcadManagedStopRequestSchema.nullable() - }) - ]) - .superRefine((transaction, context) => { - if (transaction.operation === 'decommission') { - if ((transaction.phase === 'prepared') !== (transaction.request === null)) { - context.addIssue({ code: 'custom', message: 'Stop request is inconsistent with phase' }) - } - if (transaction.request && transaction.request.transactionId !== transaction.transactionId) { - context.addIssue({ code: 'custom', message: 'Stop request names another transaction' }) - } - return - } - const beforeVerdict = - transaction.phase === 'prepared' || transaction.phase === 'incumbent-stopped' - const verdict = transaction.operation === 'activate' ? transaction.snapshot : transaction.rescue - if (beforeVerdict && verdict.state !== 'pending') { - context.addIssue({ code: 'custom', message: 'Snapshot state advanced before its phase' }) - } - if (!beforeVerdict && verdict.state === 'pending') { - context.addIssue({ code: 'custom', message: 'Snapshot phase has no durable verdict' }) - } - if ( - transaction.operation === 'activate' && - (transaction.phase === 'candidate-ready') !== (transaction.recordAfter !== null) - ) { - context.addIssue({ code: 'custom', message: 'Committed record is inconsistent with phase' }) - } - }) diff --git a/src/main/ssh/orcad-activation-transaction-store.ts b/src/main/ssh/orcad-activation-transaction-store.ts deleted file mode 100644 index 1e1b92927e4..00000000000 --- a/src/main/ssh/orcad-activation-transaction-store.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - ORCAD_ACTIVATION_TRANSACTION_FILENAME, - parseOrcadActivationTransaction, - serializeOrcadActivationTransaction, - type OrcadActivationTransaction -} from './orcad-activation-transaction' -import { orcadActivationTransactionRoot } from './orcad-activation-lock' -import { - readBoundedOrcadRemoteRecord, - writeAtomicOrcadRemoteRecord -} from './orcad-remote-record-file' -import type { OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -const ORCAD_ACTIVATION_TRANSACTION_MAX_BYTES = 64 * 1024 - -type OrcadActivationTransactionStoreOptions = OrcadRemoteExecTarget & { remoteHome: string } - -export function orcadActivationTransactionPath( - host: RemoteHostPlatform, - remoteHome: string -): string { - return joinRemotePath( - host, - orcadActivationTransactionRoot(host, remoteHome), - ORCAD_ACTIVATION_TRANSACTION_FILENAME - ) -} - -/** `null` only on a verified absence; an unreadable journal keeps the host fenced. */ -export async function readOrcadActivationTransaction( - options: OrcadActivationTransactionStoreOptions -): Promise { - const read = await readBoundedOrcadRemoteRecord( - options, - orcadActivationTransactionPath(options.host, options.remoteHome), - ORCAD_ACTIVATION_TRANSACTION_MAX_BYTES - ) - const parsed = parseOrcadActivationTransaction(read.state === 'present' ? read.raw : null) - if (parsed.state === 'unreadable') { - throw new Error(`Cannot read this host's orcad activation transaction: ${parsed.reason}`) - } - return parsed.state === 'ok' ? parsed.transaction : null -} - -export function writeOrcadActivationTransaction( - options: OrcadActivationTransactionStoreOptions, - transaction: OrcadActivationTransaction -): Promise { - return writeAtomicOrcadRemoteRecord( - options, - orcadActivationTransactionPath(options.host, options.remoteHome), - serializeOrcadActivationTransaction(transaction) - ) -} diff --git a/src/main/ssh/orcad-activation-transaction-transitions.ts b/src/main/ssh/orcad-activation-transaction-transitions.ts deleted file mode 100644 index 95facd02daa..00000000000 --- a/src/main/ssh/orcad-activation-transaction-transitions.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { OrcadActivationRecord } from './orcad-activation-record' -import { ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION } from './orcad-activation-transaction-schema' -import type { - OrcadActivateTransaction, - OrcadRollbackTransaction -} from './orcad-activation-transaction' - -export function createOrcadActivationTransaction(options: { - transactionId: string - candidateVersion: string - recordBefore: OrcadActivationRecord - snapshotDirName: string - now: Date -}): OrcadActivateTransaction { - const timestamp = options.now.toISOString() - return { - schemaVersion: ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - transactionId: options.transactionId, - operation: 'activate', - phase: 'prepared', - startedAt: timestamp, - updatedAt: timestamp, - candidateVersion: options.candidateVersion, - recordBefore: options.recordBefore, - recordAfter: null, - snapshot: { dirName: options.snapshotDirName, state: 'pending' } - } -} - -export function withOrcadActivationIncumbentStopped( - transaction: OrcadActivateTransaction, - now: Date -): OrcadActivateTransaction { - return { ...transaction, phase: 'incumbent-stopped', updatedAt: now.toISOString() } -} - -export function withOrcadActivationSnapshot( - transaction: OrcadActivateTransaction, - state: 'captured' | 'empty', - now: Date -): OrcadActivateTransaction { - return { - ...transaction, - phase: 'snapshot-captured', - updatedAt: now.toISOString(), - snapshot: { dirName: transaction.snapshot.dirName, state } - } -} - -export function withOrcadActivationCandidateReady( - transaction: OrcadActivateTransaction, - recordAfter: OrcadActivationRecord, - now: Date -): OrcadActivateTransaction { - return { ...transaction, phase: 'candidate-ready', updatedAt: now.toISOString(), recordAfter } -} - -export function createOrcadRollbackTransaction(options: { - transactionId: string - incumbentVersion: string - targetVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord - rescueDirName: string - now: Date -}): OrcadRollbackTransaction { - const timestamp = options.now.toISOString() - return { - schemaVersion: ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - transactionId: options.transactionId, - operation: 'rollback', - phase: 'prepared', - startedAt: timestamp, - updatedAt: timestamp, - incumbentVersion: options.incumbentVersion, - targetVersion: options.targetVersion, - recordBefore: options.recordBefore, - recordAfter: options.recordAfter, - rescue: { dirName: options.rescueDirName, state: 'pending' } - } -} - -export function withOrcadRollbackPhase( - transaction: OrcadRollbackTransaction, - phase: 'incumbent-stopped' | 'rollback-state-restored' | 'target-ready', - now: Date -): OrcadRollbackTransaction { - return { ...transaction, phase, updatedAt: now.toISOString() } -} - -export function withOrcadRollbackRescue( - transaction: OrcadRollbackTransaction, - state: 'captured' | 'empty', - now: Date -): OrcadRollbackTransaction { - return { - ...transaction, - phase: 'rescue-captured', - updatedAt: now.toISOString(), - rescue: { dirName: transaction.rescue.dirName, state } - } -} diff --git a/src/main/ssh/orcad-activation-transaction.test.ts b/src/main/ssh/orcad-activation-transaction.test.ts deleted file mode 100644 index 65fbb6227d0..00000000000 --- a/src/main/ssh/orcad-activation-transaction.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type * as DeployHelpers from './ssh-relay-deploy-helpers' -import type * as InstallLock from './ssh-relay-install-lock' - -vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ - ...(await importOriginal()), - execCommand: vi.fn().mockResolvedValue('') -})) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn().mockResolvedValue(undefined) -})) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { - parseOrcadActivationTransaction, - planOrcadTransactionRecovery, - serializeOrcadActivationTransaction, - type OrcadActivationTransaction -} from './orcad-activation-transaction' -import { - createOrcadActivationTransaction, - createOrcadRollbackTransaction, - withOrcadActivationCandidateReady, - withOrcadActivationIncumbentStopped, - withOrcadActivationSnapshot, - withOrcadRollbackPhase, - withOrcadRollbackRescue -} from './orcad-activation-transaction-transitions' -import { - resolveOrcadActivationReadinessTimeout, - withOrcadActivationLock, - withStaleOrcadActivationRecoveryLock -} from './orcad-activation-lock' -import { FakeOrcadHost, NEW, OLD } from './orcad-activation-host-test-harness' -import { withRolledBackVersion } from './orcad-activation-record' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' - -const T = new Date('2026-02-02T00:00:00.000Z') -const ID = '7f1c2a7e-6c1b-4a8e-9f0e-0a1b2c3d4e5f' -const before = FakeOrcadHost.newRecord() - -function activation(): ReturnType { - return createOrcadActivationTransaction({ - transactionId: ID, - candidateVersion: '0.3.0+cc01', - recordBefore: before, - snapshotDirName: 'pre-0.3.0+cc01-1', - now: T - }) -} - -function rollback(): ReturnType { - return createOrcadRollbackTransaction({ - transactionId: ID, - incumbentVersion: NEW, - targetVersion: OLD, - recordBefore: before, - recordAfter: withRolledBackVersion(before, T), - rescueDirName: 'rollback-rescue-0.2.0+bb01-1', - now: T - }) -} - -const roundTrip = (transaction: OrcadActivationTransaction): unknown => - parseOrcadActivationTransaction(serializeOrcadActivationTransaction(transaction)) - -describe('parseOrcadActivationTransaction', () => { - it('round-trips every phase of both operations', () => { - const stopped = withOrcadActivationIncumbentStopped(activation(), T) - const captured = withOrcadActivationSnapshot(stopped, 'captured', T) - const ready = withOrcadActivationCandidateReady( - captured, - { ...before, active: '0.3.0+cc01' }, - T - ) - const rescued = withOrcadRollbackRescue( - withOrcadRollbackPhase(rollback(), 'incumbent-stopped', T), - 'empty', - T - ) - for (const transaction of [activation(), stopped, captured, ready, rollback(), rescued]) { - expect(roundTrip(transaction)).toEqual({ state: 'ok', transaction }) - } - }) - - it.each([ - ['an unknown operation, so an older client keeps a newer fence', { operation: 'decommission' }], - ['a snapshot verdict before its phase', { snapshot: { dirName: 'pre-x', state: 'captured' } }], - ['a committed record before candidate-ready', { recordAfter: before }], - ['an unsafe version', { candidateVersion: '../../etc' }], - ['an unsafe snapshot name', { snapshot: { dirName: '../x', state: 'pending' } }] - ])('reads %s as unreadable', (_name, patch) => { - const raw = JSON.stringify({ ...activation(), ...patch }) - expect(parseOrcadActivationTransaction(raw)).toMatchObject({ state: 'unreadable' }) - }) - - it('rejects a rollback whose versions disagree with its records', () => { - const raw = JSON.stringify({ ...rollback(), targetVersion: '0.0.9+dd01' }) - expect(parseOrcadActivationTransaction(raw)).toMatchObject({ state: 'unreadable' }) - }) -}) - -describe('planOrcadTransactionRecovery', () => { - const after = { ...before, active: '0.3.0+cc01' } - const captured = withOrcadActivationSnapshot( - withOrcadActivationIncumbentStopped(activation(), T), - 'captured', - T - ) - - it.each([ - ['prepared', activation(), null, null], - ['incumbent-stopped', withOrcadActivationIncumbentStopped(activation(), T), null, null], - ['snapshot-captured', captured, '0.3.0+cc01', 'captured'] - ] as const)('undoes an activation interrupted at %s', (_phase, transaction, launched, state) => { - expect(planOrcadTransactionRecovery(transaction, before)).toMatchObject({ - action: 'undo', - launchedVersion: launched, - activeVersion: NEW, - restoreState: state === null ? null : { state } - }) - }) - - it('finishes a commit whose record write was lost, and stabilizes one that landed', () => { - const ready = withOrcadActivationCandidateReady(captured, after, T) - expect(planOrcadTransactionRecovery(ready, before)).toEqual({ - action: 'finish-commit', - record: after - }) - expect(planOrcadTransactionRecovery(ready, after)).toEqual({ - action: 'stabilize-committed', - activeVersion: '0.3.0+cc01' - }) - }) - - it('restores the rescue once a rollback may have replaced the state', () => { - const rescued = withOrcadRollbackRescue(rollback(), 'captured', T) - expect(planOrcadTransactionRecovery(rescued, before)).toMatchObject({ - action: 'undo', - launchedVersion: null, - activeVersion: NEW, - restoreState: { state: 'captured' } - }) - expect( - planOrcadTransactionRecovery( - withOrcadRollbackPhase(rescued, 'rollback-state-restored', T), - before - ) - ).toMatchObject({ launchedVersion: OLD }) - }) - - it('refuses when the record matches neither side', () => { - expect(planOrcadTransactionRecovery(activation(), { ...before, previous: null })).toMatchObject( - { - action: 'refuse', - code: 'orcad_recovery_activation_record_changed' - } - ) - }) -}) - -describe('activation fence', () => { - const target = { - conn: {} as SshConnection, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/u' - } - const removals = (): string[] => - vi - .mocked(execCommand) - .mock.calls.map(([, command]) => command) - .filter((command) => command.includes('rm -')) - - it('never takes over a held fence by age, and removes the journal before the lock', async () => { - vi.clearAllMocks() - await withOrcadActivationLock(target, async () => undefined) - expect(vi.mocked(acquireInstallLock).mock.calls[0]?.[3]).toMatchObject({ - allowStaleTakeover: false, - relayGcClaim: false - }) - const [release] = removals() - expect(release?.indexOf('transaction.json')).toBeLessThan( - release?.indexOf("rm -rf '/home/u/.orca-remote/.orcad-activation-transaction'") ?? -1 - ) - }) - - it('keeps the fence after an unconfirmed termination, a retained error, or retain()', async () => { - vi.clearAllMocks() - const lost = Object.assign(new Error('lost'), { sshChannelCloseConfirmed: false }) - await expect(withOrcadActivationLock(target, () => Promise.reject(lost))).rejects.toBe(lost) - await expect( - withOrcadActivationLock(target, async (lock) => { - lock.retainOnError() - throw new Error('mid-transaction') - }) - ).rejects.toThrow('mid-transaction') - await withOrcadActivationLock(target, async (lock) => lock.retain()) - expect(removals()).toEqual([]) - }) - - it('releases after a recovered failure even though the run throws', async () => { - vi.clearAllMocks() - await expect( - withOrcadActivationLock(target, async (lock) => { - lock.retainOnError() - lock.recovered() - throw new Error('snapshot failed; incumbent restarted') - }) - ).rejects.toThrow('incumbent restarted') - expect(removals()).toHaveLength(1) - }) - - it('lets recovery take over only stale fences, without waiting', async () => { - vi.clearAllMocks() - await withStaleOrcadActivationRecoveryLock(target, async () => undefined) - expect(vi.mocked(acquireInstallLock).mock.calls[0]?.[3]).toMatchObject({ - allowStaleTakeover: true, - waitTimeoutMs: 0 - }) - }) - - it.each([0, -1, 5 * 60_000 + 1, 1.5])('rejects readiness timeout %s', (timeout) => { - expect(() => resolveOrcadActivationReadinessTimeout(timeout, 1)).toThrow() - }) -}) diff --git a/src/main/ssh/orcad-activation-transaction.ts b/src/main/ssh/orcad-activation-transaction.ts deleted file mode 100644 index cc00c51f642..00000000000 --- a/src/main/ssh/orcad-activation-transaction.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * The host-side journal that makes an orcad activation, rollback or decommission crash-safe. - * - * Written before the first mutation and kept until the host is proven to serve exactly one - * slot again. The activation record stays the commit point: a journal whose `recordAfter` - * matches the record committed; one whose `recordBefore` matches did not, and recovery puts - * the pre-transaction slot and state back. Anything else keeps the fence for an operator. - */ -import { - parseOrcadActivationRecord, - serializeOrcadActivationRecord, - type OrcadActivationRecord -} from './orcad-activation-record' -import { - type ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - OrcadActivationTransactionSchema -} from './orcad-activation-transaction-schema' -import { - orcadDecommissionTransactionDefect, - planOrcadDecommissionRecovery, - type OrcadDecommissionRecoveryPlan, - type OrcadDecommissionTransaction -} from './orcad-decommission-transaction' - -export const ORCAD_ACTIVATION_TRANSACTION_FILENAME = 'transaction.json' -export const ORCAD_ACTIVATION_TRANSACTION_DIRNAME = '.orcad-activation-transaction' - -export type OrcadSnapshotVerdict = { dirName: string; state: 'pending' | 'captured' | 'empty' } - -export type OrcadActivateTransaction = { - schemaVersion: typeof ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION - transactionId: string - operation: 'activate' - phase: 'prepared' | 'incumbent-stopped' | 'snapshot-captured' | 'candidate-ready' - startedAt: string - updatedAt: string - candidateVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord | null - snapshot: OrcadSnapshotVerdict -} - -export type OrcadRollbackTransaction = { - schemaVersion: typeof ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION - transactionId: string - operation: 'rollback' - phase: - | 'prepared' - | 'incumbent-stopped' - | 'rescue-captured' - | 'rollback-state-restored' - | 'target-ready' - startedAt: string - updatedAt: string - incumbentVersion: string - targetVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord - rescue: OrcadSnapshotVerdict -} - -export type OrcadActivationTransaction = - | OrcadActivateTransaction - | OrcadRollbackTransaction - | OrcadDecommissionTransaction - -export type OrcadActivationTransactionReadResult = - | { state: 'absent' } - | { state: 'ok'; transaction: OrcadActivationTransaction } - | { state: 'unreadable'; reason: string } - -/** What recovery must do; `undo` lists the state to put back once the new slot is quiescent. */ -export type OrcadTransactionRecoveryPlan = - | { action: 'stabilize-committed'; activeVersion: string | null } - /** The new slot passed its gate and the journal holds the record; only the write was lost. */ - | { action: 'finish-commit'; record: OrcadActivationRecord } - | { - action: 'undo' - /** The slot that may have run after the state changed hands. */ - launchedVersion: string | null - activeVersion: string | null - restoreState: OrcadSnapshotVerdict | null - } - | OrcadDecommissionRecoveryPlan - | { action: 'refuse'; code: string; reason: string } - -export function parseOrcadActivationTransaction( - raw: string | null -): OrcadActivationTransactionReadResult { - if (raw === null || raw.trim() === '') { - return { state: 'absent' } - } - let json: unknown - try { - json = JSON.parse(raw) - } catch (error) { - return unreadable(`transaction is not JSON: ${errorMessage(error)}`) - } - const parsed = OrcadActivationTransactionSchema.safeParse(json) - if (!parsed.success) { - const issue = parsed.error.issues[0] - const path = issue?.path.length ? issue.path.join('.') : 'transaction' - return unreadable(`${path} is invalid: ${issue?.message ?? 'unknown shape'}`) - } - const recordBefore = parseNestedRecord(parsed.data.recordBefore, 'recordBefore') - if (recordBefore.state === 'unreadable') { - return recordBefore - } - if (parsed.data.operation === 'decommission') { - const after = parseNestedRecord(parsed.data.recordAfter, 'recordAfter') - if (after.state === 'unreadable') { - return after - } - const transaction = { - ...parsed.data, - recordBefore: recordBefore.record, - recordAfter: after.record - } - const defect = orcadDecommissionTransactionDefect(transaction) - return defect ? unreadable(defect) : { state: 'ok', transaction } - } - if (parsed.data.operation === 'activate') { - const recordAfter = - parsed.data.recordAfter === null - ? null - : parseNestedRecord(parsed.data.recordAfter, 'recordAfter') - if (recordAfter?.state === 'unreadable') { - return recordAfter - } - if (recordAfter && recordAfter.record.active !== parsed.data.candidateVersion) { - return unreadable('recordAfter does not activate candidateVersion') - } - return { - state: 'ok', - transaction: { - ...parsed.data, - recordBefore: recordBefore.record, - recordAfter: recordAfter?.record ?? null - } - } - } - const recordAfter = parseNestedRecord(parsed.data.recordAfter, 'recordAfter') - if (recordAfter.state === 'unreadable') { - return recordAfter - } - if ( - recordBefore.record.active !== parsed.data.incumbentVersion || - recordBefore.record.previous !== parsed.data.targetVersion - ) { - return unreadable('rollback versions do not match recordBefore') - } - if ( - recordAfter.record.active !== parsed.data.targetVersion || - recordAfter.record.previous !== null || - recordAfter.record.snapshot !== null - ) { - return unreadable('recordAfter is not a completed rollback record') - } - return { - state: 'ok', - transaction: { - ...parsed.data, - recordBefore: recordBefore.record, - recordAfter: recordAfter.record - } - } -} - -export function serializeOrcadActivationTransaction( - transaction: OrcadActivationTransaction -): string { - return `${JSON.stringify(transaction, null, 2)}\n` -} - -export function planOrcadTransactionRecovery( - transaction: OrcadActivationTransaction, - currentRecord: OrcadActivationRecord -): OrcadTransactionRecoveryPlan { - if (transaction.operation === 'decommission') { - const committed = sameOrcadActivationRecord(currentRecord, transaction.recordAfter) - return committed || sameOrcadActivationRecord(currentRecord, transaction.recordBefore) - ? planOrcadDecommissionRecovery(transaction, committed) - : recordChangedRefusal(transaction) - } - if ( - transaction.recordAfter && - sameOrcadActivationRecord(currentRecord, transaction.recordAfter) - ) { - return { action: 'stabilize-committed', activeVersion: transaction.recordAfter.active } - } - if (!sameOrcadActivationRecord(currentRecord, transaction.recordBefore)) { - return recordChangedRefusal(transaction) - } - if (transaction.phase === 'candidate-ready' || transaction.phase === 'target-ready') { - return { action: 'finish-commit', record: transaction.recordAfter ?? neverRecord() } - } - if (transaction.operation === 'activate') { - // The candidate launches only after the snapshot verdict is durable. - const launched = transaction.phase === 'snapshot-captured' - return { - action: 'undo', - launchedVersion: launched ? transaction.candidateVersion : null, - activeVersion: transaction.recordBefore.active, - restoreState: launched ? transaction.snapshot : null - } - } - // The rescue is the incumbent's state; it only needs restoring once the target's replaced it. - const replaced = transaction.phase === 'rollback-state-restored' - const rescued = replaced || transaction.phase === 'rescue-captured' - return { - action: 'undo', - launchedVersion: replaced ? transaction.targetVersion : null, - activeVersion: transaction.incumbentVersion, - // A crash mid-restore leaves the phase at rescue-captured with the root half replaced. - restoreState: rescued ? transaction.rescue : null - } -} - -export function sameOrcadActivationRecord( - left: OrcadActivationRecord, - right: OrcadActivationRecord -): boolean { - return serializeOrcadActivationRecord(left) === serializeOrcadActivationRecord(right) -} - -function parseNestedRecord( - value: unknown, - field: string -): { state: 'ok'; record: OrcadActivationRecord } | { state: 'unreadable'; reason: string } { - const parsed = parseOrcadActivationRecord(JSON.stringify(value)) - return parsed.state === 'ok' - ? { state: 'ok', record: parsed.record } - : unreadable( - `${field} is invalid: ${parsed.state === 'absent' ? 'record is absent' : parsed.reason}` - ) -} - -function recordChangedRefusal( - transaction: OrcadActivationTransaction -): Extract { - return { - action: 'refuse', - code: 'orcad_recovery_activation_record_changed', - reason: - `The activation record matches neither side of the interrupted ${transaction.operation}. ` + - 'Preserving the activation fence for operator inspection.' - } -} - -function neverRecord(): never { - throw new Error('A committed phase must carry its record; the schema enforces this.') -} - -function unreadable(reason: string): { state: 'unreadable'; reason: string } { - return { state: 'unreadable', reason } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-active-readiness.ts b/src/main/ssh/orcad-active-readiness.ts deleted file mode 100644 index 5fe6c157f9b..00000000000 --- a/src/main/ssh/orcad-active-readiness.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** Proving that the orcad an activation record names is the one actually serving. */ -import { evaluateOrcadActivation, type OrcadActivationExpectation } from './orcad-activation-gate' -import { - orcadLivenessProbeCommand, - parseOrcadLiveness, - parseOrcadReadinessOutput, - readOrcadReadinessCommand, - type OrcadLaunchSpec, - type OrcadReadinessParse -} from './orcad-remote-launch' -import { - execOrcadRemote, - launchOrcadAndAwaitReadiness, - type OrcadRemoteExecTarget -} from './orcad-remote-runtime-control' -import type { ServeReadiness } from '../server/serve-readiness' - -/** `exited` is proven absence; `unverifiable` means the host could not say, which is not death. */ -export type OrcadReadinessFailureVerdict = 'exited' | 'unverifiable' | 'rejected' - -export class OrcadActiveReadinessError extends Error { - constructor( - readonly verdict: OrcadReadinessFailureVerdict, - message: string - ) { - super(message) - this.name = 'OrcadActiveReadinessError' - } -} - -function gatedReadiness( - parsed: OrcadReadinessParse, - expectation: OrcadActivationExpectation, - label: string -): ServeReadiness { - const readiness = parsed.state === 'ready' ? parsed.readiness : null - const verdict = evaluateOrcadActivation(readiness, expectation) - if (verdict.decision === 'reject') { - throw new OrcadActiveReadinessError( - 'rejected', - `${label} failed its readiness check: ${verdict.reason}` - ) - } - if (!readiness) { - throw new OrcadActiveReadinessError( - 'rejected', - `${label} passed activation without a readiness payload.` - ) - } - const coverage = orcadDaemonCoverageRefusal(readiness) - if (coverage) { - throw new OrcadActiveReadinessError('rejected', `${label} ${coverage}`) - } - return readiness -} - -/** - * A slot proves terminals only when its daemon's self-test spawned a PTY, or completed the - * handshake on a platform whose daemon never spawn-probes. A build that predates the coverage - * field keeps the identity gate alone, so an older slot is not stranded. - */ -export function orcadDaemonCoverageRefusal(readiness: ServeReadiness): string | null { - const health = readiness.health - const coverage = health?.terminalDaemon?.selfTest?.coverage - if (!health || coverage === undefined || coverage === 'pty-spawn') { - return null - } - if (coverage === 'handshake' && health.platform === 'win32') { - return null - } - return ( - `reported terminal-daemon coverage '${String(coverage)}', which does not prove a PTY can ` + - `be created on ${health.platform}.` - ) -} - -/** Checks a recorded-active slot without starting anything. */ -export async function probeActiveOrcadReadiness( - target: OrcadRemoteExecTarget & { remoteInstallDir: string }, - expectation: OrcadActivationExpectation -): Promise { - const liveness = parseOrcadLiveness( - await execOrcadRemote(target, orcadLivenessProbeCommand(target.host, target.remoteInstallDir)) - ) - if (liveness === 'DEAD') { - throw new OrcadActiveReadinessError( - 'exited', - `orcad ${expectation.fullVersion} is recorded active but its process has exited.` - ) - } - if (liveness !== 'LIVE') { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${expectation.fullVersion} process state is unverifiable.` - ) - } - const parsed = parseOrcadReadinessOutput( - await execOrcadRemote(target, readOrcadReadinessCommand(target.host, target.remoteInstallDir)) - ) - return gatedReadiness(parsed, expectation, 'The active orcad') -} - -/** Starts a slot (recovery or rollback) and accepts it only if it proves the expected build. */ -export async function launchOrcadSlotAndAwaitReadiness( - target: OrcadRemoteExecTarget & { - readinessTimeoutMs?: number - sleep?: (ms: number) => Promise - }, - spec: OrcadLaunchSpec, - expectation: OrcadActivationExpectation -): Promise { - const parsed = await launchOrcadAndAwaitReadiness(target, spec) - return gatedReadiness(parsed, expectation, `orcad ${spec.fullVersion}`) -} diff --git a/src/main/ssh/orcad-daemon-protocol-crossing.ts b/src/main/ssh/orcad-daemon-protocol-crossing.ts deleted file mode 100644 index 9a2506d0896..00000000000 --- a/src/main/ssh/orcad-daemon-protocol-crossing.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * D7: whether live terminals survive an orcad restart onto another build. - * - * The daemon outlives every orcad restart, so after the swap the incoming build must route - * sessions owned by a daemon that may speak another protocol. It can only when it speaks that - * protocol or lists it as previous — the same rule `config/scripts/daemon-protocol-facts.mjs` - * (`canAttach`) applies to release pairs in CI. - */ -import { - PREVIOUS_DAEMON_PROTOCOL_VERSIONS, - PROTOCOL_VERSION -} from '../daemon/daemon-protocol-version' -import type { OrcadTerminalCensus } from './orcad-update-plan' - -export type OrcadDaemonProtocolFacts = { - protocolVersion: number - previousProtocolVersions: readonly number[] -} - -/** This client's build, which is also the orcad bundle it deploys. */ -export const CURRENT_ORCAD_DAEMON_PROTOCOL: OrcadDaemonProtocolFacts = { - protocolVersion: PROTOCOL_VERSION, - previousProtocolVersions: PREVIOUS_DAEMON_PROTOCOL_VERSIONS -} - -export function orcadBuildCanAttachDaemon( - reader: OrcadDaemonProtocolFacts, - ownerProtocolVersion: number -): boolean { - return ( - reader.protocolVersion === ownerProtocolVersion || - reader.previousProtocolVersions.includes(ownerProtocolVersion) - ) -} - -export type OrcadLiveDaemonCrossing = - | 'no-live-terminals' - | 'attachable' - | 'strands-live-terminals' - | 'unverifiable' - -/** An unknown session count or daemon protocol is planned for as live and unattachable. */ -export function assessOrcadLiveDaemonCrossing( - census: OrcadTerminalCensus, - incoming: OrcadDaemonProtocolFacts -): OrcadLiveDaemonCrossing { - if (census.liveSessions === 0) { - return 'no-live-terminals' - } - if (census.daemonProtocolVersion === null) { - return 'unverifiable' - } - return orcadBuildCanAttachDaemon(incoming, census.daemonProtocolVersion) - ? 'attachable' - : 'strands-live-terminals' -} diff --git a/src/main/ssh/orcad-decommission-recovery.ts b/src/main/ssh/orcad-decommission-recovery.ts deleted file mode 100644 index 4c1fecbf698..00000000000 --- a/src/main/ssh/orcad-decommission-recovery.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** Recovering an interrupted decommission from its journal entry; see orcad-decommission-transaction. */ -import { writeOrcadActivationRecord } from './orcad-activation-record-store' -import type { OrcadActivationRecoveryResult } from './orcad-activation-recovery' -import type { OrcadDecommissionRecoveryPlan } from './orcad-decommission-transaction' -import { settleOrcadDecommissionStop } from './orcad-decommission-stop' -import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import { - ensureOrcadSlotServing, - orcadSlotDir, - resolveOrcadSlotIdentity, - type OrcadSlotOptions -} from './orcad-recovery-slot' - -export async function reconcileOrcadDecommission( - options: OrcadSlotOptions, - plan: OrcadDecommissionRecoveryPlan -): Promise { - if (plan.action === 'keep-serving') { - // Nothing was sent, so the recorded version must still be the one serving. - const identity = await resolveOrcadSlotIdentity(options, plan.version) - return { - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: plan.version, - readiness: await ensureOrcadSlotServing(options, identity) - } - } - if (plan.action === 'resume-stop') { - const settlement = await settleOrcadDecommissionStop(options, plan.request) - if (settlement.state === 'unsettled') { - return { - outcome: 'refused', - verdict: settlement.verdict, - code: 'orcad_recovery_decommission_unsettled', - reason: settlement.reason - } - } - if (settlement.state === 'withdrawn') { - return reconcileOrcadDecommission(options, { - action: 'keep-serving', - version: plan.request.version - }) - } - return reconcileOrcadDecommission(options, { - action: 'confirm-decommissioned', - version: plan.request.version, - record: plan.record - }) - } - // Only proven exit commits; a slot that is live or unanswering keeps the fence. - const liveness = parseOrcadLiveness( - await execOrcadRemote( - options, - orcadLivenessProbeCommand(options.host, orcadSlotDir(options, plan.version)) - ) - ) - if (liveness !== 'DEAD') { - return { - outcome: 'refused', - verdict: liveness === 'LIVE' ? 'live' : 'unverifiable', - code: 'orcad_recovery_decommissioned_slot_not_exited', - reason: `orcad ${plan.version} is recorded as stopped but is ${liveness.toLowerCase()}.` - } - } - if (plan.record) { - await writeOrcadActivationRecord(options, plan.record) - } - return { outcome: 'recovered', resolution: 'committed', activeVersion: null, readiness: null } -} diff --git a/src/main/ssh/orcad-decommission-stop.ts b/src/main/ssh/orcad-decommission-stop.ts deleted file mode 100644 index 42e2f0fa654..00000000000 --- a/src/main/ssh/orcad-decommission-stop.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Settling a dispatched decommission stop: proven exit, a clean cancellation, or a fence. - * - * Used by the decommission and by its crash recovery, so both read the host the same way. A - * lost answer is `unverifiable`, never exit; cancelling is how a stop that did not finish is - * withdrawn, and only an orcad-confirmed cancellation lets the fence go. - */ -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { - cancelRemoteOrcadManagedStop, - completeRemoteOrcadManagedStop -} from './orcad-managed-remote-stop' -import type { OrcadSlotOptions } from './orcad-recovery-slot' -import type { - OrcadDaemonRetirementVerdict, - OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' - -export type OrcadDecommissionStopSettlement = - | { state: 'exited'; retirement: OrcadDaemonRetirementVerdict } - /** orcad never acted on the request and keeps serving; the verdict says why it was withdrawn. */ - | { state: 'withdrawn'; verdict: 'live' | 'unverifiable'; reason: string } - /** orcad began stopping, or the host could not say: the fence must stay. */ - | { state: 'unsettled'; verdict: 'live' | 'unverifiable'; reason: string } - -export async function settleOrcadDecommissionStop( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest -): Promise { - let verdict: 'live' | 'unverifiable' - let reason: string - try { - const completion = await completeRemoteOrcadManagedStop(options, request) - if (completion.verdict === 'exited') { - // A completion without a recorded outcome proves exit but not retirement. - return { state: 'exited', retirement: completion.retirement ?? 'unverifiable' } - } - verdict = completion.verdict - reason = `orcad ${request.version} did not exit (${completion.verdict}).` - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - verdict = 'unverifiable' - reason = `The host gave no verifiable stop verdict: ${errorMessage(error)}` - } - try { - const cancellation = await cancelRemoteOrcadManagedStop(options, request) - if (cancellation.outcome === 'canceled') { - return { - state: 'withdrawn', - verdict, - reason: `${reason} The stop request was withdrawn; orcad keeps serving.` - } - } - return { - state: 'unsettled', - verdict: 'live', - reason: `${reason} orcad already acted on the request and is still stopping.` - } - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return { - state: 'unsettled', - verdict: 'unverifiable', - reason: `${reason} Withdrawing the request gave no verifiable answer: ${errorMessage(error)}` - } - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-decommission-transaction.test.ts b/src/main/ssh/orcad-decommission-transaction.test.ts deleted file mode 100644 index cb49f77a0d5..00000000000 --- a/src/main/ssh/orcad-decommission-transaction.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - parseOrcadActivationTransaction, - planOrcadTransactionRecovery, - serializeOrcadActivationTransaction -} from './orcad-activation-transaction' -import { - createOrcadDecommissionTransaction, - withOrcadDecommissionProcessExited, - withOrcadDecommissionStopDispatched -} from './orcad-decommission-transaction' -import { FakeOrcadHost, NEW } from './orcad-activation-host-test-harness' -import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' - -const T = new Date('2026-02-02T00:00:00.000Z') -const ID = '7f1c2a7e-6c1b-4a8e-9f0e-0a1b2c3d4e5f' -const before = { ...FakeOrcadHost.newRecord(), active: NEW } -const prepared = createOrcadDecommissionTransaction({ - transactionId: ID, - recordBefore: before, - now: T -}) -const request: OrcadManagedStopRequest = { - schemaVersion: 1, - transactionId: ID, - version: NEW, - runtimeId: 'runtime-1', - instance: { pid: 42, startedAtMs: 5, nonce: 'nonce', lockPath: '/home/u/.orca/orcad.lock' }, - retireIdleDaemon: true -} -const dispatched = withOrcadDecommissionStopDispatched(prepared, request, T) -const exited = withOrcadDecommissionProcessExited(dispatched, T) - -describe('the decommission journal entry', () => { - it('deactivates the active version and keeps it as previous', () => { - expect(prepared.recordAfter).toMatchObject({ active: null, previous: NEW, snapshot: null }) - }) - - it('round-trips every phase', () => { - for (const transaction of [prepared, dispatched, exited]) { - expect( - parseOrcadActivationTransaction(serializeOrcadActivationTransaction(transaction)) - ).toEqual({ state: 'ok', transaction }) - } - }) - - it.each([ - ['a dispatched phase without its request', { ...dispatched, request: null }], - ['a request before dispatch', { ...prepared, request }], - [ - 'a request for another transaction', - { - ...dispatched, - request: { ...request, transactionId: '5a7e1f0c-3b2d-4e6f-9a8b-7c6d5e4f3a2b' } - } - ], - [ - 'a request for another version', - { ...dispatched, request: { ...request, version: '0.9.0+ff01' } } - ], - ['a recordAfter that still serves', { ...prepared, recordAfter: before }] - ])('reads %s as unreadable, keeping the fence', (_name, transaction) => { - expect(parseOrcadActivationTransaction(JSON.stringify(transaction))).toMatchObject({ - state: 'unreadable' - }) - }) - - it('plans recovery from what the host is known to have done', () => { - const after = prepared.recordAfter - expect(planOrcadTransactionRecovery(prepared, before)).toEqual({ - action: 'keep-serving', - version: NEW - }) - expect(planOrcadTransactionRecovery(dispatched, before)).toEqual({ - action: 'resume-stop', - request, - record: after - }) - expect(planOrcadTransactionRecovery(exited, before)).toEqual({ - action: 'confirm-decommissioned', - version: NEW, - record: after - }) - expect(planOrcadTransactionRecovery(dispatched, after)).toEqual({ - action: 'confirm-decommissioned', - version: NEW, - record: null - }) - expect(planOrcadTransactionRecovery(dispatched, { ...after, previous: null })).toMatchObject({ - action: 'refuse' - }) - }) -}) diff --git a/src/main/ssh/orcad-decommission-transaction.ts b/src/main/ssh/orcad-decommission-transaction.ts deleted file mode 100644 index 4852cb965fe..00000000000 --- a/src/main/ssh/orcad-decommission-transaction.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * The decommission entry of the activation journal: stop the active orcad with an - * instance-bound request and record that nothing serves, or put it back. - * - * Phases: `prepared` (nothing sent), `stop-dispatched` (the request may have reached the - * host), `process-exited` (exit proven by a completed-stop receipt). Only the last may commit. - */ -import { - serializeOrcadActivationRecord, - withDeactivatedVersion, - type OrcadActivationRecord -} from './orcad-activation-record' -import { ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION } from './orcad-activation-transaction-schema' -import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' - -export type OrcadDecommissionTransaction = { - schemaVersion: typeof ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION - transactionId: string - operation: 'decommission' - phase: 'prepared' | 'stop-dispatched' | 'process-exited' - startedAt: string - updatedAt: string - activeVersion: string - recordBefore: OrcadActivationRecord - recordAfter: OrcadActivationRecord - request: OrcadManagedStopRequest | null -} - -export type OrcadDecommissionRecoveryPlan = - /** Exit was proven; write the deactivated record (if it is not there yet) and confirm. */ - | { action: 'confirm-decommissioned'; version: string; record: OrcadActivationRecord | null } - /** The request may have reached orcad; its completion or cancellation decides. */ - | { action: 'resume-stop'; request: OrcadManagedStopRequest; record: OrcadActivationRecord } - /** Nothing was sent; the active version must still be serving. */ - | { action: 'keep-serving'; version: string } - -export function createOrcadDecommissionTransaction(options: { - transactionId: string - recordBefore: OrcadActivationRecord & { active: string } - now: Date -}): OrcadDecommissionTransaction { - const timestamp = options.now.toISOString() - return { - schemaVersion: ORCAD_ACTIVATION_TRANSACTION_SCHEMA_VERSION, - transactionId: options.transactionId, - operation: 'decommission', - phase: 'prepared', - startedAt: timestamp, - updatedAt: timestamp, - activeVersion: options.recordBefore.active, - recordBefore: options.recordBefore, - recordAfter: withDeactivatedVersion(options.recordBefore), - request: null - } -} - -export function withOrcadDecommissionStopDispatched( - transaction: OrcadDecommissionTransaction, - request: OrcadManagedStopRequest, - now: Date -): OrcadDecommissionTransaction { - return { ...transaction, phase: 'stop-dispatched', updatedAt: now.toISOString(), request } -} - -export function withOrcadDecommissionProcessExited( - transaction: OrcadDecommissionTransaction, - now: Date -): OrcadDecommissionTransaction { - return { ...transaction, phase: 'process-exited', updatedAt: now.toISOString() } -} - -/** A reason when the parsed journal is not a coherent decommission; otherwise `null`. */ -export function orcadDecommissionTransactionDefect( - transaction: OrcadDecommissionTransaction -): string | null { - if (transaction.recordBefore.active !== transaction.activeVersion) { - return 'decommission version does not match recordBefore' - } - if ( - serializeOrcadActivationRecord(transaction.recordAfter) !== - serializeOrcadActivationRecord(withDeactivatedVersion(transaction.recordBefore)) - ) { - return 'recordAfter is not the deactivated recordBefore' - } - if (transaction.request && transaction.request.version !== transaction.activeVersion) { - return 'stop request names another version' - } - return null -} - -/** Called once the current record matched one side of the transaction. */ -export function planOrcadDecommissionRecovery( - transaction: OrcadDecommissionTransaction, - committed: boolean -): OrcadDecommissionRecoveryPlan { - if (committed) { - return { action: 'confirm-decommissioned', version: transaction.activeVersion, record: null } - } - if (transaction.phase === 'process-exited') { - return { - action: 'confirm-decommissioned', - version: transaction.activeVersion, - record: transaction.recordAfter - } - } - if (transaction.phase === 'stop-dispatched' && transaction.request) { - return { - action: 'resume-stop', - request: transaction.request, - record: transaction.recordAfter - } - } - return { action: 'keep-serving', version: transaction.activeVersion } -} diff --git a/src/main/ssh/orcad-gc-transaction-pins.ts b/src/main/ssh/orcad-gc-transaction-pins.ts deleted file mode 100644 index 4c90e6be434..00000000000 --- a/src/main/ssh/orcad-gc-transaction-pins.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * What an in-flight activation, rollback or decommission still needs from GC. - * - * The journal names every slot the transaction may restart or settle; GC must keep them all. - * A held fence without a journal, or a journal this client cannot read, means a transaction - * this client cannot see into, so GC keeps everything rather than guess. - */ -import { remoteInstallDirName, ORCAD_INSTALL_MODEL } from './remote-install-model' -import type { OrcadActivationTransaction } from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { orcadActivationFenceExists } from './orcad-activation-lock' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' -import type { OrcadRemoteExecTarget } from './orcad-remote-runtime-control' - -export type OrcadGcTransactionPins = { state: 'pinned'; dirNames: string[] } | { state: 'keep-all' } - -function transactionVersions(transaction: OrcadActivationTransaction): (string | null)[] { - const records = [transaction.recordBefore, transaction.recordAfter] - const fromRecords = records.flatMap((record) => (record ? [record.active, record.previous] : [])) - if (transaction.operation === 'activate') { - return [...fromRecords, transaction.candidateVersion] - } - // Any other operation names its slots through its records. - return transaction.operation === 'rollback' - ? [...fromRecords, transaction.incumbentVersion, transaction.targetVersion] - : fromRecords -} - -export async function readOrcadGcTransactionPins( - target: OrcadRemoteExecTarget & { remoteHome: string } -): Promise { - try { - const transaction = await readOrcadActivationTransaction(target) - if (!transaction) { - // A fence without a journal is a transaction starting or a release cut short. - return (await orcadActivationFenceExists(target)) - ? { state: 'keep-all' } - : { state: 'pinned', dirNames: [] } - } - const versions = transactionVersions(transaction).filter( - (version): version is string => typeof version === 'string' && version.length > 0 - ) - return { - state: 'pinned', - dirNames: [...new Set(versions)].map((version) => - remoteInstallDirName(ORCAD_INSTALL_MODEL, version) - ) - } - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return { state: 'keep-all' } - } -} diff --git a/src/main/ssh/orcad-incumbent-recovery.ts b/src/main/ssh/orcad-incumbent-recovery.ts deleted file mode 100644 index 5e68778c1a3..00000000000 --- a/src/main/ssh/orcad-incumbent-recovery.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Putting a host back to the slot and state it had before an activation or rollback. - * - * Shared by the in-flight failure paths and by crash recovery, so both apply one rule: state - * a launched slot may have changed is replaced only after that slot is proven exited, and only - * when the change provably carries no terminals — the slot exposed RPC, so a pre-launch census - * cannot vouch for it. - */ -import type { ServeReadiness } from '../server/serve-readiness' -import { RELAY_REMOTE_DIR } from './relay-protocol' -import { ORCAD_STATE_SNAPSHOT_DIR } from './orcad-activation-record' -import type { OrcadSnapshotVerdict } from './orcad-activation-transaction' -import { - clearOrcadStateSnapshotMembersCommand, - compareOrcadStateSnapshotCommand, - orcadSnapshotIsUnchanged, - parseOrcadSnapshotRestore, - restoreOrcadStateSnapshotCommand -} from './orcad-state-snapshot' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import { - ensureOrcadSlotServing, - launchOrcadSlot, - quiesceInterruptedOrcadSlot, - type OrcadSlotIdentity, - type OrcadSlotOptions -} from './orcad-recovery-slot' -import { joinRemotePath } from './ssh-remote-platform' - -export type OrcadIncumbentRecoveryOptions = OrcadSlotOptions & { - /** - * Terminals started on the host since `since`, from a census taken now. Absent when no fresh - * census is available; `null` when the daemon did not answer. Both keep changed state. - */ - terminalsStartedSince?: (since: string) => Promise -} - -export type OrcadIncumbentRecovery = - | { outcome: 'restored'; readiness: ServeReadiness | null } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export function orcadSnapshotPath(options: OrcadSlotOptions, dirName: string): string { - return joinRemotePath( - options.host, - options.remoteHome, - RELAY_REMOTE_DIR, - ORCAD_STATE_SNAPSHOT_DIR, - dirName - ) -} - -/** Throws when a step cannot be verified; the caller keeps the fence. */ -export async function recoverOrcadIncumbent( - options: OrcadIncumbentRecoveryOptions, - input: { - transactionStartedAt: string - launchedVersion: string | null - incumbent: OrcadSlotIdentity | null - restoreState: OrcadSnapshotVerdict | null - /** This run itself proved both slots exited, so no fresh liveness probe is needed. */ - slotsProvenExited?: boolean - } -): Promise { - const quiescence = - input.slotsProvenExited || !input.restoreState - ? 'exited' - : await quiesceInterruptedOrcadSlot(options, input.launchedVersion, input.incumbent) - // With no incumbent there is no older reader to protect; the record names nothing to serve. - if (quiescence === 'exited' && input.restoreState && input.incumbent) { - const decision = input.launchedVersion - ? await decideChangedStateRestore(options, input.transactionStartedAt, input.restoreState) - : 'restore' - if (decision !== 'restore' && decision !== 'unchanged') { - return decision - } - if (decision === 'restore') { - await restoreState(options, input.restoreState) - } - } - if (!input.incumbent) { - return { outcome: 'restored', readiness: null } - } - return { - outcome: 'restored', - readiness: input.slotsProvenExited - ? await launchOrcadSlot(options, input.incumbent) - : await ensureOrcadSlotServing(options, input.incumbent) - } -} - -async function decideChangedStateRestore( - options: OrcadIncumbentRecoveryOptions, - since: string, - state: OrcadSnapshotVerdict -): Promise<'unchanged' | 'restore' | Extract> { - if (state.state === 'captured') { - const snapshotDir = orcadSnapshotPath(options, state.dirName) - // Read-only, so a lost answer is just "changed". - const comparison = await execOrcadRemote( - options, - compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) - ).catch(() => '') - if (orcadSnapshotIsUnchanged(comparison)) { - return 'unchanged' - } - } - const started = options.terminalsStartedSince - ? await options.terminalsStartedSince(since) - : undefined - if (started === 0) { - return 'restore' - } - const retained = - state.state === 'captured' ? ` at ${orcadSnapshotPath(options, state.dirName)}` : '' - return started === undefined || started === null - ? { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_census_required', - reason: - 'The launched build is stopped, but profile state changed or could not be verified, ' + - 'so the previous build was not restarted against it. Current state and daemon ' + - 'terminals are preserved; recovery requires a fresh host terminal census before ' + - `restoring the prelaunch snapshot${retained}.` - } - : { - outcome: 'refused', - verdict: 'live', - code: 'orcad_recovery_orphans_live_terminals', - reason: - `${started} terminal${started === 1 ? '' : 's'} started after the interrupted ` + - 'change, and the prelaunch snapshot does not describe them. Restoring it would ' + - `orphan running work; state is preserved${retained}.` - } -} - -async function restoreState(options: OrcadSlotOptions, state: OrcadSnapshotVerdict): Promise { - if (state.state === 'pending') { - throw new Error('The interrupted transaction has no durable snapshot verdict.') - } - const command = - state.state === 'captured' - ? restoreOrcadStateSnapshotCommand( - options.host, - options.userDataDir, - orcadSnapshotPath(options, state.dirName) - ) - : clearOrcadStateSnapshotMembersCommand(options.host, options.userDataDir) - const restored = parseOrcadSnapshotRestore(await execOrcadRemote(options, command)) - if (restored !== 'restored') { - throw new Error(`The prelaunch state could not be restored (${restored}).`) - } -} diff --git a/src/main/ssh/orcad-initial-activation-admission.ts b/src/main/ssh/orcad-initial-activation-admission.ts deleted file mode 100644 index cfa85da4dab..00000000000 --- a/src/main/ssh/orcad-initial-activation-admission.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Before the first managed activation, prove no unmanaged Orca runtime owns the data root. - * - * With no active record there is no incumbent to stop, but a hand-started orcad or headless - * Orca may still hold the shared root. Its owner records name a PID; a live, unreadable or - * unexpected record defers rather than starting a second owner beside it. - */ -import { ORCAD_LOCK_FILE_NAME } from '../orcad/orcad-instance-lock' -import { PRIMARY_RUNTIME_METADATA_FILE } from '../../shared/runtime-bootstrap' -import { shellEscape } from './ssh-connection-utils' -import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -const OWNER_RECORD_MAX_BYTES = 64 * 1024 - -export type OrcadInitialActivationAdmission = - | { decision: 'proceed' } - | { decision: 'defer'; code: string; reason: string } - -/** Runs on the candidate slot's own runtime, so the probe needs no host Node. */ -export function initialOrcadActivationAdmissionCommand( - host: RemoteHostPlatform, - userDataDir: string, - remoteInstallDir: string, - legacyNodePath: string -): string { - const owners = [ORCAD_LOCK_FILE_NAME, PRIMARY_RUNTIME_METADATA_FILE].map((name) => ({ - name, - path: joinRemotePath(host, userDataDir, name) - })) - const script = [ - 'const fs=require("node:fs");', - `const limit=${OWNER_RECORD_MAX_BYTES};`, - 'const owners=JSON.parse(process.argv[1]??"[]");', - 'const invalid=(owner)=>`UNVERIFIABLE ${owner.name}`;', - 'function probe(owner){let fd;', - 'try{const noFollow=fs.constants.O_NOFOLLOW;', - 'if(typeof noFollow!=="number")return invalid(owner);', - 'fd=fs.openSync(owner.path,fs.constants.O_RDONLY|noFollow);', - 'const before=fs.fstatSync(fd);', - 'if(!before.isFile()||before.size>limit)return invalid(owner);', - 'const buffer=Buffer.alloc(limit+1);let bytes=0;', - 'while(byteslimit||bytes!==after.size||before.size!==after.size||before.mtimeMs!==after.mtimeMs)', - 'return invalid(owner);', - 'const record=JSON.parse(buffer.subarray(0,bytes).toString("utf8"));', - 'const pid=record?.pid;', - 'if(!Number.isSafeInteger(pid)||pid<=0)return invalid(owner);', - 'try{process.kill(pid,0);return `LIVE ${owner.name} ${pid}`;}', - 'catch(error){if(error?.code==="ESRCH")return null;', - 'if(error?.code==="EPERM")return `LIVE ${owner.name} ${pid}`;', - 'return invalid(owner);}}', - 'catch(error){return error?.code==="ENOENT"?null:invalid(owner);}', - 'finally{if(fd!==undefined){try{fs.closeSync(fd);}catch{}}}}', - 'for(const owner of owners){const result=probe(owner);', - 'if(result){console.log(result);process.exit(0);}}console.log("CLEAR");' - ].join('') - // The subshell turns the selector's `exit 78` into silence, which parses as unverifiable. - return ( - `(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, legacyNodePath)}; ` + - `"$orcad_runtime" -e ${shellEscape(script)} ${shellEscape(JSON.stringify(owners))})` - ) -} - -export function parseInitialOrcadActivationAdmission( - output: string -): OrcadInitialActivationAdmission { - const result = output.trim().split(/\r?\n/u).pop()?.trim() ?? '' - if (result === 'CLEAR') { - return { decision: 'proceed' } - } - const live = /^LIVE ([^ ]+) ([1-9][0-9]*)$/u.exec(result) - if (live) { - return { - decision: 'defer', - code: 'orcad_initial_runtime_live', - reason: - `An unmanaged runtime owner is still live according to ${live[1]} (pid ${live[2]}). ` + - 'Stop that Orca runtime before converting this data root to managed orcad.' - } - } - const record = /^UNVERIFIABLE ([^ ]+)$/u.exec(result)?.[1] ?? 'owner record' - return { - decision: 'defer', - code: 'orcad_initial_runtime_unverifiable', - reason: - `The host could not safely interpret ${record}, so it cannot prove the shared data root ` + - 'is quiescent. Preserve the file, verify its owner on the host, and retry after the owner exits.' - } -} diff --git a/src/main/ssh/orcad-installed-activation.ts b/src/main/ssh/orcad-installed-activation.ts deleted file mode 100644 index d3960508052..00000000000 --- a/src/main/ssh/orcad-installed-activation.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * The locked half of a deploy: stop, snapshot, start and commit, journaled at every step. - * - * The journal is durable before the first mutation, so a crash at any point leaves enough on - * the host for `recoverInterruptedOrcadActivation` to finish or undo it. A run that ends with - * the host provably back on one slot drops the fence; one that cannot prove it keeps it. - */ -import { randomUUID } from 'node:crypto' -import type { OrcadDeployOptions, OrcadDeployResult } from './orcad-remote-deploy' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { withActivatedVersion, type OrcadStateSnapshot } from './orcad-activation-record' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { evaluateOrcadActivation } from './orcad-activation-gate' -import { planOrcadUpdate } from './orcad-update-plan' -import { CURRENT_ORCAD_DAEMON_PROTOCOL } from './orcad-daemon-protocol-crossing' -import { ORCAD_LOG_FILENAME, type OrcadReadinessParse } from './orcad-remote-launch' -import { - captureOrcadStateSnapshotCommand, - orcadSnapshotDirName, - parseOrcadSnapshotCapture -} from './orcad-state-snapshot' -import { orcadStopFreedTheHost } from './orcad-remote-process-control' -import { joinRemotePath } from './ssh-remote-platform' -import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' -import { preflightInstalledOrcad } from './orcad-remote-preflight' -import type { OrcadActivationLockControl } from './orcad-activation-lock' -import type { OrcadActivateTransaction } from './orcad-activation-transaction' -import { - createOrcadActivationTransaction, - withOrcadActivationCandidateReady, - withOrcadActivationIncumbentStopped, - withOrcadActivationSnapshot -} from './orcad-activation-transaction-transitions' -import { - readOrcadActivationTransaction, - writeOrcadActivationTransaction -} from './orcad-activation-transaction-store' -import { - execOrcadRemote, - launchOrcadAndAwaitReadiness, - withoutAbortSignal -} from './orcad-remote-runtime-control' -import { - initialOrcadActivationAdmissionCommand, - parseInitialOrcadActivationAdmission -} from './orcad-initial-activation-admission' -import { - launchOrcadSlot, - orcadSlotDir, - resolveOrcadSlotIdentity, - stopOrcadSlot, - ORCAD_SLOT_STOP_WAIT_SECONDS, - type OrcadSlotIdentity -} from './orcad-recovery-slot' -import { orcadSnapshotPath, recoverOrcadIncumbent } from './orcad-incumbent-recovery' - -type Outcome = Extract - -export async function activateInstalledOrcad( - options: OrcadDeployOptions & { localOrcadDir: string }, - fullVersion: string, - remoteDir: string, - lock: OrcadActivationLockControl -): Promise { - const now = options.now ?? ((): Date => new Date()) - const notActivated = (code: string, reason: string): Outcome => ({ - outcome: 'installed-not-activated', - fullVersion, - code, - reason - }) - if (await readOrcadActivationTransaction(options)) { - // Holding the lock proves its writer is gone, but undoing its work needs a fresh census. - lock.retain() - return notActivated( - 'orcad_activation_recovery_required', - 'An earlier activation on this host was interrupted. Recover it before deploying again.' - ) - } - const record = await readOrcadActivationRecord(options) - const plan = planOrcadUpdate({ - record, - candidateVersion: fullVersion, - census: options.census, - candidateDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL, - ...(options.force !== undefined ? { force: options.force } : {}) - }) - if (plan.action === 'noop') { - return { outcome: 'already-active', fullVersion } - } - if (plan.action === 'defer') { - return notActivated(plan.code, plan.reason) - } - - try { - await preflightInstalledOrcad({ ...options, remoteInstallDir: remoteDir, fullVersion }) - } catch (error) { - options.signal?.throwIfAborted() - return notActivated( - 'orcad_candidate_preflight_failed', - `Candidate profile preflight failed; the incumbent was not stopped: ${errorMessage(error)}` - ) - } - - let incumbent: OrcadSlotIdentity | null = null - if (record.active) { - try { - incumbent = await resolveOrcadSlotIdentity(options, record.active) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return notActivated( - 'orcad_incumbent_identity_unverifiable', - `The active orcad ${record.active} build identity could not be verified before ` + - `stopping it: ${errorMessage(error)} Nothing was stopped.` - ) - } - } else { - const admission = parseInitialOrcadActivationAdmission( - await execOrcadRemote( - options, - initialOrcadActivationAdmissionCommand( - options.host, - options.userDataDir, - remoteDir, - options.nodePath - ) - ).catch((error: unknown) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return '' - }) - ) - if (admission.decision === 'defer') { - return notActivated(admission.code, admission.reason) - } - } - - const startedAt = now() - let transaction: OrcadActivateTransaction = createOrcadActivationTransaction({ - transactionId: randomUUID(), - candidateVersion: fullVersion, - recordBefore: record, - snapshotDirName: orcadSnapshotDirName(fullVersion, startedAt.getTime()), - now: startedAt - }) - await writeOrcadActivationTransaction(options, transaction) - lock.retainOnError() - - if (incumbent) { - const stopped = await stopOrcadSlot(options, incumbent.remoteDir, false) - if (!orcadStopFreedTheHost(stopped)) { - // Only a delivered SIGTERM can still change the host; otherwise nothing happened. - if (stopped === 'still-running') { - lock.retain() - } - return notActivated( - 'orcad_outgoing_stop_incomplete', - `Could not verify that orcad ${incumbent.version} exited within ` + - `${ORCAD_SLOT_STOP_WAIT_SECONDS}s (${stopped}). No snapshot was taken and the ` + - 'candidate was not started. Orca requires matching runtime readiness before ' + - 'signaling an incumbent and confirmed exit before snapshotting.' - ) - } - } - transaction = withOrcadActivationIncumbentStopped(transaction, now()) - await writeOrcadActivationTransaction(options, transaction) - - // A live SQLite WAL is not a backup boundary, so the snapshot waits for confirmed exit. - const snapshotDir = orcadSnapshotPath(options, transaction.snapshot.dirName) - const capture = parseOrcadSnapshotCapture( - await execOrcadRemote( - options, - captureOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) - ).catch((error: unknown) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return 'FAILED' - }) - ) - if (capture === 'failed') { - const restarted = incumbent - ? ` The incumbent was stopped before snapshotting; ${await restartAfterSnapshotFailure(options, incumbent, lock)}` - : '' - throw new Error( - `Could not snapshot ${options.userDataDir} before activating ${fullVersion}. Orca's ` + - 'persisted state carries no schema version, so without a snapshot a rollback has no ' + - `way back. Refusing to activate.${restarted}` - ) - } - const snapshot: OrcadStateSnapshot | null = - capture === 'captured' - ? { - dirName: transaction.snapshot.dirName, - takenBeforeVersion: fullVersion, - readableByVersion: record.active, - takenAt: startedAt.toISOString() - } - : null - transaction = withOrcadActivationSnapshot(transaction, capture, now()) - await writeOrcadActivationTransaction(options, transaction) - - let parsed: OrcadReadinessParse | null = null - let launchError: unknown - try { - parsed = await launchOrcadAndAwaitReadiness(options, { - remoteInstallDir: remoteDir, - nodePath: options.nodePath, - fullVersion, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - launchError = error - } - const verdict = evaluateOrcadActivation(parsed?.state === 'ready' ? parsed.readiness : null, { - buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), - fullVersion - }) - if (verdict.decision === 'reject') { - const [code, reason] = - launchError === undefined - ? [verdict.code, verdict.reason] - : [ - 'orcad_candidate_launch_failed', - `The candidate failed while starting: ${errorMessage(launchError)}` - ] - const restored = await restoreAfterRejectedCandidate(options, transaction, incumbent, lock) - return notActivated( - code, - `${reason} Candidate stderr is at ` + - `${joinRemotePath(options.host, remoteDir, ORCAD_LOG_FILENAME)}. ${restored}` - ) - } - - const recordAfter = withActivatedVersion(record, fullVersion, snapshot, now()) - transaction = withOrcadActivationCandidateReady(transaction, recordAfter, now()) - await writeOrcadActivationTransaction(options, transaction) - await writeOrcadActivationRecord(options, recordAfter) - return { outcome: 'installed-and-activated', fullVersion, verdict } -} - -async function restartAfterSnapshotFailure( - options: OrcadDeployOptions, - incumbent: OrcadSlotIdentity, - lock: OrcadActivationLockControl -): Promise { - try { - await launchOrcadSlot(withoutAbortSignal(options), incumbent) - lock.recovered() - return `orcad ${incumbent.version} was restarted and is serving again.` - } catch (error) { - lock.retain() - return `restarting orcad ${incumbent.version} failed: ${errorMessage(error)} This host requires recovery.` - } -} - -/** Stop the candidate this run launched, then put the incumbent back only on safe state. */ -async function restoreAfterRejectedCandidate( - options: OrcadDeployOptions, - transaction: OrcadActivateTransaction, - incumbent: OrcadSlotIdentity | null, - lock: OrcadActivationLockControl -): Promise { - const recoveryOptions = withoutAbortSignal(options) - try { - const candidateDir = orcadSlotDir(options, transaction.candidateVersion) - const stopped = await stopOrcadSlot(recoveryOptions, candidateDir, true) - if (!orcadStopFreedTheHost(stopped)) { - lock.retain() - return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` - } - const recovery = await recoverOrcadIncumbent(recoveryOptions, { - transactionStartedAt: transaction.startedAt, - launchedVersion: transaction.candidateVersion, - incumbent, - restoreState: transaction.snapshot, - slotsProvenExited: true - }) - if (recovery.outcome === 'refused') { - lock.retain() - return recovery.reason - } - return incumbent - ? `orcad ${incumbent.version} was restarted and is serving again.` - : 'No previous version was active, so this host is now serving nothing.' - } catch (error) { - lock.retain() - return `Restoring the previous version failed: ${errorMessage(error)} This host requires recovery.` - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-managed-lifecycle-test-fixture.ts b/src/main/ssh/orcad-managed-lifecycle-test-fixture.ts deleted file mode 100644 index 37d27cd3c9a..00000000000 --- a/src/main/ssh/orcad-managed-lifecycle-test-fixture.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import type { SshTarget } from '../../shared/ssh-types' -import type { ServeReadiness } from '../server/serve-readiness' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -export const MANAGED_VERSION = '0.2.0+abc' -export const MANAGED_PREVIOUS_VERSION = '0.1.0+def' -export const MANAGED_LOCAL_PORT = 46_768 - -/** A managed server registered in a temp profile, with its claimed SSH target. */ -export function createManagedLifecycleHarness() { - const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-managed-lifecycle-')) - let target: SshTarget = { - id: 'ssh-1', - label: 'Builder', - host: 'builder', - port: 22, - username: 'dev', - generation: 4, - owner: createManagedOrcadSshOwner('environment-1') - } - const environment = addManagedOrcadEnvironment(userDataPath, { - id: 'environment-1', - name: 'Managed', - pairingCode: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint: `ws://127.0.0.1:${MANAGED_LOCAL_PORT}/`, - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }), - orcadDeployment: { - sshTargetId: 'ssh-1', - sshTargetGeneration: 4, - localPort: MANAGED_LOCAL_PORT, - remotePort: 6_768 - } - }) - const flushes: number[] = [] - const claims = new SshTargetOrcadClaims({ - ...emptyDependentStateStore(), - allocateSshTargetGeneration: () => 5, - flushPendingOrThrowAsync: async () => { - flushes.push(flushes.length) - }, - getFolderWorkspaces: () => [], - getRepos: () => [], - getSshTarget: (id) => (id === target.id ? target : undefined), - getSshTargets: () => [target], - updateSshTarget: (_id, updates) => (target = { ...target, ...updates }) - }) - const targetStore = { - getTarget: (id: string) => (id === target.id ? target : undefined), - getOrcadRuntimeClaims: () => claims - } - const context = (record: Record = {}) => ({ - activationRecord: { - active: MANAGED_VERSION, - previous: MANAGED_PREVIOUS_VERSION, - activatedAt: '2026-01-01T00:00:00.000Z', - snapshot: null, - ...record - }, - serverTarget: 'linux-x64-glibc', - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target, - userDataDir: '/home/dev/.orca' - }) - return { userDataPath, environment, targetStore, context, flushes, current: () => target } -} - -/** Readiness whose pairing offer, once tunneled, matches the harness server's saved one. */ -export function managedReadiness(deviceToken = 'device-token'): ServeReadiness { - const endpoint = 'ws://127.0.0.1:6768' - return { - runtimeId: 'runtime-1', - boundEndpoint: endpoint, - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { - available: true, - url: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken, - publicKeyB64: 'public-key' - }), - endpoint, - deviceId: 'device-1', - webClientUrl: null, - scope: 'runtime', - qr: null - } - } -} diff --git a/src/main/ssh/orcad-managed-remote-stop.ts b/src/main/ssh/orcad-managed-remote-stop.ts deleted file mode 100644 index 1200192d9db..00000000000 --- a/src/main/ssh/orcad-managed-remote-stop.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Stopping one remote orcad instance through its instance-bound request, never a signal. - * - * The client names the instance from two host records that must agree: the slot's readiness - * payload (runtime ID, PID, capability) and the data root's instance lock (PID, start time, - * nonce). The slot's own `orcad.js` then writes the request and proves exit on the host. - */ -import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { parseOrcadReadinessOutput, readOrcadReadinessCommand } from './orcad-remote-launch' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import { readBoundedOrcadRemoteRecord } from './orcad-remote-record-file' -import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot' -import { joinRemotePath } from './ssh-remote-platform' -import { ORCAD_LOCK_FILE_NAME, parseOrcadInstanceLockRecord } from '../orcad/orcad-instance-lock' -import { - ORCAD_CANCEL_MANAGED_STOP_FLAG, - ORCAD_COMPLETE_MANAGED_STOP_FLAG, - ORCAD_STOP_REQUESTS_CAPABILITY, - OrcadManagedStopCancellationSchema, - OrcadManagedStopCompletionSchema, - type OrcadManagedStopCancellation, - type OrcadManagedStopCompletion, - type OrcadManagedStopContext, - type OrcadManagedStopRequest -} from '../../shared/orcad-stop-request' - -const LOCK_RECORD_MAX_BYTES = 64 * 1024 - -export type OrcadManagedStopTarget = - | { state: 'ready'; context: OrcadManagedStopContext } - | { state: 'refused'; verdict: 'unverifiable'; code: string; reason: string } - -function refused(code: string, reason: string): OrcadManagedStopTarget { - return { state: 'refused', verdict: 'unverifiable', code, reason } -} - -/** Names the running instance of `version`, or says why the host could not prove which it is. */ -export async function readRemoteOrcadManagedStopTarget( - options: OrcadSlotOptions, - version: string -): Promise { - assertPosixOrcadHost(options.host) - const slotDir = orcadSlotDir(options, version) - const readiness = parseOrcadReadinessOutput( - await execOrcadRemote(options, readOrcadReadinessCommand(options.host, slotDir)) - ) - if (readiness.state !== 'ready' || !readiness.readiness.health) { - return refused( - 'orcad_managed_stop_readiness_unverifiable', - `orcad ${version} has no readable readiness record, so the running instance is unknown.` - ) - } - const { health, runtimeId } = readiness.readiness - if (health.stopRequests !== ORCAD_STOP_REQUESTS_CAPABILITY) { - return refused( - 'orcad_managed_stop_unsupported', - `orcad ${version} predates managed stop requests; it can only be stopped by signal.` - ) - } - const lockPath = joinRemotePath(options.host, options.userDataDir, ORCAD_LOCK_FILE_NAME) - const lockRead = await readBoundedOrcadRemoteRecord(options, lockPath, LOCK_RECORD_MAX_BYTES) - const lock = lockRead.state === 'present' ? parseOrcadInstanceLockRecord(lockRead.raw) : null - if (!lock || lock.pid !== health.pid || !runtimeId) { - return refused( - 'orcad_managed_stop_instance_unverifiable', - `The instance lock does not name the orcad ${version} that published readiness.` - ) - } - return { - state: 'ready', - context: { - version, - runtimeId, - instance: { pid: lock.pid, startedAtMs: lock.startedAtMs, nonce: lock.nonce, lockPath } - } - } -} - -export async function completeRemoteOrcadManagedStop( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest -): Promise { - return OrcadManagedStopCompletionSchema.parse( - await runSlotCommand(options, request, ORCAD_COMPLETE_MANAGED_STOP_FLAG) - ) -} - -export async function cancelRemoteOrcadManagedStop( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest -): Promise { - return OrcadManagedStopCancellationSchema.parse( - await runSlotCommand(options, request, ORCAD_CANCEL_MANAGED_STOP_FLAG) - ) -} - -/** Runs the slot's own build, which owns the request format it is asked to write. */ -async function runSlotCommand( - options: OrcadSlotOptions, - request: OrcadManagedStopRequest, - flag: string -): Promise { - const slotDir = orcadSlotDir(options, request.version) - const entry = joinRemotePath(options.host, slotDir, 'orcad.js') - const output = await execOrcadRemote( - options, - `${selectOrcadSlotRuntimeCommand(options.host, slotDir, options.nodePath)} && ` + - `"$orcad_runtime" ${shellEscape(entry)} ${flag} ${shellEscape(JSON.stringify(request))}` - ) - const line = output - .trim() - .split('\n') - .findLast((candidate) => candidate.trim().startsWith('{')) - if (!line) { - throw new Error('orcad managed stop command returned no verifiable answer') - } - const parsed: unknown = JSON.parse(line) - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('orcad managed stop command returned no verifiable answer') - } - // The answer must be about this exact request, not another transaction's. - if (!('transactionId' in parsed) || parsed.transactionId !== request.transactionId) { - throw new Error('orcad managed stop command answered another transaction') - } - return parsed -} diff --git a/src/main/ssh/orcad-managed-runtime-context.ts b/src/main/ssh/orcad-managed-runtime-context.ts deleted file mode 100644 index 37e9c5d9daa..00000000000 --- a/src/main/ssh/orcad-managed-runtime-context.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import type { - KnownRuntimeEnvironment, - OrcadDeploymentLink -} from '../../shared/runtime-environments' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { ServeReadiness } from '../server/serve-readiness' -import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' -import { probeActiveOrcadReadiness } from './orcad-active-readiness' -import { resolveOrcadRemoteContext, type OrcadRemoteContext } from './orcad-remote-context' -import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { computeRemoteInstallDir } from './ssh-relay-versioned-install' -import { getSshConnectionManager, getSshTargetRegistryStore } from './ssh-target-registry' - -export const ORCAD_BIND_HOST = '127.0.0.1' - -// Why empty: managed slots always carry their pinned runtime marker, so a marker-less slot -// fails to launch instead of silently running on whatever Node the host has. -export const MANAGED_ORCAD_LEGACY_NODE_PATH = '' - -export function requireManagedOrcadInfrastructure() { - const targetStore = requireManagedOrcadTargetStore() - const connectionManager = getSshConnectionManager() - if (!connectionManager) { - throw new Error('SSH is unavailable on this client; the managed Orca server is unverifiable.') - } - return { connectionManager, targetStore, claims: targetStore.getOrcadRuntimeClaims() } -} - -export function requireManagedOrcadTargetStore() { - const targetStore = getSshTargetRegistryStore() - if (!targetStore) { - throw new Error('SSH target state is unavailable; the managed Orca server is unverifiable.') - } - return targetStore -} - -export function requireManagedOrcadEnvironment( - userDataPath: string, - selector: string -): { environment: KnownRuntimeEnvironment; deployment: OrcadDeploymentLink } { - const environment = resolveEnvironment(userDataPath, selector) - const deployment = environment.orcadDeployment - if (!deployment || environment.connectionDependency !== 'ssh-tunnel') { - throw new Error('This server is not managed through an orcad SSH deployment.') - } - return { environment, deployment } -} - -export async function resolveLinkedOrcadContext( - environment: KnownRuntimeEnvironment, - deployment: OrcadDeploymentLink, - signal?: AbortSignal -): Promise { - const { connectionManager, targetStore } = requireManagedOrcadInfrastructure() - const target = targetStore.getTarget(deployment.sshTargetId) - if ( - !target || - target.generation !== deployment.sshTargetGeneration || - getManagedOrcadOwnerEnvironmentId(target.owner) !== environment.id - ) { - throw new Error('The managed Orca server SSH registration is no longer valid.') - } - const connection = await connectionManager.connect(target) - return resolveOrcadRemoteContext(target, connection, signal) -} - -/** The slot options every remote lifecycle step takes for this managed server. */ -export function managedOrcadSlot(context: OrcadRemoteContext, port: number, signal?: AbortSignal) { - return { - conn: context.connection, - host: context.host, - remoteHome: context.remoteHome, - nodePath: MANAGED_ORCAD_LEGACY_NODE_PATH, - userDataDir: context.userDataDir, - bindHost: ORCAD_BIND_HOST, - port, - signal - } -} - -export function managedOrcadInstallDir(context: OrcadRemoteContext, version: string): string { - return computeRemoteInstallDir( - ORCAD_INSTALL_MODEL, - context.remoteHome, - version, - context.host.pathFlavor - ) -} - -/** The active slot's readiness, accepted only if it is the build this client holds. */ -export function probeManagedOrcadReadiness( - context: OrcadRemoteContext, - localOrcadDir: string, - fullVersion: string, - signal?: AbortSignal -): Promise { - return probeActiveOrcadReadiness( - { - conn: context.connection, - host: context.host, - remoteInstallDir: managedOrcadInstallDir(context, fullVersion), - signal - }, - { buildHash: computeLocalOrcadBuildHash(localOrcadDir), fullVersion } - ) -} - -// Why only this code: deploy never has a session count to force past, so forcing an unknown -// census lands on the daemon-protocol deferral, which force cannot clear. -export function isForceableOrcadDeferral(code: string): boolean { - return code === 'orcad_update_terminals_running' -} diff --git a/src/main/ssh/orcad-managed-tunnel-resume.ts b/src/main/ssh/orcad-managed-tunnel-resume.ts deleted file mode 100644 index 4f76704ed3c..00000000000 --- a/src/main/ssh/orcad-managed-tunnel-resume.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { - getPreferredPairingOffer, - getRuntimeSshAccess, - type KnownRuntimeEnvironment, - type RuntimeSshTunnelLink -} from '../../shared/runtime-environments' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionManager } from './ssh-connection-manager' -import type { SshPortForwardManager } from './ssh-port-forward' -import type { getSshTargetRegistryStore } from './ssh-target-registry' - -export type ActiveOrcadTunnel = { - connection: SshConnection - forwardId: string - localPort: number - remotePort: number - sshTargetGeneration: number - targetId: string - transportGeneration: number -} - -export type OrcadManagedTunnelProbe = ( - environment: KnownRuntimeEnvironment, - timeoutMs: number -) => Promise - -export type OrcadManagedTunnelResumeOptions = { - attempts: number - resolveEnvironment: (environmentId: string) => KnownRuntimeEnvironment | null - timeoutMs: number -} - -type ResumeRecoveryDependencies = { - active: Map - forwards: SshPortForwardManager - getConnectionManager: () => SshConnectionManager | null - getManagerGeneration: () => number - getTargetStore: () => ReturnType - inFlight: Map> - ownershipGenerations: Map - probeTunnel?: OrcadManagedTunnelProbe -} - -type ResolvedManagedTunnelEnvironment = { - deployment: RuntimeSshTunnelLink - environment: KnownRuntimeEnvironment -} - -export class OrcadManagedTunnelResumeRecovery { - private readonly probeTunnel: OrcadManagedTunnelProbe - private resumeInFlight: Promise | null = null - - constructor(private readonly dependencies: ResumeRecoveryDependencies) { - this.probeTunnel = dependencies.probeTunnel ?? probeManagedOrcadTunnel - } - - dispose(): void { - this.resumeInFlight = null - } - - recover(options: OrcadManagedTunnelResumeOptions): Promise { - if (this.resumeInFlight) { - return this.resumeInFlight - } - const managerGeneration = this.dependencies.getManagerGeneration() - const recoveries = [...this.dependencies.active].map(([environmentId, active]) => - this.recoverActive(environmentId, active, managerGeneration, options) - ) - const operation = Promise.allSettled(recoveries) - .then((results) => { - const failed = results.find( - (result): result is PromiseRejectedResult => result.status === 'rejected' - ) - if (failed) { - throw failed.reason - } - }) - .finally(() => { - if (this.resumeInFlight === operation) { - this.resumeInFlight = null - } - }) - this.resumeInFlight = operation - return operation - } - - private async recoverActive( - environmentId: string, - active: ActiveOrcadTunnel, - managerGeneration: number, - options: OrcadManagedTunnelResumeOptions - ): Promise { - const ownershipGeneration = this.dependencies.ownershipGenerations.get(environmentId) ?? 0 - for (let attempt = 0; attempt < options.attempts; attempt++) { - const resolved = this.resolveEnvironment(environmentId, active, options) - if (!resolved) { - return - } - if (await this.probeTunnel(resolved.environment, options.timeoutMs)) { - return - } - if (!this.stillOwned(environmentId, active, ownershipGeneration, managerGeneration, true)) { - return - } - } - - const pending = this.dependencies.inFlight.get(environmentId) - if (pending) { - await pending.catch(() => undefined) - } - if (!this.stillOwned(environmentId, active, ownershipGeneration, managerGeneration, true)) { - return - } - const resolved = this.resolveEnvironment(environmentId, active, options) - const connectionManager = this.dependencies.getConnectionManager() - if ( - !resolved || - !connectionManager || - connectionManager.getConnection(active.targetId) !== active.connection - ) { - return - } - const operation = this.reconnectAndRebuild( - resolved.environment, - active, - connectionManager, - ownershipGeneration, - managerGeneration, - options - ).finally(() => { - if (this.dependencies.inFlight.get(environmentId) === operation) { - this.dependencies.inFlight.delete(environmentId) - } - }) - this.dependencies.inFlight.set(environmentId, operation) - await operation - } - - private async reconnectAndRebuild( - environment: KnownRuntimeEnvironment, - active: ActiveOrcadTunnel, - connectionManager: SshConnectionManager, - ownershipGeneration: number, - managerGeneration: number, - options: OrcadManagedTunnelResumeOptions - ): Promise { - await connectionManager.reconnect(active.targetId) - if ( - !this.stillOwned(environment.id, active, ownershipGeneration, managerGeneration, true) || - connectionManager !== this.dependencies.getConnectionManager() || - connectionManager.getConnection(active.targetId) !== active.connection || - connectionManager.getState(active.targetId)?.status !== 'connected' || - active.connection.getTransportGeneration() <= active.transportGeneration - ) { - return - } - if (!this.resolveEnvironment(environment.id, active, options)) { - return - } - - const currentActive = this.dependencies.active.get(environment.id) - if (currentActive && currentActive !== active) { - return - } - if (currentActive === active) { - await this.dependencies.forwards.removeForwardAndWait(active.forwardId) - if (this.dependencies.active.get(environment.id) === active) { - this.dependencies.active.delete(environment.id) - } - } - if (!this.stillOwned(environment.id, active, ownershipGeneration, managerGeneration, true)) { - return - } - - const current = this.resolveEnvironment(environment.id, active, options) - if (!current) { - return - } - const { deployment } = current - const transportGeneration = active.connection.getTransportGeneration() - const forward = await this.dependencies.forwards.addForward( - active.targetId, - active.connection, - deployment.localPort, - '127.0.0.1', - deployment.remotePort, - `Managed Orca server: ${current.environment.name}` - ) - if ( - forward.localPort !== deployment.localPort || - active.connection.getTransportGeneration() !== transportGeneration || - !this.stillOwned(environment.id, active, ownershipGeneration, managerGeneration, true) || - !this.resolveEnvironment(environment.id, active, options) - ) { - await this.dependencies.forwards.removeForwardAndWait(forward.id) - if (forward.localPort !== deployment.localPort) { - throw new Error('Managed Orca tunnel bound an unexpected local port after host resume.') - } - return - } - this.dependencies.active.set(environment.id, { - connection: active.connection, - forwardId: forward.id, - localPort: forward.localPort, - remotePort: forward.remotePort, - sshTargetGeneration: active.sshTargetGeneration, - targetId: active.targetId, - transportGeneration - }) - } - - private resolveEnvironment( - environmentId: string, - active: ActiveOrcadTunnel, - options: OrcadManagedTunnelResumeOptions - ): ResolvedManagedTunnelEnvironment | null { - const environment = options.resolveEnvironment(environmentId) - const deployment = environment ? getRuntimeSshAccess(environment) : undefined - const target = this.dependencies.getTargetStore()?.getTarget(active.targetId) - if ( - !environment || - environment.connectionDependency !== 'ssh-tunnel' || - !deployment || - deployment.sshTargetId !== active.targetId || - deployment.sshTargetGeneration !== active.sshTargetGeneration || - deployment.localPort !== active.localPort || - deployment.remotePort !== active.remotePort || - !target || - target.generation !== active.sshTargetGeneration || - getManagedOrcadOwnerEnvironmentId(target.owner) !== environmentId - ) { - return null - } - return { deployment, environment } - } - - private stillOwned( - environmentId: string, - active: ActiveOrcadTunnel, - ownershipGeneration: number, - managerGeneration: number, - allowMissingActive = false - ): boolean { - const current = this.dependencies.active.get(environmentId) - return ( - this.dependencies.getManagerGeneration() === managerGeneration && - (this.dependencies.ownershipGenerations.get(environmentId) ?? 0) === ownershipGeneration && - (current === active || (allowMissingActive && current === undefined)) - ) - } -} - -async function probeManagedOrcadTunnel( - environment: KnownRuntimeEnvironment, - timeoutMs: number -): Promise { - try { - await sendRemoteRuntimeRequest( - getPreferredPairingOffer(environment), - 'status.get', - undefined, - timeoutMs - ) - return true - } catch { - return false - } -} diff --git a/src/main/ssh/orcad-managed-tunnel.test.ts b/src/main/ssh/orcad-managed-tunnel.test.ts deleted file mode 100644 index 420c04cfccf..00000000000 --- a/src/main/ssh/orcad-managed-tunnel.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createEnvironmentFromPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import type { SshTarget } from '../../shared/ssh-types' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionManager } from './ssh-connection-manager' -import type { SshConnectionStore } from './ssh-connection-store' -import type { SshPortForwardManager } from './ssh-port-forward' -import { OrcadManagedTunnelManager } from './orcad-managed-tunnel' - -function createEnvironment(linkKind: 'orcadDeployment' | 'sshAccess'): KnownRuntimeEnvironment { - const paired = createEnvironmentFromPairingOffer({ - id: 'environment-1', - name: 'Managed server', - now: 1, - offer: { - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768', - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }, - connectionDependency: 'ssh-tunnel' - }) - const access = { - sshTargetId: 'ssh-1', - sshTargetGeneration: 7, - localPort: 46_768, - remotePort: 6_768 - } - return linkKind === 'orcadDeployment' - ? { ...paired, orcadDeployment: access } - : { - ...paired, - sshAccess: { - ...access, - endpointId: paired.preferredEndpointId, - previousPreferredEndpointId: paired.preferredEndpointId - } - } -} - -function setup(overrides: Partial = {}) { - const target: SshTarget = { - id: 'ssh-1', - label: 'Managed server', - host: 'example.com', - port: 22, - username: 'deploy', - generation: 7, - owner: createManagedOrcadSshOwner('environment-1'), - ...overrides - } - let transportGeneration = 3 - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double for the members the tunnel manager calls. - const connection = { - getTransportGeneration: vi.fn(() => transportGeneration) - } as unknown as SshConnection - const connect = vi.fn().mockResolvedValue(connection) - const reconnect = vi.fn().mockImplementation(async () => { - transportGeneration += 1 - }) - const getConnection = vi.fn(() => connection) - const getState = vi.fn(() => ({ status: 'connected' })) - const probeTunnel = vi.fn().mockResolvedValue(true) - const addForward = vi - .fn() - .mockImplementation( - async ( - connectionId: string, - _connection: SshConnection, - localPort: number, - _remoteHost: string, - remotePort: number - ) => ({ - id: `forward-${addForward.mock.calls.length}`, - connectionId, - localPort, - remoteHost: '127.0.0.1', - remotePort - }) - ) - const removeForwardAndWait = vi.fn().mockResolvedValue(null) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double for the members the tunnel manager calls. - const forwardManager = { - setCallbacks: vi.fn(), - addForward, - removeForwardAndWait, - dispose: vi.fn() - } as unknown as SshPortForwardManager - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double for the members the tunnel manager calls. - const connectionManager = { - connect, - getConnection, - getState, - reconnect - } as unknown as SshConnectionManager - const manager = new OrcadManagedTunnelManager({ - getConnectionManager: () => connectionManager, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the tunnel manager reads only getTarget. - getTargetStore: () => ({ getTarget: vi.fn(() => target) }) as unknown as SshConnectionStore, - forwardManager, - probeTunnel - }) - return { - addForward, - connect, - connection, - getConnection, - getState, - manager, - probeTunnel, - reconnect, - removeForwardAndWait, - target, - setTransportGeneration: (generation: number) => { - transportGeneration = generation - } - } -} - -describe('OrcadManagedTunnelManager initial binding', () => { - it.each(['close', 'dispose', 'reconnect'] as const)( - 'removes a late initial forward after %s supersedes setup', - async (action) => { - const state = setup() - state.addForward.mockImplementationOnce(async () => { - if (action === 'close') { - await state.manager.close('environment-1') - } - if (action === 'dispose') { - state.manager.dispose() - } - if (action === 'reconnect') { - state.setTransportGeneration(4) - } - return { id: 'late-initial-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await expect( - state.manager.start('environment-1', state.target, state.connection, 6_768) - ).rejects.toThrow('superseded') - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-initial-forward') - await state.manager.close('environment-1') - expect(state.removeForwardAndWait).toHaveBeenCalledTimes(1) - } - ) - - it('does not open a forward when canceled while the prior forward is closing', async () => { - const state = setup() - await state.manager.ensure(createEnvironment('sshAccess')) - state.removeForwardAndWait.mockImplementationOnce(async () => { - await state.manager.close('environment-1') - }) - - await expect( - state.manager.start('environment-1', state.target, state.connection, 6_768) - ).rejects.toThrow('superseded') - - expect(state.addForward).toHaveBeenCalledTimes(1) - }) - - it('discards an older binding without closing the newer tunnel', async () => { - const state = setup() - let finishFirst!: () => void - state.addForward.mockImplementationOnce( - () => - new Promise((resolve) => { - finishFirst = () => resolve({ id: 'old-forward', localPort: 46_768, remotePort: 6_768 }) - }) - ) - const first = state.manager.start('environment-1', state.target, state.connection, 6_768) - const rejected = expect(first).rejects.toThrow('superseded') - await vi.waitFor(() => expect(state.addForward).toHaveBeenCalledOnce()) - - await state.manager.start('environment-1', state.target, state.connection, 6_768) - finishFirst() - await rejected - - expect(state.removeForwardAndWait.mock.calls).toEqual([['old-forward']]) - await state.manager.close('environment-1') - expect(state.removeForwardAndWait.mock.calls).toEqual([['old-forward'], ['forward-2']]) - }) -}) - -describe.each(['orcadDeployment', 'sshAccess'] as const)( - 'OrcadManagedTunnelManager (%s)', - (linkKind) => { - const environment = () => createEnvironment(linkKind) - const resumeOptions = () => ({ - attempts: 2, - resolveEnvironment: () => environment(), - timeoutMs: 5_000 - }) - it('connects through the raw SSH manager and creates the exact loopback forward', async () => { - const state = setup() - - await state.manager.ensure(environment()) - - expect(state.connect).toHaveBeenCalledOnce() - if (linkKind === 'sshAccess') { - expect(environment().orcadDeployment).toBeUndefined() - } - expect(state.addForward).toHaveBeenCalledWith( - 'ssh-1', - expect.anything(), - 46_768, - '127.0.0.1', - 6_768, - 'Managed Orca server: Managed server' - ) - }) - - it('reuses a tunnel only for the same SSH transport generation', async () => { - const state = setup() - - await state.manager.ensure(environment()) - await state.manager.ensure(environment()) - expect(state.addForward).toHaveBeenCalledOnce() - - state.setTransportGeneration(4) - await state.manager.ensure(environment()) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('forward-1') - expect(state.addForward).toHaveBeenCalledTimes(2) - }) - - it.each(['ensure', 'resume'] as const)( - 'discards a forward when SSH reconnects during %s binding', - async (operation) => { - const state = setup() - if (operation === 'resume') { - await state.manager.ensure(environment()) - state.probeTunnel.mockResolvedValue(false) - } - state.addForward.mockImplementationOnce(async () => { - state.setTransportGeneration(9) - return { id: 'stale-forward', localPort: 46_768, remotePort: 6_768 } - }) - await (operation === 'resume' - ? state.manager.recoverAfterHostResume(resumeOptions()) - : state.manager.ensure(environment())) - expect(state.removeForwardAndWait).toHaveBeenCalledWith('stale-forward') - state.probeTunnel.mockClear() - await state.manager.recoverAfterHostResume(resumeOptions()) - expect(state.probeTunnel).not.toHaveBeenCalled() - await state.manager.ensure(environment()) - expect(state.addForward).toHaveBeenCalledTimes(operation === 'resume' ? 3 : 2) - } - ) - - it('fails closed when the SSH registration generation changed', async () => { - const state = setup({ generation: 8 }) - - await expect(state.manager.ensure(environment())).rejects.toThrow('removed or re-created') - expect(state.connect).not.toHaveBeenCalled() - }) - - it('fails closed when another environment owns the target', async () => { - const state = setup({ owner: createManagedOrcadSshOwner('environment-2') }) - - await expect(state.manager.ensure(environment())).rejects.toThrow('no longer owned') - expect(state.connect).not.toHaveBeenCalled() - }) - - it('coalesces concurrent tunnel preflights', async () => { - const state = setup() - let finishConnect!: () => void - state.connect.mockImplementationOnce( - () => - new Promise((resolve) => { - finishConnect = () => resolve(state.connection) - }) - ) - - const first = state.manager.ensure(environment()) - const second = state.manager.ensure(environment()) - finishConnect() - await Promise.all([first, second]) - - expect(state.connect).toHaveBeenCalledOnce() - expect(state.addForward).toHaveBeenCalledOnce() - }) - - it('does not create a forward after close cancels an in-flight connection', async () => { - const state = setup() - state.connect.mockImplementationOnce(async () => { - await state.manager.close('environment-1') - return state.connection - }) - - await state.manager.ensure(environment()) - - expect(state.addForward).not.toHaveBeenCalled() - }) - - it('rechecks SSH ownership after connection resolves', async () => { - const state = setup() - state.connect.mockImplementationOnce(async () => { - state.target.owner = createManagedOrcadSshOwner('environment-2') - return state.connection - }) - - await state.manager.ensure(environment()) - - expect(state.addForward).not.toHaveBeenCalled() - }) - - it('re-reads the saved environment after connection rather than trusting its initial snapshot', async () => { - const state = setup() - const original = environment() - let current = original - state.connect.mockImplementationOnce(async () => { - current = { - ...original, - pairingRevision: (original.pairingRevision ?? original.createdAt) + 1 - } - return state.connection - }) - - await state.manager.ensure(original, () => current) - - expect(state.addForward).not.toHaveBeenCalled() - }) - - it('removes a newly bound forward if the saved environment disappears during binding', async () => { - const state = setup() - const original = environment() - let current: KnownRuntimeEnvironment | null = original - state.addForward.mockImplementationOnce(async () => { - current = null - return { id: 'late-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await state.manager.ensure(original, () => current) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-forward') - }) - - it('removes a newly bound forward if the environment closes during binding', async () => { - const state = setup() - state.addForward.mockImplementationOnce(async () => { - await state.manager.close('environment-1') - return { id: 'late-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await state.manager.ensure(environment()) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-forward') - await state.manager.recoverAfterHostResume(resumeOptions()) - expect(state.probeTunnel).not.toHaveBeenCalled() - }) - - it('keeps a healthy managed tunnel intact after host resume', async () => { - const state = setup() - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume(resumeOptions()) - - expect(state.probeTunnel).toHaveBeenCalledOnce() - expect(state.probeTunnel).toHaveBeenCalledWith( - expect.objectContaining({ id: 'environment-1' }), - 5_000 - ) - expect(state.reconnect).not.toHaveBeenCalled() - expect(state.removeForwardAndWait).not.toHaveBeenCalled() - }) - - it('retries a failed wake probe before reconnecting', async () => { - const state = setup() - state.probeTunnel.mockResolvedValueOnce(false).mockResolvedValueOnce(true) - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume(resumeOptions()) - - expect(state.probeTunnel).toHaveBeenCalledTimes(2) - expect(state.reconnect).not.toHaveBeenCalled() - }) - - it('reconnects and rebuilds the exact persisted port after failed wake probes', async () => { - const state = setup() - state.probeTunnel.mockResolvedValue(false) - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume(resumeOptions()) - - expect(state.reconnect).toHaveBeenCalledOnce() - expect(state.reconnect).toHaveBeenCalledWith('ssh-1') - expect(state.removeForwardAndWait).toHaveBeenCalledWith('forward-1') - expect(state.addForward).toHaveBeenLastCalledWith( - 'ssh-1', - state.connection, - 46_768, - '127.0.0.1', - 6_768, - 'Managed Orca server: Managed server' - ) - - await state.manager.ensure(environment()) - expect(state.addForward).toHaveBeenCalledTimes(2) - }) - - it('coalesces concurrent host-resume recoveries', async () => { - const state = setup() - let finishProbe!: () => void - state.probeTunnel.mockImplementationOnce( - () => - new Promise((resolve) => { - finishProbe = () => resolve(true) - }) - ) - await state.manager.ensure(environment()) - - const first = state.manager.recoverAfterHostResume(resumeOptions()) - const second = state.manager.recoverAfterHostResume(resumeOptions()) - expect(second).toBe(first) - finishProbe() - await Promise.all([first, second]) - - expect(state.probeTunnel).toHaveBeenCalledOnce() - }) - - it('does not reconnect an environment closed during its wake probe', async () => { - const state = setup() - let finishProbe!: () => void - state.probeTunnel.mockImplementationOnce( - () => - new Promise((resolve) => { - finishProbe = () => resolve(false) - }) - ) - await state.manager.ensure(environment()) - - const recovery = state.manager.recoverAfterHostResume({ - ...resumeOptions(), - attempts: 1 - }) - await vi.waitFor(() => expect(state.probeTunnel).toHaveBeenCalledOnce()) - await state.manager.close('environment-1') - finishProbe() - await recovery - - expect(state.reconnect).not.toHaveBeenCalled() - expect(state.addForward).toHaveBeenCalledOnce() - }) - - it('does not rebuild when reconnect did not establish a newer transport', async () => { - const state = setup() - state.probeTunnel.mockResolvedValue(false) - state.reconnect.mockImplementationOnce(async () => undefined) - await state.manager.ensure(environment()) - - await state.manager.recoverAfterHostResume({ - ...resumeOptions(), - attempts: 1 - }) - - expect(state.reconnect).toHaveBeenCalledOnce() - expect(state.removeForwardAndWait).not.toHaveBeenCalled() - expect(state.addForward).toHaveBeenCalledOnce() - }) - - it('does not rebuild after re-pairing removes SSH access during reconnect', async () => { - const state = setup() - let current = environment() - state.probeTunnel.mockResolvedValue(false) - await state.manager.ensure(current) - state.reconnect.mockImplementationOnce(async () => { - state.setTransportGeneration(4) - current = { ...current, orcadDeployment: undefined, sshAccess: undefined } - }) - - await state.manager.recoverAfterHostResume({ - ...resumeOptions(), - resolveEnvironment: () => current - }) - - expect(state.addForward).toHaveBeenCalledOnce() - expect(state.removeForwardAndWait).not.toHaveBeenCalled() - }) - - it('removes the replacement forward when re-pairing happens during resume binding', async () => { - const state = setup() - let current = environment() - state.probeTunnel.mockResolvedValue(false) - await state.manager.ensure(current) - state.addForward.mockImplementationOnce(async () => { - current = { ...current, orcadDeployment: undefined, sshAccess: undefined } - return { id: 'late-forward', localPort: 46_768, remotePort: 6_768 } - }) - - await state.manager.recoverAfterHostResume({ - ...resumeOptions(), - resolveEnvironment: () => current - }) - - expect(state.removeForwardAndWait).toHaveBeenCalledWith('late-forward') - }) - } -) diff --git a/src/main/ssh/orcad-managed-tunnel.ts b/src/main/ssh/orcad-managed-tunnel.ts deleted file mode 100644 index a50578e170b..00000000000 --- a/src/main/ssh/orcad-managed-tunnel.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { - getRuntimeSshAccess, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import type { SshTarget } from '../../shared/ssh-types' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { SshConnection } from './ssh-connection' -import type { SshConnectionManager } from './ssh-connection-manager' -import { SshPortForwardManager } from './ssh-port-forward' -import { - OrcadManagedTunnelResumeRecovery, - type ActiveOrcadTunnel, - type OrcadManagedTunnelProbe, - type OrcadManagedTunnelResumeOptions -} from './orcad-managed-tunnel-resume' -import { getSshConnectionManager, getSshTargetRegistryStore } from './ssh-target-registry' - -type OrcadManagedTunnelDependencies = { - getConnectionManager: () => SshConnectionManager | null - getTargetStore: () => ReturnType - forwardManager?: SshPortForwardManager - probeTunnel?: OrcadManagedTunnelProbe -} - -export class OrcadManagedTunnelManager { - private readonly active = new Map() - private readonly inFlight = new Map>() - private readonly ownershipGenerations = new Map() - private readonly forwards: SshPortForwardManager - private readonly resumeRecovery: OrcadManagedTunnelResumeRecovery - private managerGeneration = 0 - - constructor(private readonly dependencies: OrcadManagedTunnelDependencies) { - this.forwards = dependencies.forwardManager ?? new SshPortForwardManager() - this.resumeRecovery = new OrcadManagedTunnelResumeRecovery({ - active: this.active, - forwards: this.forwards, - getConnectionManager: dependencies.getConnectionManager, - getManagerGeneration: () => this.managerGeneration, - getTargetStore: dependencies.getTargetStore, - inFlight: this.inFlight, - ownershipGenerations: this.ownershipGenerations, - probeTunnel: dependencies.probeTunnel - }) - this.forwards.setCallbacks({ - onForwardClosed: (entry) => { - for (const [environmentId, active] of this.active) { - if (active.forwardId === entry.id) { - this.active.delete(environmentId) - } - } - } - }) - } - - ensure( - environment: KnownRuntimeEnvironment, - resolveCurrent: () => KnownRuntimeEnvironment | null = () => environment - ): Promise { - if (!getRuntimeSshAccess(environment)) { - return Promise.resolve() - } - const pending = this.inFlight.get(environment.id) - if (pending) { - return pending - } - const operation = this.ensureManagedTunnel(environment, resolveCurrent).finally(() => { - if (this.inFlight.get(environment.id) === operation) { - this.inFlight.delete(environment.id) - } - }) - this.inFlight.set(environment.id, operation) - return operation - } - - async start( - environmentId: string, - target: SshTarget, - connection: SshConnection, - remotePort: number - ): Promise { - if (!target.generation) { - throw new Error('Managed Orca SSH target has no registration generation.') - } - const managerGeneration = this.managerGeneration - const ownershipGeneration = (this.ownershipGenerations.get(environmentId) ?? 0) + 1 - const transportGeneration = connection.getTransportGeneration() - const stillCurrent = (): boolean => - this.managerGeneration === managerGeneration && - this.ownershipGenerations.get(environmentId) === ownershipGeneration && - connection.getTransportGeneration() === transportGeneration - await this.close(environmentId) - if (!stillCurrent()) { - throw new Error('Orca SSH tunnel setup was superseded.') - } - const forward = await this.forwards.addForward( - target.id, - connection, - 0, - '127.0.0.1', - remotePort, - `Managed Orca server` - ) - if (!stillCurrent()) { - await this.forwards.removeForwardAndWait(forward.id) - throw new Error('Orca SSH tunnel setup was superseded.') - } - this.active.set(environmentId, { - connection, - forwardId: forward.id, - localPort: forward.localPort, - remotePort: forward.remotePort, - sshTargetGeneration: target.generation, - targetId: target.id, - transportGeneration - }) - return forward.localPort - } - - async close(environmentId: string): Promise { - this.ownershipGenerations.set( - environmentId, - (this.ownershipGenerations.get(environmentId) ?? 0) + 1 - ) - const active = this.active.get(environmentId) - if (!active) { - return - } - this.active.delete(environmentId) - await this.forwards.removeForwardAndWait(active.forwardId) - } - - dispose(): void { - this.managerGeneration += 1 - this.active.clear() - this.inFlight.clear() - this.ownershipGenerations.clear() - this.resumeRecovery.dispose() - this.forwards.dispose() - } - - recoverAfterHostResume(options: OrcadManagedTunnelResumeOptions): Promise { - return this.resumeRecovery.recover(options) - } - - private async ensureManagedTunnel( - environment: KnownRuntimeEnvironment, - resolveCurrent: () => KnownRuntimeEnvironment | null - ): Promise { - const deployment = getRuntimeSshAccess(environment) - if (!deployment || environment.connectionDependency !== 'ssh-tunnel') { - throw new Error('Managed orcad environment is missing its SSH tunnel dependency.') - } - const targetStore = this.dependencies.getTargetStore() - const connectionManager = this.dependencies.getConnectionManager() - if (!targetStore || !connectionManager) { - throw new Error('SSH is unavailable on this client; the managed Orca server is unverifiable.') - } - const target = targetStore.getTarget(deployment.sshTargetId) - if (!target || target.generation !== deployment.sshTargetGeneration) { - throw new Error( - 'The SSH registration for this managed Orca server was removed or re-created.' - ) - } - if (getManagedOrcadOwnerEnvironmentId(target.owner) !== environment.id) { - throw new Error('The SSH target is no longer owned by this managed Orca server.') - } - - const managerGeneration = this.managerGeneration - const ownershipGeneration = this.ownershipGenerations.get(environment.id) ?? 0 - const stillOwned = (): boolean => { - const currentTarget = targetStore.getTarget(target.id) - const currentEnvironment = resolveCurrent() - const currentAccess = currentEnvironment ? getRuntimeSshAccess(currentEnvironment) : undefined - return ( - this.managerGeneration === managerGeneration && - (this.ownershipGenerations.get(environment.id) ?? 0) === ownershipGeneration && - currentTarget?.generation === target.generation && - getManagedOrcadOwnerEnvironmentId(currentTarget?.owner) === environment.id && - currentEnvironment?.id === environment.id && - currentEnvironment.runtimeId === environment.runtimeId && - (currentEnvironment.pairingRevision ?? currentEnvironment.createdAt) === - (environment.pairingRevision ?? environment.createdAt) && - currentEnvironment.connectionDependency === 'ssh-tunnel' && - currentAccess?.sshTargetId === deployment.sshTargetId && - currentAccess.sshTargetGeneration === deployment.sshTargetGeneration && - currentAccess.localPort === deployment.localPort && - currentAccess.remotePort === deployment.remotePort - ) - } - const connection = await connectionManager.connect(target) - if (!stillOwned()) { - return - } - const transportGeneration = connection.getTransportGeneration() - const active = this.active.get(environment.id) - if ( - active?.connection === connection && - active.transportGeneration === transportGeneration && - active.targetId === target.id && - active.sshTargetGeneration === target.generation && - active.localPort === deployment.localPort && - active.remotePort === deployment.remotePort - ) { - return - } - if (active) { - await this.forwards.removeForwardAndWait(active.forwardId) - if (this.active.get(environment.id) === active) { - this.active.delete(environment.id) - } - } - if (!stillOwned()) { - return - } - const forward = await this.forwards.addForward( - target.id, - connection, - deployment.localPort, - '127.0.0.1', - deployment.remotePort, - `Managed Orca server: ${environment.name}` - ) - if (forward.localPort !== deployment.localPort) { - await this.forwards.removeForwardAndWait(forward.id) - throw new Error('Managed Orca tunnel bound an unexpected local port.') - } - if (!stillOwned() || connection.getTransportGeneration() !== transportGeneration) { - await this.forwards.removeForwardAndWait(forward.id) - return - } - this.active.set(environment.id, { - connection, - forwardId: forward.id, - localPort: forward.localPort, - remotePort: forward.remotePort, - sshTargetGeneration: target.generation, - targetId: target.id, - transportGeneration - }) - } -} - -const managedTunnels = new OrcadManagedTunnelManager({ - getConnectionManager: getSshConnectionManager, - getTargetStore: getSshTargetRegistryStore -}) - -export async function ensureOrcadManagedTunnel( - userDataPath: string, - selector: string -): Promise { - const environment = resolveEnvironment(userDataPath, selector) - await managedTunnels.ensure(environment, () => { - try { - return resolveEnvironment(userDataPath, environment.id) - } catch { - return null - } - }) -} - -export function disposeOrcadManagedTunnels(): void { - managedTunnels.dispose() -} - -export function recoverOrcadManagedTunnelsAfterHostResume( - userDataPath: string, - options: { attempts: number; timeoutMs: number } -): Promise { - return managedTunnels.recoverAfterHostResume({ - ...options, - resolveEnvironment: (environmentId) => { - try { - return resolveEnvironment(userDataPath, environmentId) - } catch { - return null - } - } - }) -} - -export function startOrcadManagedTunnel( - environmentId: string, - target: SshTarget, - connection: SshConnection, - remotePort: number -): Promise { - return managedTunnels.start(environmentId, target, connection, remotePort) -} - -export function closeOrcadManagedTunnel(environmentId: string): Promise { - return managedTunnels.close(environmentId) -} diff --git a/src/main/ssh/orcad-managed-update-deferrals.ts b/src/main/ssh/orcad-managed-update-deferrals.ts deleted file mode 100644 index 1bf0285f2b2..00000000000 --- a/src/main/ssh/orcad-managed-update-deferrals.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** The last update each managed server deferred in this session, so status can report it. */ -import type { OrcadManagedDeferral } from '../../shared/orcad-managed-runtime' - -type RecordedDeferral = OrcadManagedDeferral & { deferredAt: string } - -const deferrals = new Map() - -export function recordManagedOrcadUpdateDeferral( - environmentId: string, - deferral: OrcadManagedDeferral, - now = new Date() -): void { - deferrals.set(environmentId, { ...deferral, deferredAt: now.toISOString() }) -} - -export function clearManagedOrcadUpdateDeferral(environmentId: string): void { - deferrals.delete(environmentId) -} - -export function readManagedOrcadUpdateDeferral(environmentId: string): RecordedDeferral | null { - return deferrals.get(environmentId) ?? null -} diff --git a/src/main/ssh/orcad-recovery-slot.ts b/src/main/ssh/orcad-recovery-slot.ts deleted file mode 100644 index 8e4cec5a768..00000000000 --- a/src/main/ssh/orcad-recovery-slot.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Slot-level verdicts shared by activation, rollback and their crash recovery. - * - * Every verdict here is the execution host's: `exited` only on positive proof, and a probe - * that could not answer is `unverifiable` — never a reason to start a second slot. - */ -import type { ServeReadiness } from '../server/serve-readiness' -import type { OrcadActivationExpectation } from './orcad-activation-gate' -import { ORCAD_INSTALL_MODEL } from './remote-install-model' -import { computeRemoteInstallDir } from './ssh-relay-versioned-install' -import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' -import { - launchOrcadSlotAndAwaitReadiness, - OrcadActiveReadinessError, - probeActiveOrcadReadiness -} from './orcad-active-readiness' -import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' -import { - parseOrcadStopOutcome, - stopOrcadCommand, - type OrcadStopOutcome -} from './orcad-remote-process-control' -import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { - initialOrcadActivationAdmissionCommand, - parseInitialOrcadActivationAdmission -} from './orcad-initial-activation-admission' - -export const ORCAD_SLOT_STOP_WAIT_SECONDS = 20 - -export type OrcadSlotOptions = OrcadRemoteExecTarget & { - remoteHome: string - /** Host Node for legacy slots only; a slot's own runtime marker wins. */ - nodePath: string - userDataDir: string - bindHost: string - port: number - readinessTimeoutMs?: number - sleep?: (ms: number) => Promise -} - -export type OrcadSlotIdentity = { version: string; remoteDir: string; buildHash: string } - -function expectation(identity: OrcadSlotIdentity): OrcadActivationExpectation { - return { buildHash: identity.buildHash, fullVersion: identity.version } -} - -export function orcadSlotDir(options: OrcadSlotOptions, version: string): string { - return computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, version) -} - -/** Reads the installed bytes' hash, so a later readiness payload can be matched to them. */ -export async function resolveOrcadSlotIdentity( - options: OrcadSlotOptions, - version: string -): Promise { - const remoteDir = orcadSlotDir(options, version) - return { version, remoteDir, buildHash: await readRemoteOrcadBuildHash(options, remoteDir) } -} - -export function launchOrcadSlot( - options: OrcadSlotOptions, - identity: OrcadSlotIdentity -): Promise { - return launchOrcadSlotAndAwaitReadiness( - options, - { - remoteInstallDir: identity.remoteDir, - nodePath: options.nodePath, - fullVersion: identity.version, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }, - expectation(identity) - ) -} - -/** Proves the slot serves, starting it only on proven exit. */ -export async function ensureOrcadSlotServing( - options: OrcadSlotOptions, - identity: OrcadSlotIdentity -): Promise { - const liveness = parseOrcadLiveness( - await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, identity.remoteDir)) - ) - if (liveness === 'LIVE') { - return probeActiveOrcadReadiness( - { ...options, remoteInstallDir: identity.remoteDir }, - expectation(identity) - ) - } - if (liveness === 'UNKNOWN') { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${identity.version} process state is unverifiable.` - ) - } - return launchOrcadSlot(options, identity) -} - -/** `justLaunched` only for a slot this run started; otherwise the readiness PID must agree. */ -export async function stopOrcadSlot( - options: OrcadSlotOptions, - remoteDir: string, - justLaunched: boolean -): Promise { - return parseOrcadStopOutcome( - await execOrcadRemote( - options, - stopOrcadCommand( - options.host, - remoteDir, - justLaunched - ? { waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, justLaunched: true } - : { waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, nodePath: options.nodePath } - ) - ) - ) -} - -export type OrcadSlotQuiescence = 'exited' | 'other-slot-serving' - -/** - * Before an interrupted run's state is replaced: the slot it started must be proven exited and - * the slot being restored must not be running, or must already be serving (nothing to undo). - * - * A missing PID file is not proof of exit: an SSH drop can kill the launcher after `nohup` - * but before it records `$!`, so the data root's owner records decide. - */ -export async function quiesceInterruptedOrcadSlot( - options: OrcadSlotOptions, - /** `null` when nothing was launched, so only `otherSlot` needs ruling out. */ - version: string | null, - otherSlot: OrcadSlotIdentity | null -): Promise { - const remoteDir = version === null ? null : orcadSlotDir(options, version) - const stopped = - remoteDir === null ? 'already-exited' : await stopOrcadSlot(options, remoteDir, false) - let exited = stopped === 'stopped' || stopped === 'already-exited' - if (stopped === 'unknown' && remoteDir !== null) { - // The readiness PID never matched, e.g. the slot died before readiness; liveness decides. - exited = (await slotLiveness(options, remoteDir)) === 'DEAD' - } - if (otherSlot) { - const other = await slotLiveness(options, otherSlot.remoteDir) - if (other === 'LIVE' && (exited || stopped === 'no-pid')) { - // The serving slot holds the instance lock, so a PID-less launch cannot own the state. - await probeActiveOrcadReadiness( - { ...options, remoteInstallDir: otherSlot.remoteDir }, - expectation(otherSlot) - ) - return 'other-slot-serving' - } - if (other !== 'DEAD') { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${version ?? 'candidate'} (${stopped}) and ${otherSlot.version} (${other}) cannot both be ` + - 'ruled out as owners of the shared state; it was not replaced.' - ) - } - } - if (!exited && stopped === 'no-pid' && remoteDir !== null) { - const admission = parseInitialOrcadActivationAdmission( - await execOrcadRemote( - options, - initialOrcadActivationAdmissionCommand( - options.host, - options.userDataDir, - remoteDir, - options.nodePath - ) - ) - ) - exited = admission.decision === 'proceed' - } - if (!exited) { - throw new OrcadActiveReadinessError( - 'unverifiable', - `orcad ${version ?? 'candidate'} could not be confirmed stopped (${stopped}); replacing its state would be unsafe.` - ) - } - return 'exited' -} - -async function slotLiveness( - options: OrcadSlotOptions, - remoteDir: string -): Promise<'LIVE' | 'DEAD' | 'UNKNOWN'> { - return parseOrcadLiveness( - await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, remoteDir)) - ) -} diff --git a/src/main/ssh/orcad-remote-build-hash.ts b/src/main/ssh/orcad-remote-build-hash.ts deleted file mode 100644 index 7ed6384dfe7..00000000000 --- a/src/main/ssh/orcad-remote-build-hash.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** The installed slot's `orcad.js` identity, the same 16-hex prefix orcad reports in its health. */ -import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' - -const BUILD_HASH_MARKER = '__ORCAD_BUILD_HASH__' - -export async function readRemoteOrcadBuildHash( - target: OrcadRemoteExecTarget, - remoteInstallDir: string -): Promise { - const output = await execOrcadRemote( - target, - remoteOrcadBuildHashCommand(target.host, remoteInstallDir) - ) - const match = output.match(/__ORCAD_BUILD_HASH__\s+([a-fA-F0-9]{16})/u) - if (!match?.[1]) { - throw new Error('Could not verify the installed orcad build hash.') - } - return match[1].toLowerCase() -} - -export function remoteOrcadBuildHashCommand( - host: RemoteHostPlatform, - remoteInstallDir: string -): string { - assertPosixOrcadHost(host) - const path = shellEscape(joinRemotePath(host, remoteInstallDir, 'orcad.js')) - // Why both tools: GNU/busybox ship sha256sum, macOS ships shasum; either prints the digest first. - return [ - `orca_hash=$(if command -v sha256sum >/dev/null 2>&1; then sha256sum ${path} | awk '{print $1}';`, - `elif command -v shasum >/dev/null 2>&1; then shasum -a 256 ${path} | awk '{print $1}'; fi);`, - `case "$orca_hash" in [0-9a-fA-F][0-9a-fA-F]*) printf '%s %.16s\\n' ${shellEscape(BUILD_HASH_MARKER)} "$orca_hash";; esac` - ].join(' ') -} diff --git a/src/main/ssh/orcad-remote-context.ts b/src/main/ssh/orcad-remote-context.ts deleted file mode 100644 index 6b893ba2950..00000000000 --- a/src/main/ssh/orcad-remote-context.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** Everything a managed-orcad operation needs to know about one SSH host before it acts. */ -import type { ServerTarget } from '../../shared/node-runtime-pin' -import type { SshTarget } from '../../shared/ssh-types' -import type { OrcadActivationRecord } from './orcad-activation-record' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { execOrcadRemote } from './orcad-remote-runtime-control' -import type { SshConnection } from './ssh-connection' -import { readRemoteHomeCommand } from './ssh-remote-commands' -import { - joinRemotePath, - normalizeRemoteHome, - validateRemoteHome, - type RemoteHostPlatform -} from './ssh-remote-platform' -import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' - -export type OrcadRemoteContext = { - activationRecord: OrcadActivationRecord - serverTarget: ServerTarget - connection: SshConnection - host: RemoteHostPlatform - remoteHome: string - target: SshTarget - userDataDir: string -} - -export async function resolveOrcadRemoteContext( - target: SshTarget, - connection: SshConnection, - signal?: AbortSignal -): Promise { - const host = await detectRemoteHostPlatform(connection, { signal }) - if (!host) { - throw new Error('This SSH host platform is not supported by managed orcad.') - } - // Why first: every lifecycle step after this is POSIX-only, so refuse before probing further. - assertPosixOrcadHost(host) - const remote = { conn: connection, host, signal } - const remoteHome = normalizeRemoteHome( - await execOrcadRemote(remote, readRemoteHomeCommand(host)), - host - ) - if (!validateRemoteHome(remoteHome, host)) { - throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) - } - const serverTarget = await resolveOrcadDeploymentTarget({ conn: connection, host, signal }) - const activationRecord = await readOrcadActivationRecord({ ...remote, remoteHome }) - return { - activationRecord, - serverTarget, - connection, - host, - remoteHome, - target, - userDataDir: joinRemotePath(host, remoteHome, '.orca') - } -} diff --git a/src/main/ssh/orcad-remote-decommission.test.ts b/src/main/ssh/orcad-remote-decommission.test.ts deleted file mode 100644 index 492fcd03e5e..00000000000 --- a/src/main/ssh/orcad-remote-decommission.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as DeployHelpers from './ssh-relay-deploy-helpers' -import type * as RecordFile from './orcad-remote-record-file' -import type * as InstallLock from './ssh-relay-install-lock' - -vi.mock('./ssh-relay-deploy-helpers', async (importOriginal) => ({ - ...(await importOriginal()), - execCommand: vi.fn() -})) -vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn() -})) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn() -})) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock } from './ssh-relay-install-lock' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' -import { decommissionRemoteOrcad } from './orcad-remote-stop' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { parseOrcadActivationRecord } from './orcad-activation-record' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' -import { FakeOrcadHost, OLD, type CrashMode } from './orcad-activation-host-test-harness' - -let host = new FakeOrcadHost() - -const slot = { - conn: {} as SshConnection, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/u', - nodePath: '/usr/bin/node', - userDataDir: '/home/u/.orca', - bindHost: '127.0.0.1', - port: 7777, - readinessTimeoutMs: 50, - sleep: async () => {}, - now: () => new Date('2026-02-02T00:00:00.000Z') -} -const idle: OrcadTerminalCensus = { - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 -} - -function currentRecord() { - const parsed = parseOrcadActivationRecord(host.record) - if (parsed.state !== 'ok') { - throw new Error('fixture record is unreadable') - } - return parsed.record -} - -const decommission = (census: OrcadTerminalCensus = idle) => - decommissionRemoteOrcad({ ...slot, record: currentRecord(), census }) - -beforeEach(() => { - vi.clearAllMocks() - host = FakeOrcadHost.deployedOld() - vi.mocked(execCommand).mockImplementation(async (_conn, command) => host.exec(command)) - vi.mocked(acquireInstallLock).mockImplementation(async () => host.acquireFence()) - vi.mocked(writeAtomicOrcadRemoteRecord).mockImplementation(async (_target, path, contents) => - host.write(path, contents) - ) -}) - -function expectExactlyTheRecordedSlot(): void { - const active = host.activeVersion() - expect([...host.alive]).toEqual(active ? [active] : []) - expect(host.journal).toBeNull() - expect(host.fence).toBe(false) -} - -describe('decommissioning a managed orcad', () => { - it('stops the active instance by request, then records that nothing serves', async () => { - expect(await decommission()).toEqual({ - outcome: 'decommissioned', - version: OLD, - retirement: 'retired' - }) - expect(currentRecord()).toMatchObject({ active: null, previous: OLD, snapshot: null }) - expectExactlyTheRecordedSlot() - // Never a signal: the instance-bound request is the only stop path. - expect(host.commands.some((command) => command.includes('kill -TERM'))).toBe(false) - }) - - it.each([ - [{ ...idle, liveSessions: null }, 'unverifiable', 'orcad_decommission_census_unavailable'], - [{ ...idle, liveSessions: 2 }, 'live', 'orcad_decommission_terminals_running'] - ] as const)('refuses while the census says %j', async (census, verdict, code) => { - expect(await decommission(census)).toMatchObject({ outcome: 'refused', verdict, code }) - expect(host.alive.has(OLD)).toBe(true) - expect(writeAtomicOrcadRemoteRecord).not.toHaveBeenCalled() - }) - - it('refuses an orcad that cannot be addressed by request', async () => { - host.alive.clear() - expect(await decommission()).toMatchObject({ - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_managed_stop_readiness_unverifiable' - }) - expect(writeAtomicOrcadRemoteRecord).not.toHaveBeenCalled() - }) - - it('withdraws a request orcad never acted on and keeps serving', async () => { - host.managedStop = 'stays' - expect(await decommission()).toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_decommission_stop_withdrawn' - }) - expect(currentRecord().active).toBe(OLD) - expectExactlyTheRecordedSlot() - }) - - it('keeps the fence while orcad is still stopping, and recovery finishes once it exits', async () => { - host.managedStop = 'stays-dispatched' - expect(await decommission()).toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_decommission_stop_unsettled' - }) - expect(host.fence).toBe(true) - expect(host.journal).not.toBeNull() - expect(await recoverInterruptedOrcadActivation(slot)).toMatchObject({ - outcome: 'refused', - code: 'orcad_recovery_decommission_unsettled' - }) - host.managedStop = 'exits' - expect(await recoverInterruptedOrcadActivation(slot)).toMatchObject({ - outcome: 'recovered', - resolution: 'committed', - activeVersion: null - }) - expectExactlyTheRecordedSlot() - }) - - it.each(['before', 'after'])( - 'recovers to exactly the recorded slot when interrupted at every mutation (%s)', - async (mode) => { - host = FakeOrcadHost.deployedOld() - await decommission() - const total = host.mutations - expect(total).toBeGreaterThan(3) - for (let crashAt = 1; crashAt <= total; crashAt += 1) { - host = FakeOrcadHost.deployedOld() - host.crashAt = crashAt - host.crashMode = mode - await decommission().catch(() => undefined) - host.crashAt = null - const result = await recoverInterruptedOrcadActivation(slot) - expect(['recovered', 'none'], `mutation ${crashAt}`).toContain(result.outcome) - expectExactlyTheRecordedSlot() - } - } - ) -}) diff --git a/src/main/ssh/orcad-remote-deploy-stop.ts b/src/main/ssh/orcad-remote-deploy-stop.ts new file mode 100644 index 00000000000..8a00a1dfa03 --- /dev/null +++ b/src/main/ssh/orcad-remote-deploy-stop.ts @@ -0,0 +1,68 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { + parseOrcadStopOutcome, + stopOrcadCommand, + type OrcadStopOutcome +} from './orcad-remote-process-control' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { compareOrcadStateSnapshotCommand, orcadSnapshotIsUnchanged } from './orcad-state-snapshot' + +export type OrcadOutgoingStopOptions = { + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + nodePath: string + signal?: AbortSignal +} + +/** Stop the outgoing runtime and return its execution-host verdict. */ +export async function stopOutgoingOrcad( + options: OrcadOutgoingStopOptions, + outgoingVersion: string +): Promise { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + outgoingVersion + ) + const output = await execCommand( + options.conn, + stopOrcadCommand(options.host, outgoingDir, { waitSeconds: 20, nodePath: options.nodePath }), + { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + } + ) + return parseOrcadStopOutcome(output) +} + +/** The caller must confirm candidate exit before inspecting its shared state. */ +export async function rejectedOrcadStateRecoveryRefusal( + options: OrcadOutgoingStopOptions & { userDataDir: string }, + incumbentVersion: string, + snapshotDir: string | undefined +): Promise { + const unchanged = snapshotDir + ? orcadSnapshotIsUnchanged( + await execCommand( + options.conn, + compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir), + { wrapCommand: options.host.commandDialect !== 'powershell', signal: options.signal } + ).catch(() => '') + ) + : false + if (unchanged) { + return undefined + } + // RPC was already exposed; a prelaunch census cannot authorize discarding candidate writes. + const retainedSnapshot = snapshotDir ? ` at ${snapshotDir}.` : ', which is unavailable.' + return ( + 'The candidate is stopped, but profile state changed or could not be verified. ' + + `orcad ${incumbentVersion} was not restarted against potentially incompatible state. ` + + 'Current state and daemon terminals are preserved; recovery requires a fresh host ' + + `terminal census before restoring the prelaunch snapshot${retainedSnapshot}` + ) +} diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 29af8774db9..8308ff52db5 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -1,5 +1,4 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' -import type * as RecordFile from './orcad-remote-record-file' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -18,10 +17,6 @@ vi.mock('./ssh-relay-install-transfers', () => ({ uploadRelayDirectory: vi.fn().mockResolvedValue(undefined), writeRelayFile: vi.fn().mockResolvedValue(undefined) })) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined) -})) vi.mock('./orcad-remote-node-runtime', () => ({ ensureRemoteOrcadNodeRuntime: vi.fn().mockResolvedValue(undefined) })) @@ -31,8 +26,7 @@ vi.mock('./orcad-local-build-hash', () => ({ import { execCommand } from './ssh-relay-deploy-helpers' import { acquireInstallLock } from './ssh-relay-install-lock' -import { uploadRelayDirectory } from './ssh-relay-install-transfers' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy' import { installOrcadBundle } from './orcad-remote-install' import { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime' @@ -59,7 +53,6 @@ vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({ })) function readyLine(overrides: { - version?: string buildHash?: string daemonState?: 'live' | 'degraded' | 'absent' selfTestOk?: boolean @@ -74,7 +67,7 @@ function readyLine(overrides: { pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, health: { buildHash: overrides.buildHash ?? 'abc123def4567890', - buildVersion: overrides.version ?? NEW_VERSION, + buildVersion: NEW_VERSION, nodeVersion: '20.11.0', nodeAbi: '115', platform: 'linux', @@ -113,21 +106,10 @@ type HostScript = { function scriptHost(script: HostScript): void { mockExec.mockImplementation(async (_conn, command: string) => { const text = String(command) - if (text.includes('__ORCAD_RECORD_PRESENT__') && text.includes('orcad-active.json')) { + if (text.startsWith('cat ') && text.includes('orcad-active.json')) { return script.activationRecord - ? `__ORCAD_RECORD_PRESENT__\n${script.activationRecord}` - : '__ORCAD_RECORD_ABSENT__\n' } - if (text.includes('__ORCAD_RECORD_PRESENT__') && text.includes('transaction.json')) { - return '__ORCAD_RECORD_ABSENT__\n' - } - if (text.includes('__ORCAD_BUILD_HASH__')) { - return '__ORCAD_BUILD_HASH__ abc123def4567890\n' - } - if (text.includes('orcad.lock') && text.includes('orca-runtime.json')) { - return 'CLEAR' - } - if (text.includes('.orcad-readiness') && text.startsWith('head -c ')) { + if (text.includes('.orcad-readiness') && text.startsWith('cat ')) { if (script.readinessAtMs !== undefined && Date.now() < script.readinessAtMs) { return '' } @@ -180,7 +162,7 @@ function options(overrides: Partial = {}): OrcadDeployOption userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, readinessTimeoutMs: 50, sleep: async () => {}, now: () => new Date('2026-02-02T00:00:00.000Z'), @@ -290,8 +272,8 @@ describe('deployOrcad', () => { expect(script.log).toEqual(['preflight']) expect( vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.some(([, path]) => path.includes('orcad-active.json')) + .mocked(writeRelayFile) + .mock.calls.some(([, , path]) => path.includes('orcad-active.json')) ).toBe(false) } ) @@ -325,7 +307,7 @@ describe('deployOrcad', () => { options({ host: getRemoteHostPlatform(platform), target, - census: { liveSessions: 1, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { liveSessions: 1, startedSinceActivation: 0 } }) ) const chmod = mockExec.mock.calls.findIndex(([, command]) => @@ -374,7 +356,7 @@ describe('deployOrcad', () => { if (String(command).startsWith('chmod 755 ')) { throw new Error('chmod failed') } - return String(command).includes('__ORCAD_RECORD_ABSENT__') ? '__ORCAD_RECORD_ABSENT__\n' : '' + return '' }) await expect(deployOrcad(options())).rejects.toThrow('chmod failed') expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() @@ -459,9 +441,9 @@ describe('deployOrcad', () => { const result = await deployOrcad(options()) expect(result).toMatchObject({ outcome: 'installed-and-activated', fullVersion: NEW_VERSION }) const written = vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.find((call) => String(call[1]).endsWith('orcad-active.json')) - expect(JSON.parse(String(written?.[2]))).toMatchObject({ + .mocked(writeRelayFile) + .mock.calls.find((call) => String(call[2]).endsWith('orcad-active.json')) + expect(JSON.parse(String(written?.[3]))).toMatchObject({ active: NEW_VERSION, previous: OLD_VERSION }) @@ -488,7 +470,7 @@ describe('deployOrcad', () => { } scriptHost(script) const result = await deployOrcad( - options({ census: { liveSessions: 2, startedSinceActivation: 0, daemonProtocolVersion: 3 } }) + options({ census: { liveSessions: 2, startedSinceActivation: 0 } }) ) expect(result).toMatchObject({ outcome: 'installed-not-activated', @@ -510,8 +492,8 @@ describe('deployOrcad', () => { expect(result).toMatchObject({ code: 'orcad_activation_daemon_degraded' }) expect( vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.some((call) => String(call[1]).endsWith('orcad-active.json')) + .mocked(writeRelayFile) + .mock.calls.some((call) => String(call[2]).endsWith('orcad-active.json')) ).toBe(false) }) @@ -520,7 +502,7 @@ describe('deployOrcad', () => { activationRecord: ACTIVE_OLD, readiness: { [NEW_VERSION]: readyLine({ selfTestOk: false }), - [OLD_VERSION]: readyLine({ version: OLD_VERSION }) + [OLD_VERSION]: readyLine({}) }, log: [] } @@ -578,7 +560,7 @@ describe('deployOrcad', () => { it('restarts the incumbent when a quiescent snapshot cannot be captured', async () => { const script: HostScript = { activationRecord: ACTIVE_OLD, - readiness: { [OLD_VERSION]: readyLine({ version: OLD_VERSION }) }, + readiness: { [OLD_VERSION]: readyLine({}) }, log: [], snapshotResult: 'tar: write failed' } @@ -621,7 +603,7 @@ describe('deployOrcad', () => { activationRecord: ACTIVE_OLD, readiness: { [NEW_VERSION]: readyLine({ buildHash: 'deadbeefdeadbeef' }), - [OLD_VERSION]: readyLine({ version: OLD_VERSION }) + [OLD_VERSION]: readyLine({}) }, log: [] } diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 0e74a106a38..f296e08bd36 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -2,31 +2,52 @@ * Activate installed bytes only after the candidate proves healthy. A rejected candidate * allows restarting the incumbent only when profile state is provably unchanged; otherwise * preserve current state and the prelaunch snapshot for explicit recovery. - * - * Every activation runs under the host's activation fence and journal - * (`orcad-activation-lock.ts`), so an interrupted one is recoverable to exactly one slot. */ -import { join } from 'node:path' import type { SshConnection } from './ssh-connection' +import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' +import { execCommand } from './ssh-relay-deploy-helpers' import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { writeRelayFile } from './ssh-relay-install-transfers' import { computeRemoteInstallDir, readLocalFullVersion } from './ssh-relay-versioned-install' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import type { OrcadActivationVerdict } from './orcad-activation-gate' -import type { OrcadTerminalCensus } from './orcad-update-plan' -import type { RemoteHostPlatform } from './ssh-remote-platform' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_STATE_SNAPSHOT_DIR, + serializeOrcadActivationRecord, + withActivatedVersion, + type OrcadActivationRecord, + type OrcadStateSnapshot +} from './orcad-activation-record' +import { orcadActivationPath, readOrcadActivationRecord } from './orcad-activation-record-store' +import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' +import { planOrcadUpdate, type OrcadTerminalCensus } from './orcad-update-plan' +import { + ORCAD_LOG_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput, + readOrcadReadinessCommand +} from './orcad-remote-launch' +import { rejectedOrcadStateRecoveryRefusal, stopOutgoingOrcad } from './orcad-remote-deploy-stop' +import { + captureOrcadStateSnapshotCommand, + orcadSnapshotDirName, + parseOrcadSnapshotCapture +} from './orcad-state-snapshot' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' +import { preflightInstalledOrcad } from './orcad-remote-preflight' import { assertPosixOrcadHost } from './orcad-remote-host-support' import { installOrcadBundle } from './orcad-remote-install' +import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { ServerTarget } from '../../shared/node-runtime-pin' -import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { materializeOrcadArtifact } from './orcad-artifact-materializer' import { readOrcadBundleTarget, resolveOrcadDeploymentTarget } from './orcad-deployment-target' import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' -import { - resolveOrcadActivationReadinessTimeout, - withOrcadActivationLock -} from './orcad-activation-lock' -import { activateInstalledOrcad } from './orcad-installed-activation' export type OrcadDeployOptions = { conn: SshConnection @@ -60,6 +81,129 @@ export type OrcadDeployResult = | { outcome: 'already-active'; fullVersion: string } | { outcome: 'installed-not-activated'; fullVersion: string; code: string; reason: string } +const READINESS_POLL_MS = 500 +const STOP_WAIT_SECONDS = 20 + +function exec(options: OrcadDeployOptions, command: string): Promise { + return execCommand(options.conn, command, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal + }) +} + +function baseDir(options: OrcadDeployOptions): string { + return joinRemotePath(options.host, options.remoteHome, RELAY_REMOTE_DIR) +} + +async function captureSnapshot( + options: OrcadDeployOptions, + fullVersion: string, + outgoingVersion: string | null, + takenAt: Date +): Promise { + // The caller has already stopped the outgoing runtime. This is required once profile state + // includes SQLite: a tar of a live WAL, main database, and SHM file is not a SQLite backup. + const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime()) + const snapshotDir = joinRemotePath( + options.host, + baseDir(options), + ORCAD_STATE_SNAPSHOT_DIR, + dirName + ) + const capture = parseOrcadSnapshotCapture( + await exec( + options, + captureOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir) + ) + ) + if (capture === 'failed') { + throw new Error( + `Could not snapshot ${options.userDataDir} before activating ${fullVersion}. Orca's ` + + 'persisted state carries no schema version, so without a snapshot a rollback has no ' + + 'way back. Refusing to activate.' + ) + } + // Empty profiles need no rollback snapshot. + if (capture === 'empty') { + return null + } + return { + dirName, + takenBeforeVersion: fullVersion, + readableByVersion: outgoingVersion, + takenAt: takenAt.toISOString() + } +} + +async function launchAndAwaitReadiness( + options: OrcadDeployOptions, + remoteInstallDir: string, + fullVersion: string +): Promise> { + await exec( + options, + orcadLaunchCommand(options.host, { ...options, remoteInstallDir, fullVersion }) + ) + const deadline = Date.now() + (options.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) + let last = parseOrcadReadinessOutput('') + while (Date.now() < deadline) { + options.signal?.throwIfAborted() + last = parseOrcadReadinessOutput( + await exec(options, readOrcadReadinessCommand(options.host, remoteInstallDir)) + ) + if (last.state !== 'pending') { + return last + } + await sleep(READINESS_POLL_MS) + } + return last +} + +/** Restart the incumbent only when the candidate left shared state unchanged. */ +async function restoreIncumbent( + options: OrcadDeployOptions, + record: OrcadActivationRecord, + candidateDir?: string, + snapshot?: OrcadStateSnapshot | null +): Promise { + if (candidateDir) { + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, candidateDir, { + waitSeconds: STOP_WAIT_SECONDS, + justLaunched: true + }) + ) + ) + if (!orcadStopFreedTheHost(stopped)) { + return `The candidate itself did not stop (${stopped}); the host may still be serving the rejected build.` + } + } + if (!record.active) { + return 'No previous version was active, so this host is now serving nothing.' + } + if (candidateDir) { + const snapshotDir = snapshot + ? joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, snapshot.dirName) + : undefined + const refusal = await rejectedOrcadStateRecoveryRefusal(options, record.active, snapshotDir) + if (refusal) { + return refusal + } + } + const incumbentDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + record.active + ) + const parsed = await launchAndAwaitReadiness(options, incumbentDir, record.active) + return parsed.state === 'ready' + ? `orcad ${record.active} was restarted and is serving again.` + : `orcad ${record.active} was relaunched but has not published readiness; this host may be down.` +} + /** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */ export async function deployOrcad(input: OrcadDeployOptions): Promise { assertPosixOrcadHost(input.host) @@ -71,10 +215,6 @@ export async function deployOrcad(input: OrcadDeployOptions): Promise materializeNodeRuntimeArchive( target, @@ -84,14 +224,108 @@ export async function deployOrcad(input: OrcadDeployOptions): Promise new Date()) const fullVersion = readLocalFullVersion(options.localOrcadDir) const remoteDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, fullVersion) - // Fail fast before upload; the activation re-reads it under the fence. - await readOrcadActivationRecord(options) + const record = await readOrcadActivationRecord(options) await installOrcadBundle(options, fullVersion, remoteDir) - return withOrcadActivationLock(options, (lock) => - activateInstalledOrcad(options, fullVersion, remoteDir, lock) + const plan = planOrcadUpdate({ + record, + candidateVersion: fullVersion, + census: options.census, + ...(options.force !== undefined ? { force: options.force } : {}) + }) + if (plan.action === 'noop') { + return { outcome: 'already-active', fullVersion } + } + if (plan.action === 'defer') { + return { + outcome: 'installed-not-activated', + fullVersion, + code: plan.code, + reason: plan.reason + } + } + + try { + await preflightInstalledOrcad({ + ...options, + remoteInstallDir: remoteDir, + fullVersion + }) + } catch (error) { + options.signal?.throwIfAborted() + return { + outcome: 'installed-not-activated', + fullVersion, + code: 'orcad_candidate_preflight_failed', + reason: `Candidate profile preflight failed; the incumbent was not stopped: ${ + error instanceof Error ? error.message : String(error) + }` + } + } + + if (record.active) { + const stopped = await stopOutgoingOrcad(options, record.active) + if (!orcadStopFreedTheHost(stopped)) { + return { + outcome: 'installed-not-activated', + fullVersion, + code: 'orcad_outgoing_stop_incomplete', + reason: + `Could not verify that orcad ${record.active} exited (${stopped}). ` + + 'No snapshot was taken and the candidate was not started. Orca requires matching ' + + 'runtime readiness before signaling an incumbent and confirmed exit before snapshotting.' + } + } + } + + // A live SQLite WAL is not a backup boundary: tar can observe the main file, WAL and SHM + // at different points and restore a set SQLite cannot recover. Stop the incumbent first so + // its final durable flush has completed before capturing the pre-activation state. + let snapshot: OrcadStateSnapshot | null = null + if (record.active) { + try { + snapshot = await captureSnapshot(options, fullVersion, record.active, now()) + } catch (error) { + const restored = await restoreIncumbent(options, record).catch( + (restartError: unknown) => + `The incumbent could not be restarted: ${ + restartError instanceof Error ? restartError.message : String(restartError) + }` + ) + throw new Error( + `${error instanceof Error ? error.message : String(error)} The incumbent was stopped ` + + `before snapshotting; ${restored}` + ) + } + } + + const parsed = await launchAndAwaitReadiness(options, remoteDir, fullVersion) + const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { + buildHash: computeLocalOrcadBuildHash(options.localOrcadDir), + fullVersion + }) + if (verdict.decision === 'reject') { + const restored = await restoreIncumbent(options, record, remoteDir, snapshot) + return { + outcome: 'installed-not-activated', + fullVersion, + code: verdict.code, + reason: + `${verdict.reason} Candidate stderr is at ` + + `${joinRemotePath(options.host, remoteDir, ORCAD_LOG_FILENAME)}. ${restored}` + } + } + + await writeRelayFile( + options.conn, + options.host, + orcadActivationPath(options.host, options.remoteHome), + serializeOrcadActivationRecord(withActivatedVersion(record, fullVersion, snapshot, now())), + { signal: options.signal } ) + return { outcome: 'installed-and-activated', fullVersion, verdict } } diff --git a/src/main/ssh/orcad-remote-gc.test.ts b/src/main/ssh/orcad-remote-gc.test.ts index 974677d45f5..044bd974e98 100644 --- a/src/main/ssh/orcad-remote-gc.test.ts +++ b/src/main/ssh/orcad-remote-gc.test.ts @@ -23,8 +23,6 @@ vi.mock('./ssh-relay-install-lock', () => ({ import { execCommand } from './ssh-relay-deploy-helpers' import { gcOldOrcadVersions } from './orcad-remote-gc' import { emptyOrcadActivationRecord } from './orcad-activation-record' -import { serializeOrcadActivationTransaction } from './orcad-activation-transaction' -import { createOrcadActivationTransaction } from './orcad-activation-transaction-transitions' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' @@ -41,22 +39,8 @@ function scriptHost(options: { listing: string[] liveness?: Record removed: string[] - /** The activation journal: absent by default, raw contents, or a read with no answer. */ - journal?: string | Error - fenceHeld?: boolean }): void { mockExec.mockImplementation(async (_conn, command: string) => { - if (command.includes('__ORCAD_RECORD_PRESENT__') && command.includes('transaction.json')) { - if (options.journal instanceof Error) { - throw options.journal - } - return options.journal === undefined - ? '__ORCAD_RECORD_ABSENT__\n' - : `__ORCAD_RECORD_PRESENT__\n${options.journal}` - } - if (command.includes('.orcad-activation-transaction') && command.includes('LOCKED')) { - return options.fenceHeld ? 'LOCKED' : 'OPEN' - } if (command.includes('-mindepth 1 -maxdepth 1')) { return options.listing.join('\n') } @@ -200,8 +184,7 @@ describe('orcad GC', () => { ).rejects.toBe(error) expect(removed).toEqual([]) - // Journal read and fence probe, then the GC pass up to the unconfirmed probe. - expect(mockExec).toHaveBeenCalledTimes(6) + expect(mockExec).toHaveBeenCalledTimes(4) expect(mockExec.mock.calls.at(-1)?.[1]).toContain('.orcad-pid') }) @@ -243,57 +226,4 @@ describe('orcad GC', () => { await gcOldOrcadVersions({ ...options, nodeRuntimePins: ['a'.repeat(64)] }) expect(inventories()).toBe(1) }) - - describe('with an activation transaction in flight', () => { - const listing = ['orcad-0.1.0+01d', 'orcad-0.2.0+9ee0', 'orcad-0.3.0+cc0'] - const record = { ...emptyOrcadActivationRecord(), active: '0.3.0+cc0' } - const run = () => - gcOldOrcadVersions({ - conn, - host, - remoteHome: '/home/u', - currentDirAbsPath: '/home/u/.orca-remote/orcad-0.3.0+cc0', - record - }) - - it('keeps every slot a pending journal names', async () => { - const removed: string[] = [] - const before = { ...record, previous: '0.1.0+01d' } - scriptHost({ - listing, - removed, - journal: serializeOrcadActivationTransaction( - createOrcadActivationTransaction({ - transactionId: '7f1c2a7e-6c1b-4a8e-9f0e-0a1b2c3d4e5f', - candidateVersion: '0.2.0+9ee0', - recordBefore: before, - snapshotDirName: 'pre-0.2.0+9ee0-1', - now: new Date(0) - }) - ) - }) - await run() - expect(removed).toEqual([]) - }) - - it.each([ - ['an unreadable journal', { journal: '{"schemaVersion":99}' }], - ['a journal read with no answer', { journal: new Error('lost') }], - ['a held fence without a journal', { fenceHeld: true }] - ])('collects nothing behind %s', async (_name, state) => { - const removed: string[] = [] - vi.spyOn(console, 'warn').mockImplementation(() => {}) - scriptHost({ listing, removed, ...state }) - await run() - expect(removed).toEqual([]) - expect(mockExec.mock.calls.some(([, command]) => command.includes('-mindepth 1'))).toBe(false) - }) - - it('never names a snapshot or rescue copy as a candidate', async () => { - const removed: string[] = [] - scriptHost({ listing: [...listing, 'orcad-state-snapshots'], removed }) - await run() - expect(removed).not.toContain('orcad-state-snapshots') - }) - }) }) diff --git a/src/main/ssh/orcad-remote-gc.ts b/src/main/ssh/orcad-remote-gc.ts index 5f7664664e4..2f1692201e4 100644 --- a/src/main/ssh/orcad-remote-gc.ts +++ b/src/main/ssh/orcad-remote-gc.ts @@ -10,8 +10,7 @@ * * On top of the ownership rule, orcad pins three directories that are idle-looking but * load-bearing: the active version, the rollback target, and whichever version the LIVE - * terminal daemon was forked from. Every version an in-flight activation journal names is - * pinned too, and an unreadable journal skips the pass entirely. + * terminal daemon was forked from. */ import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' @@ -26,7 +25,6 @@ import { } from './orcad-remote-launch' import type { RemoteHostPlatform } from './ssh-remote-platform' import { gcRemoteNodeRuntimeStore } from './remote-node-runtime-store-gc' -import { readOrcadGcTransactionPins } from './orcad-gc-transaction-pins' export type OrcadGcOptions = { conn: SshConnection @@ -52,11 +50,6 @@ export type OrcadGcOptions = { } export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise { - const transaction = await readOrcadGcTransactionPins(options) - if (transaction.state === 'keep-all') { - console.warn('[orcad-gc] An activation transaction is unreadable or unjournaled; skipping GC.') - return - } await gcOldRemoteInstallVersions( options.conn, ORCAD_INSTALL_MODEL, @@ -64,10 +57,7 @@ export async function gcOldOrcadVersions(options: OrcadGcOptions): Promise options.currentDirAbsPath, options.host, { - pinnedDirNames: [ - ...orcadGcPinnedDirNames(options.record, options.liveDaemonVersion), - ...transaction.dirNames - ], + pinnedDirNames: orcadGcPinnedDirNames(options.record, options.liveDaemonVersion), isDirLive: async (dir) => { try { const probe = await execCommand( diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index 6e7a6644ec0..d6c9640ad33 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -21,14 +21,11 @@ import { } from './orcad-remote-host-support' import type { ServeReadiness } from '../server/serve-readiness' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' -import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' /** Stdout of the launched candidate: exactly one `orca_server_ready` line, then nothing. */ export const ORCAD_READINESS_FILENAME = '.orcad-readiness' /** Stderr, including the bind-exposure line and every supervision message. */ export const ORCAD_LOG_FILENAME = 'orcad.log' -// Why a cap: the readiness file is candidate-written stdout, and a runaway writer must not be read whole. -const ORCAD_READINESS_MAX_BYTES = 256 * 1024 export { ORCAD_PID_FILENAME, OrcadRemoteLaunchUnsupportedError } from './orcad-remote-host-support' export type OrcadLaunchSpec = { @@ -64,8 +61,6 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp // Why truncate: a re-launch into a dir that already holds a previous readiness line would // otherwise let the deploy activate on the OLD process's health payload. `: > ${readiness} &&`, - // A stop request the previous process never consumed must not stop this one. - `rm -f ${shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME))} &&`, 'umask 077 &&', `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, @@ -83,8 +78,7 @@ export function readOrcadReadinessCommand( ): string { assertPosixHost(host) const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) - // One byte over the cap is enough to tell an oversized payload from a full one. - return `head -c ${ORCAD_READINESS_MAX_BYTES + 1} ${readiness} 2>/dev/null || true` + return `cat ${readiness} 2>/dev/null || true` } /** @@ -140,23 +134,19 @@ export type OrcadReadinessParse = * not `malformed` — reporting a parse failure for a race would fail deploys that were fine. */ export function parseOrcadReadinessOutput(raw: string): OrcadReadinessParse { - if (Buffer.byteLength(raw, 'utf8') > ORCAD_READINESS_MAX_BYTES) { - return { state: 'malformed', reason: 'readiness payload exceeds the 256 KiB limit' } - } const lines = raw.split('\n') - for (const [index, line] of lines.entries()) { + let sawCandidate = false + for (const line of lines) { const trimmed = line.trim() if (!trimmed.startsWith('{')) { continue } + sawCandidate = true let parsed: unknown try { parsed = JSON.parse(trimmed) } catch { - // Only the unterminated last line can still be mid-write; a finished bad line will stay bad. - return index === lines.length - 1 - ? { state: 'pending' } - : { state: 'malformed', reason: 'readiness line is not valid JSON' } + continue } if (typeof parsed !== 'object' || parsed === null) { continue @@ -170,7 +160,7 @@ export function parseOrcadReadinessOutput(raw: string): OrcadReadinessParse { } return { state: 'ready', readiness: toServeReadiness(payload as Record) } } - return { state: 'pending' } + return sawCandidate ? { state: 'pending' } : { state: 'pending' } } function toServeReadiness(payload: Record): ServeReadiness { diff --git a/src/main/ssh/orcad-remote-primitives.test.ts b/src/main/ssh/orcad-remote-primitives.test.ts deleted file mode 100644 index 881f0db21f6..00000000000 --- a/src/main/ssh/orcad-remote-primitives.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn(), - isUnconfirmedSshCommandTermination: (error: unknown) => - error instanceof Error && error.message === 'channel lost' -})) - -vi.mock('./ssh-remote-platform-detection', () => ({ detectRemoteHostPlatform: vi.fn() })) - -import { execCommand } from './ssh-relay-deploy-helpers' -import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' -import { resolveOrcadRemoteContext } from './orcad-remote-context' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import type { SshConnection } from './ssh-connection' -import { - readBoundedOrcadRemoteRecord, - writeAtomicOrcadRemoteRecord -} from './orcad-remote-record-file' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { emptyOrcadActivationRecord } from './orcad-activation-record' -import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' -import { - launchOrcadSlotAndAwaitReadiness, - OrcadActiveReadinessError, - probeActiveOrcadReadiness -} from './orcad-active-readiness' -import { parseOrcadReadinessOutput } from './orcad-remote-launch' - -const mockExec = vi.mocked(execCommand) -const linux = getRemoteHostPlatform('linux-x64') -const windows = getRemoteHostPlatform('win32-x64') -const conn: SshConnection = Object.create(null) -const target = { conn, host: linux } -const BUILD_HASH = 'abc123def4567890' - -function readyLine( - buildHash = BUILD_HASH, - daemon: { coverage?: 'pty-spawn' | 'handshake'; platform?: string } = {} -): string { - const selfTest = { ok: true, verdict: 'healthy', durationMs: 5 } - return JSON.stringify({ - type: 'orca_server_ready', - runtimeId: 'r1', - boundEndpoint: 'ws://127.0.0.1:7777', - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' }, - health: { - buildHash, - buildVersion: '0.2.0+bb01', - nodeVersion: '24.21.0', - nodeAbi: '137', - platform: daemon.platform ?? 'linux', - arch: 'x64', - pid: 1, - terminalDaemon: { - state: 'live', - ownsFreshSessions: true, - pid: 2, - buildVersion: '0.2.0+bb01', - entryPath: '/x/daemon-entry.js', - protocolVersion: 38, - selfTest: - 'coverage' in daemon - ? { ...selfTest, ...(daemon.coverage ? { coverage: daemon.coverage } : {}) } - : { ...selfTest, coverage: 'pty-spawn' } - } - } - }) -} - -beforeEach(() => { - mockExec.mockReset() -}) - -describe('orcad host record files', () => { - it('tells an absent record from one whose read gave no answer', async () => { - mockExec.mockResolvedValueOnce('__ORCAD_RECORD_ABSENT__\n') - await expect(readBoundedOrcadRemoteRecord(target, '/r.json', 64)).resolves.toEqual({ - state: 'absent' - }) - mockExec.mockResolvedValueOnce('__ORCAD_RECORD_PRESENT__\n{"a":1}') - await expect(readBoundedOrcadRemoteRecord(target, '/r.json', 64)).resolves.toEqual({ - state: 'present', - raw: '{"a":1}' - }) - mockExec.mockResolvedValueOnce('') - await expect(readBoundedOrcadRemoteRecord(target, '/r.json', 64)).rejects.toThrow( - 'no verifiable answer' - ) - }) - - it('keeps the partial file when the write may still be running on the host', async () => { - mockExec.mockRejectedValueOnce(new Error('channel lost')) - await expect(writeAtomicOrcadRemoteRecord(target, '/r.json', '{}')).rejects.toThrow() - expect(mockExec).toHaveBeenCalledOnce() - mockExec.mockRejectedValueOnce(new Error('exit 1')).mockResolvedValueOnce('') - await expect(writeAtomicOrcadRemoteRecord(target, '/r.json', '{}')).rejects.toThrow() - expect(String(mockExec.mock.calls.at(-1)?.[1])).toContain('rm -f') - }) - - it('refuses Windows hosts, which the orcad lifecycle does not support', async () => { - await expect( - readBoundedOrcadRemoteRecord({ conn, host: windows }, 'C:/r.json', 64) - ).rejects.toThrow() - expect(mockExec).not.toHaveBeenCalled() - }) -}) - -describe('activation record store', () => { - const options = { conn, host: linux, remoteHome: '/home/u' } - const newer = JSON.stringify({ ...emptyOrcadActivationRecord(), schemaVersion: 2 }) - - it('reads a lost read as an error, never as an empty record', async () => { - mockExec.mockRejectedValueOnce(new Error('channel lost')) - await expect(readOrcadActivationRecord(options)).rejects.toThrow('channel lost') - }) - - it('reads a newer schema as unreadable and never overwrites it', async () => { - mockExec.mockResolvedValue(`__ORCAD_RECORD_PRESENT__\n${newer}`) - await expect(readOrcadActivationRecord(options)).rejects.toThrow('schemaVersion 2') - await expect(writeOrcadActivationRecord(options, emptyOrcadActivationRecord())).rejects.toThrow( - 'Refusing to overwrite' - ) - expect(mockExec.mock.calls.some(([, command]) => String(command).includes('mv -f'))).toBe(false) - }) - - it('writes atomically when the host has no record yet', async () => { - mockExec.mockResolvedValueOnce('__ORCAD_RECORD_ABSENT__\n').mockResolvedValueOnce('') - await writeOrcadActivationRecord(options, emptyOrcadActivationRecord()) - expect(String(mockExec.mock.calls[1]?.[1])).toMatch(/printf %s .* && mv -f /s) - }) -}) - -describe('installed build identity and readiness', () => { - const slot = { ...target, remoteInstallDir: '/home/u/.orca-remote/orcad-0.2.0+bb01' } - const expectation = { buildHash: BUILD_HASH, fullVersion: '0.2.0+bb01' } - - it('reads the 16-hex build hash the slot reports', async () => { - mockExec.mockResolvedValueOnce(`noise\n__ORCAD_BUILD_HASH__ ${BUILD_HASH.toUpperCase()}\n`) - await expect(readRemoteOrcadBuildHash(target, '/slot')).resolves.toBe(BUILD_HASH) - mockExec.mockResolvedValueOnce('') - await expect(readRemoteOrcadBuildHash(target, '/slot')).rejects.toThrow() - }) - - it.each([ - ['DEAD', 'exited'], - ['UNKNOWN', 'unverifiable'], - ['', 'unverifiable'] - ])('reports a %j liveness probe as %s', async (liveness, verdict) => { - mockExec.mockResolvedValueOnce(liveness) - await expect(probeActiveOrcadReadiness(slot, expectation)).rejects.toMatchObject({ - verdict - }) - }) - - it('accepts only the expected build once the process is live', async () => { - mockExec.mockResolvedValueOnce('LIVE').mockResolvedValueOnce(`${readyLine()}\n`) - await expect(probeActiveOrcadReadiness(slot, expectation)).resolves.toMatchObject({ - runtimeId: 'r1' - }) - mockExec - .mockResolvedValueOnce('LIVE') - .mockResolvedValueOnce(`${readyLine('ffffffffffffffff')}\n`) - const rejected = probeActiveOrcadReadiness(slot, expectation) - await expect(rejected).rejects.toBeInstanceOf(OrcadActiveReadinessError) - await expect(rejected).rejects.toMatchObject({ verdict: 'rejected' }) - }) - - it.each([ - ['a spawn-probed PTY', { coverage: 'pty-spawn' as const }], - [ - 'a handshake on a host whose daemon never spawn-probes', - { coverage: 'handshake' as const, platform: 'win32' } - ], - ['an older build that does not report coverage', { coverage: undefined }] - ])('accepts %s', async (_name, daemon) => { - mockExec - .mockResolvedValueOnce('LIVE') - .mockResolvedValueOnce(`${readyLine(BUILD_HASH, daemon)}\n`) - await expect(probeActiveOrcadReadiness(slot, expectation)).resolves.toMatchObject({ - runtimeId: 'r1' - }) - }) - - it('rejects handshake-only coverage on a host whose daemon should spawn a PTY', async () => { - mockExec - .mockResolvedValueOnce('LIVE') - .mockResolvedValueOnce(`${readyLine(BUILD_HASH, { coverage: 'handshake' })}\n`) - await expect(probeActiveOrcadReadiness(slot, expectation)).rejects.toMatchObject({ - verdict: 'rejected', - message: expect.stringContaining("coverage 'handshake'") - }) - }) - - it('applies the same coverage rule to a slot it launches', async () => { - mockExec - .mockResolvedValueOnce('4242') - .mockResolvedValueOnce(`${readyLine(BUILD_HASH, { coverage: 'handshake' })}\n`) - await expect( - launchOrcadSlotAndAwaitReadiness( - { ...target, readinessTimeoutMs: 1_000, sleep: async () => {} }, - { - remoteInstallDir: slot.remoteInstallDir, - nodePath: '/usr/bin/node', - fullVersion: '0.2.0+bb01', - userDataDir: '/home/u/.orca', - bindHost: '127.0.0.1', - port: 7777 - }, - expectation - ) - ).rejects.toMatchObject({ verdict: 'rejected' }) - }) -}) - -describe('readiness parsing bounds', () => { - it('waits on a half-written last line but rejects a finished invalid one', () => { - expect(parseOrcadReadinessOutput('{"type":"orca_ser')).toEqual({ state: 'pending' }) - expect(parseOrcadReadinessOutput('{"type":"orca_ser\n')).toMatchObject({ - state: 'malformed' - }) - }) - - it('rejects a payload over the size cap', () => { - expect(parseOrcadReadinessOutput('x'.repeat(256 * 1024 + 1))).toMatchObject({ - state: 'malformed' - }) - }) -}) - -describe('orcad remote context', () => { - it('refuses a Windows host before probing anything else', async () => { - vi.mocked(detectRemoteHostPlatform).mockResolvedValueOnce(windows) - const sshTarget = { id: 't', label: 't', host: 'h', port: 22, username: 'u' } - await expect(resolveOrcadRemoteContext(sshTarget, conn)).rejects.toThrow() - expect(mockExec).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index da4cf968706..33fd877ba41 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -7,10 +7,6 @@ import { shellEscape } from './ssh-connection-utils' import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' -import { - ORCAD_STOP_REQUEST_FILENAME, - ORCAD_STOP_REQUESTS_CAPABILITY -} from '../../shared/orcad-stop-request' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { assertPosixOrcadHost as assertPosixHost, @@ -19,12 +15,10 @@ import { } from './orcad-remote-host-support' /** - * Ask the orcad recorded in a version dir to stop, and wait for it to go. + * Signal the orcad recorded in a version dir and wait for it to go. * - * A build whose readiness advertises `health.stopRequests` is asked through the slot-local - * request file, which only that orcad watches; older builds keep receiving SIGTERM. Both need - * the readiness PID to corroborate the launcher's PID first, so a reused PID is never stopped. * `justLaunched` is only for this client's fixed exec launcher, including pre-readiness exits. + * Incumbents need their own readiness PID to corroborate the launcher's PID before any signal. */ export function stopOrcadCommand( host: RemoteHostPlatform, @@ -37,34 +31,26 @@ export function stopOrcadCommand( assertPosixHost(host) const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) const readiness = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_READINESS_FILENAME)) - const requestFile = shellEscape( - joinRemotePath(host, remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME) - ) const readRuntimePid = [ `const r = JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8'));`, `const pid = r?.type === 'orca_server_ready' ? r.health?.pid : null;`, `if (!Number.isSafeInteger(pid) || pid <= 1) process.exit(1);`, - `const mode = r.health?.stopRequests === ${ORCAD_STOP_REQUESTS_CAPABILITY} ? 'request' : 'signal';`, - `process.stdout.write(String(pid) + ':' + mode);` + `process.stdout.write(String(pid));` ].join(' ') return [ posixProcessAliveShellFunction({ refuseUnverifiable: true }), `pid=$(cat ${pidFile} 2>/dev/null);`, 'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;', - 'stop_mode=signal;', // Older launchers recorded a waiting shell, whose exit does not prove runtime exit. ...(options.justLaunched ? [] : [ - `runtime_answer=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + + `runtime_pid=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + `"$orcad_runtime" -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, - '[ "$pid" = "${runtime_answer%%:*}" ] || { echo UNKNOWN; exit 0; };', - 'stop_mode=${runtime_answer#*:};' + '[ "$pid" = "$runtime_pid" ] || { echo UNKNOWN; exit 0; };' ]), 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', - 'if [ "$stop_mode" = request ]; then', - `( umask 077; : > ${requestFile} ) 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };`, - 'else kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; }; fi;', + 'kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };', `i=0; while [ "$i" -lt ${options.waitSeconds} ]; do`, 'orcad_alive "$pid" || { echo STOPPED; exit 0; };', 'sleep 1; i=$((i + 1)); done;', diff --git a/src/main/ssh/orcad-remote-record-file.ts b/src/main/ssh/orcad-remote-record-file.ts deleted file mode 100644 index 5c29c3b3ef5..00000000000 --- a/src/main/ssh/orcad-remote-record-file.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Small JSON records Orca keeps on an orcad host (activation, transactions, stop receipts). - * - * Reads are bounded and never swallow a failure: a record that could not be read is not an - * absent one, and treating it as absent is how a client deploys over a live install. - */ -import { randomUUID } from 'node:crypto' -import { shellEscape } from './ssh-connection-utils' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { removeRemoteFileCommand } from './ssh-remote-commands' -import { execOrcadRemote, type OrcadRemoteExecTarget } from './orcad-remote-runtime-control' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' - -const ABSENT_MARKER = '__ORCAD_RECORD_ABSENT__' -const PRESENT_MARKER = '__ORCAD_RECORD_PRESENT__' - -/** `present` carries raw bytes; schema checks belong to the caller that owns the format. */ -export type OrcadRemoteRecordRead = { state: 'absent' } | { state: 'present'; raw: string } - -export async function readBoundedOrcadRemoteRecord( - target: OrcadRemoteExecTarget, - path: string, - maxBytes: number -): Promise { - assertPosixOrcadHost(target.host) - const file = shellEscape(path) - // Why markers: an empty stdout must never be mistaken for "no record" when the read failed. - const output = await execOrcadRemote( - target, - `if [ ! -e ${file} ] && [ ! -L ${file} ]; then printf '%s\\n' ${ABSENT_MARKER}; exit 0; fi; ` + - `[ -f ${file} ] || exit 65; size=$(wc -c < ${file}) || exit 65; ` + - `[ "$size" -le ${maxBytes} ] || exit 65; ` + - `printf '%s\\n' ${PRESENT_MARKER}; cat ${file}` - ) - const newline = output.indexOf('\n') - const marker = (newline === -1 ? output : output.slice(0, newline)).trim() - if (marker === ABSENT_MARKER) { - return { state: 'absent' } - } - if (marker !== PRESENT_MARKER) { - throw new Error('orcad host record read returned no verifiable answer') - } - return { state: 'present', raw: newline === -1 ? '' : output.slice(newline + 1) } -} - -export async function writeAtomicOrcadRemoteRecord( - target: OrcadRemoteExecTarget, - path: string, - contents: string -): Promise { - assertPosixOrcadHost(target.host) - const partialPath = `${path}.partial.${process.pid}.${randomUUID()}` - try { - await execOrcadRemote( - target, - `umask 077; printf %s ${shellEscape(contents)} > ${shellEscape(partialPath)} && ` + - `mv -f ${shellEscape(partialPath)} ${shellEscape(path)}` - ) - } catch (error) { - // Why keep the partial on an unconfirmed termination: the write may still be running there. - if (!isUnconfirmedSshCommandTermination(error)) { - await execOrcadRemote(target, removeRemoteFileCommand(target.host, partialPath)).catch( - () => {} - ) - } - throw error - } -} diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts index 02aada05b2b..4d2f9150a75 100644 --- a/src/main/ssh/orcad-remote-rollback.test.ts +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -1,6 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as RecordFile from './orcad-remote-record-file' -import type * as InstallLock from './ssh-relay-install-lock' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn(), @@ -11,17 +9,9 @@ vi.mock('./ssh-relay-install-transfers', () => ({ writeRelayFile: vi.fn().mockResolvedValue(undefined), uploadRelayDirectory: vi.fn().mockResolvedValue(undefined) })) -vi.mock('./ssh-relay-install-lock', async (importOriginal) => ({ - ...(await importOriginal()), - acquireInstallLock: vi.fn().mockResolvedValue(undefined) -})) -vi.mock('./orcad-remote-record-file', async (importOriginal) => ({ - ...(await importOriginal()), - writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined) -})) import { execCommand } from './ssh-relay-deploy-helpers' -import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file' +import { writeRelayFile } from './ssh-relay-install-transfers' import { rollbackOrcad, type OrcadRollbackOptions } from './orcad-remote-rollback' import { emptyOrcadActivationRecord, type OrcadActivationRecord } from './orcad-activation-record' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -78,60 +68,35 @@ function readyLine(version: string): string { }) } -type HostOverrides = { - restores?: string[] - readinessAtMs?: number - targetReady?: boolean - snapshot?: string - comparison?: string -} - -function scriptHost(log: string[], overrides: HostOverrides = {}): void { - const restores = [...(overrides.restores ?? [])] +function scriptHost( + log: string[], + overrides: { restore?: string; readinessAtMs?: number } = {} +): void { mockExec.mockImplementation(async (_conn, command: string) => { const text = String(command) - if (text.includes('__ORCAD_RECORD_PRESENT__')) { - return text.includes('transaction.json') - ? '__ORCAD_RECORD_ABSENT__\n' - : `__ORCAD_RECORD_PRESENT__\n${JSON.stringify(record())}` + if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { + return 'PRESENT' } - if (text.includes('__ORCAD_BUILD_HASH__')) { - return `__ORCAD_BUILD_HASH__ ${BUILD_HASH}\n` - } - if (text.includes('echo PRESENT')) { - return overrides.snapshot ?? 'PRESENT' - } - if (text.includes('stat -c %Y')) { + if (text.includes('find ') && text.includes('stat')) { return 'UNKNOWN' } if (text.includes('kill -TERM')) { log.push(`stop:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) return 'STOPPED' } - if (text.includes('verdict=UNCHANGED')) { - log.push('compare') - return overrides.comparison ?? 'CHANGED' - } - if (text.includes('tar -C') && text.includes('-cf')) { - log.push('rescue') - return 'CAPTURED' - } if (text.includes('tar -C') && text.includes('-xf')) { - log.push(text.includes('rollback-rescue-') ? 'restore-rescue' : 'restore') - return restores.shift() ?? 'RESTORED' + log.push('restore') + return overrides.restore ?? 'RESTORED' } if (text.includes('nohup')) { log.push(`launch:${text.includes(ACTIVE) ? ACTIVE : TARGET}`) return '9999' } - if (text.startsWith('head -c ') && text.includes('.orcad-readiness')) { + if (text.startsWith('cat ') && text.includes('.orcad-readiness')) { if (overrides.readinessAtMs !== undefined && Date.now() < overrides.readinessAtMs) { return '' } - if (text.includes(ACTIVE)) { - return readyLine(ACTIVE) - } - return overrides.targetReady === false ? '' : readyLine(TARGET) + return readyLine(TARGET) } return '' }) @@ -147,9 +112,8 @@ function options(overrides: Partial = {}): OrcadRollbackOp userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, targetBuildHash: BUILD_HASH, - targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, readinessTimeoutMs: 50, sleep: async () => {}, now: () => new Date('2026-02-02T00:00:00.000Z'), @@ -169,7 +133,7 @@ describe('rollbackOrcad', () => { expect(result).toMatchObject({ outcome: 'rolled-back', target: TARGET }) // Restoring under a running orcad would replace the store beneath a process holding it; // starting first would let the older build migrate the newer build's state. - expect(log).toEqual([`stop:${ACTIVE}`, 'rescue', 'restore', `launch:${TARGET}`]) + expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) }) it('allows rollback startup time after a slow bundled preflight', async () => { @@ -188,7 +152,7 @@ describe('rollbackOrcad', () => { ) expect(result.outcome).toBe('rolled-back') expect(elapsedMs).toBe(100_000) - expect(log).toEqual([`stop:${ACTIVE}`, 'rescue', 'restore', `launch:${TARGET}`]) + expect(log).toEqual([`stop:${ACTIVE}`, 'restore', `launch:${TARGET}`]) } finally { clock.mockRestore() } @@ -198,79 +162,57 @@ describe('rollbackOrcad', () => { const log: string[] = [] scriptHost(log) const result = await rollbackOrcad( - options({ census: { liveSessions: 3, startedSinceActivation: 2, daemonProtocolVersion: 3 } }) + options({ census: { liveSessions: 3, startedSinceActivation: 2 } }) ) expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_rollback_orphans_live_terminals' }) expect(log).toEqual([]) - expect(vi.mocked(writeAtomicOrcadRemoteRecord)).not.toHaveBeenCalled() + expect(vi.mocked(writeRelayFile)).not.toHaveBeenCalled() }) it('refuses when the snapshot is gone from the host', async () => { const log: string[] = [] - scriptHost(log, { snapshot: 'ABSENT' }) + mockExec.mockImplementation(async (_conn, command: string) => + String(command).includes('state.tar') ? 'ABSENT' : '' + ) const result = await rollbackOrcad(options()) expect(result).toMatchObject({ outcome: 'refused', code: 'orcad_rollback_snapshot_missing' }) expect(log).toEqual([]) }) - it('does not read a lost snapshot probe as a missing snapshot', async () => { + it('does not start the old build when the restore failed', async () => { const log: string[] = [] - scriptHost(log, { snapshot: '' }) - const result = await rollbackOrcad(options()) - expect(result).toMatchObject({ - outcome: 'refused', - code: 'orcad_rollback_snapshot_unverifiable' - }) - expect(log).toEqual([]) - }) - - it('puts the rescued state and the newer build back when the restore failed', async () => { - const log: string[] = [] - scriptHost(log, { restores: ['FAILED'] }) + scriptHost(log, { restore: 'FAILED' }) const result = await rollbackOrcad(options()) expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_rollback_restore_failed' }) - expect(log).toEqual([ - `stop:${ACTIVE}`, - 'rescue', - 'restore', - 'restore-rescue', - `launch:${ACTIVE}` - ]) - expect(result.outcome === 'failed' && result.reason).toContain('is serving again') + expect(log).toEqual([`stop:${ACTIVE}`, 'restore']) + expect(result.outcome === 'failed' && result.reason).toContain('Do NOT start the older build') }) - it('keeps the newer state when a failed target may have changed it and no census is fresh', async () => { + it('leaves the record naming the newer version when the target fails to come up', async () => { const log: string[] = [] - scriptHost(log, { targetReady: false }) + scriptHost(log) + mockExec.mockImplementation(async (_conn, command: string) => { + const text = String(command) + if (text.includes('state.tar') && text.includes('test -f') && !text.includes('tar -C')) { + return 'PRESENT' + } + if (text.includes('kill -TERM')) { + return 'STOPPED' + } + if (text.includes('tar -C') && text.includes('-xf')) { + return 'RESTORED' + } + // The target never publishes readiness. + return '' + }) const result = await rollbackOrcad(options()) expect(result).toMatchObject({ outcome: 'failed', code: 'orcad_activation_no_readiness' }) - expect(result.outcome === 'failed' && result.reason).toContain('fresh host terminal census') - expect(log).toEqual([ - `stop:${ACTIVE}`, - 'rescue', - 'restore', - `launch:${TARGET}`, - `stop:${TARGET}`, - 'compare' - ]) // Until the target is proven serving, `active` must still name the version an operator // would have to bring back. - expect( - vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.some((call) => String(call[1]).endsWith('orcad-active.json')) - ).toBe(false) - }) - - it('restores the rescue and the newer build after a failed target when no terminal started', async () => { - const log: string[] = [] - scriptHost(log, { targetReady: false }) - const result = await rollbackOrcad(options({ terminalsStartedSince: async () => 0 })) - expect(result.outcome === 'failed' && result.reason).toContain('is serving again') - expect(log.slice(-3)).toEqual(['compare', 'restore-rescue', `launch:${ACTIVE}`]) + expect(vi.mocked(writeRelayFile)).not.toHaveBeenCalled() }) it('records the rollback only after the target answers healthy', async () => { @@ -278,9 +220,9 @@ describe('rollbackOrcad', () => { scriptHost(log) await rollbackOrcad(options()) const written = vi - .mocked(writeAtomicOrcadRemoteRecord) - .mock.calls.find((call) => String(call[1]).endsWith('orcad-active.json')) - expect(JSON.parse(String(written?.[2]))).toMatchObject({ + .mocked(writeRelayFile) + .mock.calls.find((call) => String(call[2]).endsWith('orcad-active.json')) + expect(JSON.parse(String(written?.[3]))).toMatchObject({ active: TARGET, previous: null, snapshot: null diff --git a/src/main/ssh/orcad-remote-rollback.ts b/src/main/ssh/orcad-remote-rollback.ts index 4640a6079bc..412021c2f22 100644 --- a/src/main/ssh/orcad-remote-rollback.ts +++ b/src/main/ssh/orcad-remote-rollback.ts @@ -8,27 +8,46 @@ * build cannot be shown to read the result. Rollback therefore restores the pre-activation * snapshot, and refuses when restoring it would orphan work (`assessOrcadRollback`). * - * The order is the whole safety argument: stop, rescue, restore, then start. Restoring under - * a running orcad would replace the store beneath a process holding it open, and starting - * before restoring would let the old build migrate the new build's state. The rescue copy of - * the newer state, and the journal under the activation fence, make each step undoable. + * The order below is the whole safety argument: stop, then restore, then start. Restoring + * under a running orcad would replace the store beneath a process holding it open, and + * starting before restoring would let the old build migrate the new build's state — the + * failure this is meant to avoid, arrived at from the other side. */ import type { SshConnection } from './ssh-connection' -import type { OrcadActivationRecord } from './orcad-activation-record' -import type { OrcadTerminalCensus } from './orcad-update-plan' -import type { OrcadActivationVerdict } from './orcad-activation-gate' -import type { OrcadDaemonProtocolFacts } from './orcad-daemon-protocol-crossing' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import { sameOrcadActivationRecord } from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import { - resolveOrcadActivationReadinessTimeout, - withOrcadActivationLock -} from './orcad-activation-lock' import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' -import { rollbackOrcadLocked } from './orcad-rollback-transition' +import { execCommand } from './ssh-relay-deploy-helpers' +import { ORCAD_INSTALL_MODEL } from './remote-install-model' +import { computeRemoteInstallDir } from './ssh-relay-versioned-install' +import { writeRelayFile } from './ssh-relay-install-transfers' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { + ORCAD_STATE_SNAPSHOT_DIR, + serializeOrcadActivationRecord, + withRolledBackVersion, + type OrcadActivationRecord +} from './orcad-activation-record' +import { assessOrcadRollback, type OrcadTerminalCensus } from './orcad-update-plan' +import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate' +import { + ORCAD_LOG_FILENAME, + orcadLaunchCommand, + parseOrcadReadinessOutput, + readOrcadReadinessCommand +} from './orcad-remote-launch' +import { + newestStateMtimeCommand, + parseNewestStateMtimeSeconds, + parseOrcadSnapshotRestore, + probeOrcadStateSnapshotCommand, + restoreOrcadStateSnapshotCommand +} from './orcad-state-snapshot' +import { + orcadStopFreedTheHost, + parseOrcadStopOutcome, + stopOrcadCommand +} from './orcad-remote-process-control' +import { orcadActivationPath } from './orcad-activation-record-store' +import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' export type OrcadRollbackOptions = { conn: SshConnection @@ -42,10 +61,6 @@ export type OrcadRollbackOptions = { census: OrcadTerminalCensus /** Expected build hash of the rollback target, from the client's copy of those bytes. */ targetBuildHash: string - /** The rollback target's daemon protocol facts, from the same copy. */ - targetDaemonProtocol: OrcadDaemonProtocolFacts - /** A fresh census for a failed target that changed state; see `orcad-incumbent-recovery.ts`. */ - terminalsStartedSince?: (since: string) => Promise readinessTimeoutMs?: number now?: () => Date sleep?: (ms: number) => Promise @@ -57,34 +72,170 @@ export type OrcadRollbackResult = | { outcome: 'refused'; code: string; reason: string } | { outcome: 'failed'; code: string; reason: string } -export async function rollbackOrcad(input: OrcadRollbackOptions): Promise { - assertPosixOrcadHost(input.host) - const options = { - ...input, - readinessTimeoutMs: resolveOrcadActivationReadinessTimeout( - input.readinessTimeoutMs, - ORCAD_STARTUP_READINESS_TIMEOUT_MS - ) - } - return withOrcadActivationLock(options, async (lock) => { - if (await readOrcadActivationTransaction(options)) { - lock.retain() - return { - outcome: 'refused', - code: 'orcad_activation_recovery_required', - reason: - 'An earlier activation on this host was interrupted. Recover it before rolling back.' - } - } - if (!sameOrcadActivationRecord(await readOrcadActivationRecord(options), options.record)) { - return { - outcome: 'refused', - code: 'orcad_rollback_record_changed', - reason: - 'The host activation record changed while this rollback was waiting. Refresh the ' + - 'host state and review the new rollback target before trying again.' - } - } - return rollbackOrcadLocked(options, lock) +const READINESS_POLL_MS = 500 +const STOP_WAIT_SECONDS = 20 + +function exec(options: OrcadRollbackOptions, command: string): Promise { + return execCommand(options.conn, command, { + wrapCommand: options.host.commandDialect !== 'powershell', + signal: options.signal }) } + +function snapshotDirPath(options: OrcadRollbackOptions, dirName: string): string { + return joinRemotePath( + options.host, + options.remoteHome, + RELAY_REMOTE_DIR, + ORCAD_STATE_SNAPSHOT_DIR, + dirName + ) +} + +/** Has the store been written since activation? `null` when it cannot be established. */ +async function readStateWritesSinceActivation( + options: OrcadRollbackOptions +): Promise { + if (!options.record.activatedAt) { + return null + } + const activatedAtSeconds = Math.floor(Date.parse(options.record.activatedAt) / 1000) + if (!Number.isFinite(activatedAtSeconds)) { + return null + } + const newest = parseNewestStateMtimeSeconds( + await exec(options, newestStateMtimeCommand(options.host, options.userDataDir)).catch(() => '') + ) + return newest === null ? null : newest >= activatedAtSeconds +} + +export async function rollbackOrcad(options: OrcadRollbackOptions): Promise { + const now = options.now ?? ((): Date => new Date()) + const snapshotPresent = options.record.snapshot + ? ( + await exec( + options, + probeOrcadStateSnapshotCommand( + options.host, + snapshotDirPath(options, options.record.snapshot.dirName) + ) + ).catch(() => 'ABSENT') + ).trim() === 'PRESENT' + : false + + const safety = assessOrcadRollback({ + record: options.record, + snapshotPresent, + census: options.census, + stateWritesSinceActivation: await readStateWritesSinceActivation(options) + }) + if (safety.safety === 'unsafe') { + return { outcome: 'refused', code: safety.code, reason: safety.reason } + } + + if (options.record.active) { + const outgoingDir = computeRemoteInstallDir( + ORCAD_INSTALL_MODEL, + options.remoteHome, + options.record.active + ) + const stopped = parseOrcadStopOutcome( + await exec( + options, + stopOrcadCommand(options.host, outgoingDir, { + waitSeconds: STOP_WAIT_SECONDS, + nodePath: options.nodePath + }) + ) + ) + if (!orcadStopFreedTheHost(stopped)) { + return { + outcome: 'failed', + code: 'orcad_rollback_stop_incomplete', + reason: + `Could not verify that orcad ${options.record.active} exited (${stopped}). ` + + 'Nothing was restored. Orca requires matching runtime readiness before signaling ' + + 'an incumbent and confirmed exit before replacing its state.' + } + } + } + + // Why between stop and start: the store must be replaced while no orcad holds it, and + // before the older build gets a chance to migrate the newer build's state. + const restored = parseOrcadSnapshotRestore( + await exec( + options, + restoreOrcadStateSnapshotCommand( + options.host, + options.userDataDir, + // Guarded by `assessOrcadRollback`: `unsafe` covers a missing snapshot. + snapshotDirPath(options, options.record.snapshot?.dirName ?? '') + ) + ).catch(() => 'FAILED') + ) + if (restored !== 'restored') { + return { + outcome: 'failed', + code: 'orcad_rollback_restore_failed', + reason: + `The pre-activation snapshot could not be restored (${restored}). orcad is stopped and ` + + 'the data root may be partially replaced. Do NOT start the older build against it; ' + + `re-deploy ${options.record.active ?? 'the newer version'}, which can read what is there.` + } + } + + const targetDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, safety.target) + await exec( + options, + orcadLaunchCommand(options.host, { + remoteInstallDir: targetDir, + nodePath: options.nodePath, + fullVersion: safety.target, + userDataDir: options.userDataDir, + bindHost: options.bindHost, + port: options.port + }) + ) + const deadline = Date.now() + (options.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) + const sleep = options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) + let parsed = parseOrcadReadinessOutput('') + while (Date.now() < deadline && parsed.state === 'pending') { + options.signal?.throwIfAborted() + parsed = parseOrcadReadinessOutput( + await exec(options, readOrcadReadinessCommand(options.host, targetDir)) + ) + if (parsed.state === 'pending') { + await sleep(READINESS_POLL_MS) + } + } + const verdict = evaluateOrcadActivation(parsed.state === 'ready' ? parsed.readiness : null, { + buildHash: options.targetBuildHash, + fullVersion: safety.target + }) + if (verdict.decision === 'reject') { + return { + outcome: 'failed', + code: verdict.code, + reason: + `The rollback target ${safety.target} did not come up healthy: ${verdict.reason} The ` + + `store has been restored to its pre-activation state. Its stderr is at ` + + `${joinRemotePath(options.host, targetDir, ORCAD_LOG_FILENAME)}.` + } + } + + // Why the record is written last: until the target is proven serving, `active` still names + // the version an operator would need to bring back, and `previous` still names this target. + await writeRelayFile( + options.conn, + options.host, + orcadActivationPath(options.host, options.remoteHome), + serializeOrcadActivationRecord(withRolledBackVersion(options.record, now())), + { signal: options.signal } + ) + return { + outcome: 'rolled-back', + target: safety.target, + discarded: safety.safety === 'lossy' ? safety.discards : [], + verdict + } +} diff --git a/src/main/ssh/orcad-remote-runtime-control.ts b/src/main/ssh/orcad-remote-runtime-control.ts deleted file mode 100644 index e3ddc1566bf..00000000000 --- a/src/main/ssh/orcad-remote-runtime-control.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** Launch a slot and poll its readiness file: the one loop deploy, rollback and recovery share. */ -import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' -import { - orcadLaunchCommand, - parseOrcadReadinessOutput, - readOrcadReadinessCommand, - type OrcadLaunchSpec, - type OrcadReadinessParse -} from './orcad-remote-launch' -import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' -import type { RemoteHostPlatform } from './ssh-remote-platform' - -const READINESS_POLL_MS = 500 - -export type OrcadRemoteExecTarget = { - conn: SshConnection - host: RemoteHostPlatform - signal?: AbortSignal -} - -export function execOrcadRemote( - target: OrcadRemoteExecTarget, - command: string, - signal = target.signal -): Promise { - return execCommand(target.conn, command, { - wrapCommand: target.host.commandDialect !== 'powershell', - signal - }) -} - -/** Recovery paths must finish even when the request that started them was cancelled. */ -export function withoutAbortSignal( - options: T -): Omit { - const { signal: _signal, ...rest } = options - return rest -} - -export async function launchOrcadAndAwaitReadiness( - target: OrcadRemoteExecTarget & { - readinessTimeoutMs?: number - sleep?: (ms: number) => Promise - }, - spec: OrcadLaunchSpec -): Promise { - await execOrcadRemote(target, orcadLaunchCommand(target.host, spec)) - const deadline = Date.now() + (target.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) - const sleep = target.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) - let last = parseOrcadReadinessOutput('') - while (Date.now() < deadline) { - target.signal?.throwIfAborted() - last = parseOrcadReadinessOutput( - await execOrcadRemote(target, readOrcadReadinessCommand(target.host, spec.remoteInstallDir)) - ) - if (last.state !== 'pending') { - return last - } - await sleep(READINESS_POLL_MS) - } - return last -} diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index 0b0cab2a7a8..4839fd7b2c2 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -49,7 +49,6 @@ import { restoreOrcadStateSnapshotCommand } from './orcad-state-snapshot' import { getRemoteHostPlatform } from './ssh-remote-platform' -import { ORCAD_STOP_REQUEST_FILENAME } from '../../shared/orcad-stop-request' const host = getRemoteHostPlatform('linux-x64') let root = '' @@ -87,39 +86,20 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) -async function launchTestRuntime( - legacyWrapper = false, - stopRequests = false -): Promise<{ +async function launchTestRuntime(legacyWrapper = false): Promise<{ runtimePid: number recordedPid: number terminatedFile: string }> { const terminatedFile = join(versionDir, 'terminated') - const requestFile = join(versionDir, ORCAD_STOP_REQUEST_FILENAME) writeFileSync( join(versionDir, 'orcad.js'), [ - `const fs = require('node:fs');`, `process.on('SIGTERM', () => {`, - ` fs.writeFileSync(${JSON.stringify(terminatedFile)}, 'terminated');`, + ` require('node:fs').writeFileSync(${JSON.stringify(terminatedFile)}, 'terminated');`, ` process.exit(0);`, `});`, - ...(stopRequests - ? [ - // Like orcad's listener: consume the slot request, then stop. - `setInterval(() => {`, - ` if (fs.existsSync(${JSON.stringify(requestFile)})) {`, - ` fs.unlinkSync(${JSON.stringify(requestFile)});`, - ` fs.writeFileSync(${JSON.stringify(terminatedFile)}, 'requested');`, - ` process.exit(0);`, - ` }`, - `}, 20);` - ] - : []), - `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid${ - stopRequests ? ', stopRequests: 1' : '' - }}}));`, + `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid}}));`, `setTimeout(() => process.exit(1), 10_000);` ].join('\n') ) @@ -358,21 +338,6 @@ describe('liveness and stop commands, run for real', () => { expect(stopTestRuntime()).toBe('already-exited') }) - it('stops a build that consumes stop requests by request file, not by signal', async () => { - const { terminatedFile } = await launchTestRuntime(false, true) - expect(stopTestRuntime()).toBe('stopped') - expect(readFileSync(terminatedFile, 'utf8')).toBe('requested') - expect(existsSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) - }) - - it('clears a stop request the previous process never consumed before launching', async () => { - writeFileSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME), '') - const { runtimePid } = await launchTestRuntime(false, true) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('LIVE') - expect(runtimePid).toBeGreaterThan(1) - }) - it('refuses a legacy wrapper PID both before and after its shell exits', async () => { const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime(true) expect(recordedPid).not.toBe(runtimePid) diff --git a/src/main/ssh/orcad-remote-stop.ts b/src/main/ssh/orcad-remote-stop.ts deleted file mode 100644 index dcfbe314314..00000000000 --- a/src/main/ssh/orcad-remote-stop.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Decommissioning a managed orcad: stop its active instance and record that nothing serves. - * - * Runs under the activation fence and journal, so an interrupted decommission recovers like an - * activation does. It refuses while the terminal census says terminals are live or cannot be - * counted, and it never signals: the instance-bound request reaches only the orcad it names. - * Only proven exit deactivates the record; anything less withdraws the request or keeps the - * fence. POSIX hosts only, like the rest of the orcad deploy core. - */ -import { randomUUID } from 'node:crypto' -import type { OrcadActivationRecord } from './orcad-activation-record' -import { - readOrcadActivationRecord, - writeOrcadActivationRecord -} from './orcad-activation-record-store' -import { sameOrcadActivationRecord } from './orcad-activation-transaction' -import { - readOrcadActivationTransaction, - writeOrcadActivationTransaction -} from './orcad-activation-transaction-store' -import { withOrcadActivationLock } from './orcad-activation-lock' -import { - createOrcadDecommissionTransaction, - withOrcadDecommissionProcessExited, - withOrcadDecommissionStopDispatched -} from './orcad-decommission-transaction' -import { readRemoteOrcadManagedStopTarget } from './orcad-managed-remote-stop' -import { settleOrcadDecommissionStop } from './orcad-decommission-stop' -import { assertPosixOrcadHost } from './orcad-remote-host-support' -import type { OrcadSlotOptions } from './orcad-recovery-slot' -import type { OrcadDecommissionResult } from '../../shared/orcad-decommission' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' - -export type OrcadDecommissionOptions = OrcadSlotOptions & { - /** The record the caller reviewed; a changed host record refuses rather than guessing. */ - record: OrcadActivationRecord - /** Taken by the caller just before; any live or uncounted terminal refuses. */ - census: OrcadTerminalCensus - now?: () => Date -} - -type Refusal = Extract - -function refuse(verdict: Refusal['verdict'], code: string, reason: string): Refusal { - return { outcome: 'refused', verdict, code, reason } -} - -function censusRefusal(census: OrcadTerminalCensus): Refusal | null { - if (census.liveSessions === null) { - return refuse( - 'unverifiable', - 'orcad_decommission_census_unavailable', - 'The terminal daemon did not answer a session count, so decommissioning could end ' + - 'running work. Retry when the host answers.' - ) - } - if (census.liveSessions > 0) { - return refuse( - 'live', - 'orcad_decommission_terminals_running', - `${census.liveSessions} terminal${census.liveSessions === 1 ? ' is' : 's are'} still ` + - 'running on this host. Close them before decommissioning the server.' - ) - } - return null -} - -export async function decommissionRemoteOrcad( - options: OrcadDecommissionOptions -): Promise { - assertPosixOrcadHost(options.host) - const now = options.now ?? ((): Date => new Date()) - return withOrcadActivationLock(options, async (lock) => { - if (await readOrcadActivationTransaction(options)) { - lock.retain() - return refuse( - 'unverifiable', - 'orcad_activation_recovery_required', - 'An earlier activation on this host was interrupted. Recover it before decommissioning.' - ) - } - const record = await readOrcadActivationRecord(options) - if (!sameOrcadActivationRecord(record, options.record)) { - return refuse( - 'unverifiable', - 'orcad_decommission_record_changed', - 'The host activation record changed since it was reviewed. Refresh and try again.' - ) - } - const activeVersion = record.active - if (!activeVersion) { - return refuse( - 'unverifiable', - 'orcad_decommission_nothing_active', - 'No orcad version is active on this host, so there is nothing to decommission.' - ) - } - const census = censusRefusal(options.census) - if (census) { - return census - } - const target = await readRemoteOrcadManagedStopTarget(options, activeVersion) - if (target.state === 'refused') { - return refuse(target.verdict, target.code, target.reason) - } - - let transaction = createOrcadDecommissionTransaction({ - transactionId: randomUUID(), - recordBefore: { ...record, active: activeVersion }, - now: now() - }) - await writeOrcadActivationTransaction(options, transaction) - lock.retainOnError() - const request = { - schemaVersion: 1 as const, - transactionId: transaction.transactionId, - ...target.context, - // Best effort: an idle daemon goes with orcad, a busy one keeps its terminals. - retireIdleDaemon: true as const - } - // Durable before it can reach the host, so recovery can settle exactly this request. - transaction = withOrcadDecommissionStopDispatched(transaction, request, now()) - await writeOrcadActivationTransaction(options, transaction) - - const settlement = await settleOrcadDecommissionStop(options, request) - if (settlement.state === 'withdrawn') { - // orcad never acted on it and keeps serving; the record never changed. - return refuse(settlement.verdict, 'orcad_decommission_stop_withdrawn', settlement.reason) - } - if (settlement.state === 'unsettled') { - lock.retain() - return refuse(settlement.verdict, 'orcad_decommission_stop_unsettled', settlement.reason) - } - transaction = withOrcadDecommissionProcessExited(transaction, now()) - await writeOrcadActivationTransaction(options, transaction) - await writeOrcadActivationRecord(options, transaction.recordAfter) - return { - outcome: 'decommissioned', - version: activeVersion, - retirement: settlement.retirement - } - }) -} diff --git a/src/main/ssh/orcad-rollback-transition.ts b/src/main/ssh/orcad-rollback-transition.ts deleted file mode 100644 index ce5fe39f34b..00000000000 --- a/src/main/ssh/orcad-rollback-transition.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** The locked, journaled half of `rollbackOrcad`. */ -import { randomUUID } from 'node:crypto' -import type { OrcadRollbackOptions, OrcadRollbackResult } from './orcad-remote-rollback' -import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' -import { withRolledBackVersion } from './orcad-activation-record' -import { writeOrcadActivationRecord } from './orcad-activation-record-store' -import { evaluateOrcadActivation } from './orcad-activation-gate' -import { assessOrcadRollback } from './orcad-update-plan' -import { ORCAD_LOG_FILENAME, type OrcadReadinessParse } from './orcad-remote-launch' -import { - captureOrcadStateSnapshotCommand, - newestStateMtimeCommand, - orcadRollbackRescueDirName, - parseNewestStateMtimeSeconds, - parseOrcadSnapshotCapture, - parseOrcadSnapshotPresence, - parseOrcadSnapshotRestore, - probeOrcadStateSnapshotCommand, - restoreOrcadStateSnapshotCommand -} from './orcad-state-snapshot' -import { orcadStopFreedTheHost } from './orcad-remote-process-control' -import { joinRemotePath } from './ssh-remote-platform' -import type { OrcadActivationLockControl } from './orcad-activation-lock' -import type { OrcadRollbackTransaction } from './orcad-activation-transaction' -import { - createOrcadRollbackTransaction, - withOrcadRollbackPhase, - withOrcadRollbackRescue -} from './orcad-activation-transaction-transitions' -import { writeOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { - execOrcadRemote, - launchOrcadAndAwaitReadiness, - withoutAbortSignal -} from './orcad-remote-runtime-control' -import { - orcadSlotDir, - resolveOrcadSlotIdentity, - stopOrcadSlot, - ORCAD_SLOT_STOP_WAIT_SECONDS, - type OrcadSlotIdentity -} from './orcad-recovery-slot' -import { orcadSnapshotPath, recoverOrcadIncumbent } from './orcad-incumbent-recovery' - -/** A confirmed failure reads as no answer; an unconfirmed one propagates and keeps the fence. */ -async function execOrEmpty(options: OrcadRollbackOptions, command: string): Promise { - return execOrcadRemote(options, command).catch((error: unknown) => { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return '' - }) -} - -async function stateWritesSinceActivation(options: OrcadRollbackOptions): Promise { - const activatedAtSeconds = Math.floor(Date.parse(options.record.activatedAt ?? '') / 1000) - if (!Number.isFinite(activatedAtSeconds)) { - return null - } - const newest = parseNewestStateMtimeSeconds( - await execOrEmpty(options, newestStateMtimeCommand(options.host, options.userDataDir)) - ) - return newest === null ? null : newest >= activatedAtSeconds -} - -export async function rollbackOrcadLocked( - options: OrcadRollbackOptions, - lock: OrcadActivationLockControl -): Promise { - const now = options.now ?? ((): Date => new Date()) - const snapshot = options.record.snapshot - const presence = snapshot - ? parseOrcadSnapshotPresence( - await execOrEmpty( - options, - probeOrcadStateSnapshotCommand(options.host, orcadSnapshotPath(options, snapshot.dirName)) - ) - ) - : 'absent' - const safety = assessOrcadRollback({ - record: options.record, - snapshotPresent: presence === 'unverifiable' ? null : presence === 'present', - census: options.census, - targetDaemonProtocol: options.targetDaemonProtocol, - stateWritesSinceActivation: await stateWritesSinceActivation(options) - }) - if (safety.safety === 'unsafe') { - return { outcome: 'refused', code: safety.code, reason: safety.reason } - } - if (!snapshot || !options.record.active) { - return { - outcome: 'refused', - code: 'orcad_rollback_no_active', - reason: 'No orcad version is active on this host, so there is nothing to roll back from.' - } - } - let incumbent: OrcadSlotIdentity - try { - incumbent = await resolveOrcadSlotIdentity(options, options.record.active) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - return { - outcome: 'refused', - code: 'orcad_rollback_active_identity_unverifiable', - reason: `The active orcad identity could not be verified: ${errorMessage(error)} Nothing was changed.` - } - } - - const startedAt = now() - let transaction: OrcadRollbackTransaction = createOrcadRollbackTransaction({ - transactionId: randomUUID(), - incumbentVersion: incumbent.version, - targetVersion: safety.target, - recordBefore: options.record, - recordAfter: withRolledBackVersion(options.record, startedAt), - rescueDirName: orcadRollbackRescueDirName(incumbent.version, startedAt.getTime()), - now: startedAt - }) - await writeOrcadActivationTransaction(options, transaction) - lock.retainOnError() - - const stopped = await stopOrcadSlot(options, incumbent.remoteDir, false) - if (!orcadStopFreedTheHost(stopped)) { - if (stopped === 'still-running') { - lock.retain() - } - return { - outcome: 'failed', - code: 'orcad_rollback_stop_incomplete', - reason: - `Could not verify that orcad ${incumbent.version} exited within ` + - `${ORCAD_SLOT_STOP_WAIT_SECONDS}s (${stopped}). Nothing was restored. Orca requires ` + - 'matching runtime readiness before signaling an incumbent and confirmed exit before ' + - 'replacing its state.' - } - } - transaction = withOrcadRollbackPhase(transaction, 'incumbent-stopped', now()) - await writeOrcadActivationTransaction(options, transaction) - - const rescue = parseOrcadSnapshotCapture( - await execOrEmpty( - options, - captureOrcadStateSnapshotCommand( - options.host, - options.userDataDir, - orcadSnapshotPath(options, transaction.rescue.dirName) - ) - ) - ) - if (rescue === 'failed') { - const recovered = await putIncumbentBack(options, lock, transaction, incumbent, null) - return { - outcome: 'failed', - code: 'orcad_rollback_rescue_snapshot_failed', - reason: - 'The current state could not be preserved in a rescue snapshot, so the data root was ' + - `not replaced. ${recovered}` - } - } - transaction = withOrcadRollbackRescue(transaction, rescue, now()) - await writeOrcadActivationTransaction(options, transaction) - - // Why between stop and start: the older build must never load the newer build's state. - const restored = parseOrcadSnapshotRestore( - await execOrEmpty( - options, - restoreOrcadStateSnapshotCommand( - options.host, - options.userDataDir, - orcadSnapshotPath(options, snapshot.dirName) - ) - ) - ) - if (restored !== 'restored') { - const recovered = await putIncumbentBack(options, lock, transaction, incumbent, null) - return { - outcome: 'failed', - code: 'orcad_rollback_restore_failed', - reason: `The pre-activation snapshot could not be restored (${restored}). ${recovered}` - } - } - transaction = withOrcadRollbackPhase(transaction, 'rollback-state-restored', now()) - await writeOrcadActivationTransaction(options, transaction) - - const targetDir = orcadSlotDir(options, safety.target) - let parsed: OrcadReadinessParse | null = null - let launchError: unknown - try { - parsed = await launchOrcadAndAwaitReadiness(options, { - remoteInstallDir: targetDir, - nodePath: options.nodePath, - fullVersion: safety.target, - userDataDir: options.userDataDir, - bindHost: options.bindHost, - port: options.port - }) - } catch (error) { - if (isUnconfirmedSshCommandTermination(error)) { - throw error - } - launchError = error - } - const verdict = evaluateOrcadActivation(parsed?.state === 'ready' ? parsed.readiness : null, { - buildHash: options.targetBuildHash, - fullVersion: safety.target - }) - if (verdict.decision === 'reject') { - const reason = - launchError === undefined - ? verdict.reason - : `It failed while starting: ${errorMessage(launchError)}` - const targetStop = await stopOrcadSlot(withoutAbortSignal(options), targetDir, true).catch( - (error: unknown) => `unverifiable: ${errorMessage(error)}` - ) - const recovered = - targetStop === 'stopped' || targetStop === 'already-exited' - ? await putIncumbentBack(options, lock, transaction, incumbent, safety.target) - : retainedAfterTargetStop(lock, targetStop) - return { - outcome: 'failed', - code: launchError === undefined ? verdict.code : 'orcad_rollback_target_launch_failed', - reason: - `The rollback target ${safety.target} did not come up healthy: ${reason} ${recovered} ` + - `Its stderr is at ${joinRemotePath(options.host, targetDir, ORCAD_LOG_FILENAME)}.` - } - } - - // The record is written last: until the target is proven serving, `active` still names the - // version an operator would need to bring back. - transaction = withOrcadRollbackPhase(transaction, 'target-ready', now()) - await writeOrcadActivationTransaction(options, transaction) - await writeOrcadActivationRecord(options, transaction.recordAfter) - return { - outcome: 'rolled-back', - target: safety.target, - discarded: safety.safety === 'lossy' ? safety.discards : [], - verdict - } -} - -function retainedAfterTargetStop(lock: OrcadActivationLockControl, stopped: string): string { - lock.retain() - return ( - `The target could not be confirmed stopped (${stopped}), so the rescue snapshot was not ` + - 'restored over state it may still own. This host requires recovery.' - ) -} - -/** Restores the rescued state when it was replaced, then restarts the incumbent. */ -async function putIncumbentBack( - options: OrcadRollbackOptions, - lock: OrcadActivationLockControl, - transaction: OrcadRollbackTransaction, - incumbent: OrcadSlotIdentity, - launchedVersion: string | null -): Promise { - try { - const recovery = await recoverOrcadIncumbent(withoutAbortSignal(options), { - transactionStartedAt: transaction.startedAt, - launchedVersion, - incumbent, - restoreState: transaction.rescue.state === 'pending' ? null : transaction.rescue, - slotsProvenExited: true - }) - if (recovery.outcome === 'refused') { - lock.retain() - return recovery.reason - } - lock.recovered() - return `orcad ${incumbent.version} was restored and is serving again.` - } catch (error) { - lock.retain() - return `Restoring orcad ${incumbent.version} failed: ${errorMessage(error)} This host requires recovery.` - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/ssh/orcad-runtime-decommission.test.ts b/src/main/ssh/orcad-runtime-decommission.test.ts deleted file mode 100644 index b045fbcefdc..00000000000 --- a/src/main/ssh/orcad-runtime-decommission.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { existsSync, readFileSync, rmSync } from 'node:fs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { getRuntimeEnvironmentSidecarPath } from '../../shared/runtime-environment-sidecar' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { - createManagedLifecycleHarness, - MANAGED_VERSION -} from './orcad-managed-lifecycle-test-fixture' - -const mocks = vi.hoisted(() => { - const state: { store: unknown; transaction: unknown } = { store: null, transaction: null } - return { - state, - resolveContext: vi.fn(), - census: vi.fn(), - decommission: vi.fn(), - recover: vi.fn(), - cancel: vi.fn(), - keepServing: vi.fn(), - closeTunnel: vi.fn(), - ensureTunnel: vi.fn(), - retire: vi.fn() - } -}) - -vi.mock('./ssh-target-registry', () => ({ - getSshConnectionManager: () => ({ connect: async () => ({}) }), - getSshTargetRegistryStore: () => mocks.state.store -})) -vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) -vi.mock('./orcad-terminal-census-client', () => ({ collectManagedTerminalCensus: mocks.census })) -vi.mock('./orcad-remote-stop', () => ({ decommissionRemoteOrcad: mocks.decommission })) -vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) -vi.mock('./orcad-activation-transaction-store', () => ({ - readOrcadActivationTransaction: async () => mocks.state.transaction -})) -vi.mock('./orcad-activation-lock', () => ({ - withStaleOrcadActivationRecoveryLock: async ( - _options: unknown, - run: (lock: { retain: () => void }) => Promise - ) => run({ retain: () => {} }) -})) -vi.mock('./orcad-managed-remote-stop', () => ({ cancelRemoteOrcadManagedStop: mocks.cancel })) -vi.mock('./orcad-decommission-recovery', () => ({ reconcileOrcadDecommission: mocks.keepServing })) -vi.mock('./orcad-managed-tunnel', () => ({ - closeOrcadManagedTunnel: mocks.closeTunnel, - ensureOrcadManagedTunnel: mocks.ensureTunnel -})) - -const { cancelManagedOrcadStop, stopManagedOrcadEnvironment } = - await import('./orcad-runtime-lifecycle') - -const idle = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 7 } -let harness: ReturnType - -const stop = (isActive = false) => - stopManagedOrcadEnvironment( - harness.userDataPath, - { selector: 'Managed' }, - { isActiveEnvironment: () => isActive, retireLocalState: mocks.retire } - ) - -function expectStillLinked(): void { - expect(listEnvironments(harness.userDataPath)[0]?.orcadDeployment).toBeDefined() - expect(getManagedOrcadOwnerEnvironmentId(harness.current().owner)).toBe('environment-1') - expect(mocks.retire).not.toHaveBeenCalled() - expect(mocks.closeTunnel).not.toHaveBeenCalled() -} - -beforeEach(() => { - vi.resetAllMocks() - harness = createManagedLifecycleHarness() - mocks.state.store = harness.targetStore - mocks.state.transaction = null - mocks.resolveContext.mockImplementation(async () => harness.context()) - mocks.census.mockResolvedValue(idle) -}) - -afterEach(() => rmSync(harness.userDataPath, { recursive: true, force: true })) - -describe('stopManagedOrcadEnvironment', () => { - it('unlinks the server only after the host proves orcad exited', async () => { - mocks.decommission.mockResolvedValueOnce({ - outcome: 'decommissioned', - version: MANAGED_VERSION, - retirement: 'retired' - }) - await expect(stop()).resolves.toEqual({ - outcome: 'unlinked', - verdict: 'exited', - environmentId: 'environment-1', - sshTargetId: 'ssh-1', - stoppedVersion: MANAGED_VERSION, - retirement: 'retired' - }) - expect(mocks.decommission.mock.calls[0]?.[0]).toMatchObject({ census: idle, port: 6_768 }) - expect(listEnvironments(harness.userDataPath)).toEqual([]) - expect( - readFileSync(getRuntimeEnvironmentSidecarPath(harness.userDataPath), 'utf8') - ).not.toContain('orcadDeployment') - expect(harness.current().owner).toBeUndefined() - expect(harness.flushes).toHaveLength(1) - expect(mocks.retire).toHaveBeenCalledWith('environment-1') - expect(mocks.closeTunnel).toHaveBeenCalledWith('environment-1') - }) - - it.each([ - ['live', 'orcad_decommission_terminals_running'], - ['unverifiable', 'orcad_decommission_census_unavailable'], - ['unverifiable', 'orcad_decommission_stop_unsettled'] - ] as const)('keeps every link when the verdict is %s (%s)', async (verdict, code) => { - mocks.decommission.mockResolvedValueOnce({ outcome: 'refused', verdict, code, reason: 'no' }) - await expect(stop()).resolves.toMatchObject({ outcome: 'refused', verdict, code }) - expectStillLinked() - expect(existsSync(getRuntimeEnvironmentSidecarPath(harness.userDataPath))).toBe(true) - }) - - it('refuses the Active Server before contacting the host', async () => { - await expect(stop(true)).resolves.toMatchObject({ code: 'orcad_stop_active_environment' }) - expect(mocks.resolveContext).not.toHaveBeenCalled() - expectStillLinked() - }) - - it('finishes an interrupted stop from its journal and unlinks only on proven exit', async () => { - mocks.state.transaction = { operation: 'decommission', activeVersion: MANAGED_VERSION } - mocks.recover.mockResolvedValueOnce({ - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_decommission_unsettled', - reason: 'no answer' - }) - await expect(stop()).resolves.toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) - expectStillLinked() - mocks.recover.mockResolvedValueOnce({ - outcome: 'recovered', - resolution: 'committed', - activeVersion: null, - readiness: null - }) - await expect(stop()).resolves.toMatchObject({ outcome: 'unlinked', verdict: 'exited' }) - expect(mocks.decommission).not.toHaveBeenCalled() - }) - - it('refuses while another interrupted operation holds the journal', async () => { - mocks.state.transaction = { operation: 'activate' } - await expect(stop()).resolves.toMatchObject({ code: 'orcad_activation_recovery_required' }) - expectStillLinked() - }) - - it('unlinks a server whose record already shows nothing active', async () => { - mocks.resolveContext.mockImplementation(async () => harness.context({ active: null })) - await expect(stop()).resolves.toMatchObject({ outcome: 'unlinked', stoppedVersion: null }) - expect(mocks.decommission).not.toHaveBeenCalled() - }) -}) - -describe('cancelManagedOrcadStop', () => { - const request = { transactionId: 't-1', version: MANAGED_VERSION } - const cancel = () => cancelManagedOrcadStop(harness.userDataPath, { selector: 'Managed' }) - - it('has nothing to cancel without a decommission journal', async () => { - await expect(cancel()).resolves.toEqual({ outcome: 'none' }) - expect(mocks.cancel).not.toHaveBeenCalled() - }) - - it('withdraws a stop orcad never acted on and keeps the server serving', async () => { - mocks.state.transaction = { - operation: 'decommission', - phase: 'stop-dispatched', - activeVersion: MANAGED_VERSION, - request - } - mocks.cancel.mockResolvedValueOnce({ outcome: 'canceled' }) - mocks.keepServing.mockResolvedValueOnce({ - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: MANAGED_VERSION, - readiness: null - }) - await expect(cancel()).resolves.toEqual({ outcome: 'canceled', activeVersion: MANAGED_VERSION }) - expect(mocks.keepServing.mock.calls[0]?.[1]).toEqual({ - action: 'keep-serving', - version: MANAGED_VERSION - }) - expect(mocks.ensureTunnel).toHaveBeenCalledOnce() - expectStillLinked() - }) - - it('refuses once orcad already acted on the stop', async () => { - mocks.state.transaction = { - operation: 'decommission', - phase: 'stop-dispatched', - activeVersion: MANAGED_VERSION, - request - } - mocks.cancel.mockResolvedValueOnce({ outcome: 'dispatched' }) - await expect(cancel()).resolves.toMatchObject({ - outcome: 'refused', - verdict: 'live', - code: 'orcad_stop_already_dispatched' - }) - expect(mocks.keepServing).not.toHaveBeenCalled() - }) - - it('reports an already-exited stop for stop to finish', async () => { - mocks.state.transaction = { - operation: 'decommission', - phase: 'process-exited', - activeVersion: MANAGED_VERSION, - request - } - await expect(cancel()).resolves.toEqual({ outcome: 'already-stopped' }) - expectStillLinked() - }) -}) diff --git a/src/main/ssh/orcad-runtime-decommission.ts b/src/main/ssh/orcad-runtime-decommission.ts deleted file mode 100644 index 4eb9f335fa0..00000000000 --- a/src/main/ssh/orcad-runtime-decommission.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Stopping a managed orcad and unlinking it, on T6-4's journaled decommission. The server stays - * linked — its SSH claim, tunnel and deployment record — unless the host proves orcad exited. - */ -import type { - OrcadManagedCancelStopResult, - OrcadManagedStopResult -} from '../../shared/orcad-managed-runtime' -import { removeManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import type { - KnownRuntimeEnvironment, - OrcadDeploymentLink -} from '../../shared/runtime-environments' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { withStaleOrcadActivationRecoveryLock } from './orcad-activation-lock' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { reconcileOrcadDecommission } from './orcad-decommission-recovery' -import { cancelRemoteOrcadManagedStop } from './orcad-managed-remote-stop' -import { - managedOrcadSlot, - requireManagedOrcadInfrastructure, - resolveLinkedOrcadContext -} from './orcad-managed-runtime-context' -import { closeOrcadManagedTunnel, ensureOrcadManagedTunnel } from './orcad-managed-tunnel' -import { clearManagedOrcadUpdateDeferral } from './orcad-managed-update-deferrals' -import { decommissionRemoteOrcad } from './orcad-remote-stop' -import { withManagedOrcadLifecycle } from './orcad-runtime-maintenance' -import { collectManagedTerminalCensus } from './orcad-terminal-census-client' -import { RemoteInstallLockBusyError } from './ssh-relay-install-lock' - -export type ManagedOrcadStopPolicy = { - isActiveEnvironment: (environmentId: string) => boolean - /** Invalidates the server's transport and retires its client-side state once it is unlinked. */ - retireLocalState: (environmentId: string) => Promise | void -} - -type Refusal = Extract -type Stopped = { version: string | null; retirement: 'retired' | 'live' | 'unverifiable' | null } - -function refuse(verdict: Refusal['verdict'], code: string, reason: string): Refusal { - return { outcome: 'refused', verdict, code, reason } -} - -export function stopManagedOrcadEnvironment( - userDataPath: string, - args: { selector: string; signal?: AbortSignal }, - policy: ManagedOrcadStopPolicy -): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async (managed) => { - const { environment, deployment } = managed - if (policy.isActiveEnvironment(environment.id)) { - return refuse( - 'live', - 'orcad_stop_active_environment', - 'Choose another Active Server in Advanced before stopping this server.' - ) - } - const stopped = await stopRemote(userDataPath, environment, deployment, args.signal) - if ('outcome' in stopped) { - return stopped - } - await unlinkStoppedEnvironment(userDataPath, environment, deployment, policy) - return { - outcome: 'unlinked', - verdict: 'exited', - environmentId: environment.id, - sshTargetId: deployment.sshTargetId, - stoppedVersion: stopped.version, - retirement: stopped.retirement - } - }) -} - -/** Proven exit, or the refusal that keeps the server linked. */ -async function stopRemote( - userDataPath: string, - environment: KnownRuntimeEnvironment, - deployment: OrcadDeploymentLink, - signal?: AbortSignal -): Promise { - const context = await resolveLinkedOrcadContext(environment, deployment, signal) - const slot = managedOrcadSlot(context, deployment.remotePort, signal) - const transaction = await readOrcadActivationTransaction(slot) - if (transaction?.operation === 'decommission') { - // An earlier stop was interrupted: its journal decides, so finish exactly that one. - const recovered = await recoverInterruptedOrcadActivation(slot) - if (recovered.outcome === 'recovered' && recovered.activeVersion === null) { - return { version: transaction.activeVersion, retirement: null } - } - if (recovered.outcome === 'refused') { - return refuse(recovered.verdict, recovered.code, recovered.reason) - } - if (recovered.outcome === 'pending') { - return refuse('unverifiable', recovered.code, recovered.reason) - } - return refuse( - 'live', - 'orcad_stop_withdrawn', - 'The interrupted stop was withdrawn and the server keeps serving. Stop it again.' - ) - } - if (transaction) { - return refuse( - 'unverifiable', - 'orcad_activation_recovery_required', - 'An earlier update on this server was interrupted. Recover it before stopping.' - ) - } - const record = context.activationRecord - if (!record.active) { - // Only a proven exit (or a deploy that never activated) leaves the record without a version. - return { version: null, retirement: null } - } - const census = await collectManagedTerminalCensus(userDataPath, environment, record) - const result = await decommissionRemoteOrcad({ ...slot, record, census }) - if (result.outcome === 'refused') { - return refuse(result.verdict, result.code, result.reason) - } - return { version: result.version, retirement: result.retirement } -} - -async function unlinkStoppedEnvironment( - userDataPath: string, - environment: KnownRuntimeEnvironment, - deployment: OrcadDeploymentLink, - policy: ManagedOrcadStopPolicy -): Promise { - // Environment first: a crash before the claim is released leaves a hidden target to resume, - // never a linked server whose SSH target was already handed back. - removeManagedOrcadEnvironment(userDataPath, environment.id) - await policy.retireLocalState(environment.id) - await closeOrcadManagedTunnel(environment.id) - const { claims } = requireManagedOrcadInfrastructure() - claims.release(deployment.sshTargetId, environment.id) - clearManagedOrcadUpdateDeferral(environment.id) - await claims.flush() -} - -/** Withdraws a stop orcad has not acted on yet; the server then keeps serving. */ -export function cancelManagedOrcadStop( - userDataPath: string, - args: { selector: string; signal?: AbortSignal } -): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async (managed) => { - const { environment, deployment } = managed - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) - let result: OrcadManagedCancelStopResult - try { - result = await withStaleOrcadActivationRecoveryLock(slot, async (lock) => { - const transaction = await readOrcadActivationTransaction(slot) - if (transaction?.operation !== 'decommission') { - if (transaction) { - lock.retain() - } - return { outcome: 'none' } - } - if (transaction.phase === 'process-exited') { - lock.retain() - return { outcome: 'already-stopped' } - } - if (transaction.request) { - const cancellation = await cancelRemoteOrcadManagedStop(slot, transaction.request) - if (cancellation.outcome !== 'canceled') { - lock.retain() - return { - outcome: 'refused', - verdict: 'live', - code: 'orcad_stop_already_dispatched', - reason: 'orcad already acted on the stop and is shutting down; it cannot be canceled.' - } - } - } - const kept = await reconcileOrcadDecommission(slot, { - action: 'keep-serving', - version: transaction.activeVersion - }) - if (kept.outcome !== 'recovered' || !kept.activeVersion) { - lock.retain() - return { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_stop_cancel_unverifiable', - reason: 'The stop was withdrawn but the server could not be shown to be serving.' - } - } - return { outcome: 'canceled', activeVersion: kept.activeVersion } - }) - } catch (error) { - if (error instanceof RemoteInstallLockBusyError) { - return { - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_stop_still_running', - reason: 'A stop on this server may still be running. Retry after it settles.' - } - } - throw error - } - if (result.outcome === 'canceled') { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } - return result - }) -} diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts deleted file mode 100644 index f0f66ec5e5c..00000000000 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { listEnvironments } from '../../shared/runtime-environment-store' -import type { SshTarget } from '../../shared/ssh-types' -import { getRemoteHostPlatform } from './ssh-remote-platform' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -const mocks = vi.hoisted(() => { - const state: { store: unknown } = { store: null } - return { - state, - events: new Array(), - connect: vi.fn(), - hasDirectAuthority: vi.fn(), - resolveContext: vi.fn(), - recover: vi.fn(), - readRecord: vi.fn(), - deploy: vi.fn(), - probe: vi.fn(), - startTunnel: vi.fn(), - ensureTunnel: vi.fn(), - closeTunnel: vi.fn(), - readTransaction: vi.fn() - } -}) - -vi.mock('./ssh-target-registry', () => ({ - getSshConnectionManager: () => ({ connect: mocks.connect }), - getSshTargetRegistryStore: () => mocks.state.store, - hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority -})) -vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) -vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) -vi.mock('./orcad-activation-record-store', () => ({ readOrcadActivationRecord: mocks.readRecord })) -vi.mock('./orcad-activation-transaction-store', () => ({ - readOrcadActivationTransaction: mocks.readTransaction -})) -vi.mock('./orcad-remote-deploy', () => ({ deployOrcad: mocks.deploy })) -vi.mock('./orcad-artifact-materializer', () => ({ - materializeOrcadArtifact: async () => '/local/orcad' -})) -vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'build-hash' })) -vi.mock('./orcad-active-readiness', () => ({ probeActiveOrcadReadiness: mocks.probe })) -vi.mock('./orcad-terminal-census-client', () => ({ - collectManagedTerminalCensus: async () => ({ - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 39 - }) -})) -vi.mock('./orcad-managed-tunnel', () => ({ - startOrcadManagedTunnel: mocks.startTunnel, - ensureOrcadManagedTunnel: mocks.ensureTunnel, - closeOrcadManagedTunnel: mocks.closeTunnel -})) - -const { createManagedOrcadEnvironment, getManagedOrcadRuntimeStatus } = - await import('./orcad-runtime-lifecycle') - -const VERSION = '0.1.0+abc123' -const emptyRecord = { active: null, previous: null, activatedAt: null, snapshot: null } - -function readiness() { - const endpoint = 'ws://127.0.0.1:6768' - return { - runtimeId: 'runtime-1', - boundEndpoint: endpoint, - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { - available: true, - url: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken: 'device-token', - publicKeyB64: 'public-key' - }), - endpoint, - deviceId: 'device-1', - webClientUrl: null, - scope: 'runtime', - qr: null - } - } -} - -let userDataPath: string -let target: SshTarget -let flushes: number - -function setupStore(overrides: { repos?: { connectionId: string }[] } = {}) { - const store = { - allocateSshTargetGeneration: () => 9, - flushPendingOrThrowAsync: async () => { - flushes += 1 - mocks.events.push('flush') - }, - getFolderWorkspaces: () => [], - getRepos: () => overrides.repos ?? [], - ...emptyDependentStateStore(), - getSshTarget: (id: string) => (id === target.id ? target : undefined), - getSshTargets: () => [target], - updateSshTarget: (_id: string, updates: Partial) => { - target = { ...target, ...updates } - return target - } - } - mocks.state.store = { - getTarget: (id: string) => (id === target.id ? target : undefined), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the claims read only the store methods stubbed above. - getOrcadRuntimeClaims: () => new SshTargetOrcadClaims(store as never) - } -} - -beforeEach(() => { - vi.resetAllMocks() - mocks.events.length = 0 - flushes = 0 - userDataPath = mkdtempSync(join(tmpdir(), 'orcad-deployment-test-')) - target = { id: 'ssh-1', label: 'Builder', host: 'builder', port: 22, username: 'dev' } - setupStore() - mocks.hasDirectAuthority.mockReturnValue(false) - mocks.connect.mockImplementation(async () => { - mocks.events.push('connect') - return {} - }) - mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ - activationRecord: emptyRecord, - serverTarget: 'linux-x64-glibc', - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target: claimed, - userDataDir: '/home/dev/.orca' - })) - mocks.recover.mockResolvedValue({ outcome: 'none' }) - mocks.deploy.mockResolvedValue({ outcome: 'installed-and-activated', fullVersion: VERSION }) - mocks.probe.mockResolvedValue(readiness()) - mocks.startTunnel.mockResolvedValue(46_768) - mocks.closeTunnel.mockResolvedValue(undefined) - mocks.ensureTunnel.mockResolvedValue(undefined) -}) - -afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) - -const deploy = () => - createManagedOrcadEnvironment(userDataPath, { name: 'Managed', sshTargetId: 'ssh-1' }) - -describe('createManagedOrcadEnvironment', () => { - it('claims the target durably before contacting it, then registers a tunneled server', async () => { - const result = await deploy() - - expect(mocks.events.slice(0, 2)).toEqual(['flush', 'connect']) - expect(result).toMatchObject({ outcome: 'created', activeVersion: VERSION }) - const [environment] = listEnvironments(userDataPath) - expect(environment?.orcadDeployment).toEqual({ - sshTargetId: 'ssh-1', - sshTargetGeneration: 9, - localPort: 46_768, - remotePort: 6_768 - }) - expect(environment?.connectionDependency).toBe('ssh-tunnel') - expect(getManagedOrcadOwnerEnvironmentId(target.owner)).toBe(environment?.id) - expect(target.orcadProvisioning).toEqual({ requestId: environment?.id, name: 'Managed' }) - expect(JSON.stringify(result)).not.toContain('device-token') - expect(mocks.probe).toHaveBeenCalledWith( - expect.objectContaining({ remoteInstallDir: expect.any(String) }), - { - buildHash: 'build-hash', - fullVersion: VERSION - } - ) - expect(mocks.closeTunnel).not.toHaveBeenCalled() - }) - - it('deploys an empty host with a zero census and an unknown one over an active slot', async () => { - await deploy() - expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({ - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, - nodePath: '' - }) - }) - - it('refuses a host with direct SSH projects before claiming or connecting', async () => { - setupStore({ repos: [{ connectionId: 'ssh-1' }] }) - await expect(deploy()).rejects.toThrow('repositories or folder workspaces') - expect(target.owner).toBeUndefined() - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('refuses a host that saved state still references, naming it, before claiming', async () => { - const leases = [{ ptyId: 'pty-1', state: 'expired' }] - const store = { - ...emptyDependentStateStore({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only ptyId and state. - getSshRemotePtyLeases: () => leases as never - }), - allocateSshTargetGeneration: () => 9, - flushPendingOrThrowAsync: async () => {}, - getFolderWorkspaces: () => [], - getRepos: () => [], - getSshTarget: () => target, - getSshTargets: () => [target], - updateSshTarget: (_id: string, updates: Partial) => - (target = { ...target, ...updates }) - } - mocks.state.store = { - getTarget: () => target, - getOrcadRuntimeClaims: () => new SshTargetOrcadClaims(store) - } - await expect(deploy()).rejects.toThrow('terminal-lease ×1 (pty-1 (expired))') - expect(target.owner).toBeUndefined() - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('refuses a connected direct SSH session and a taken server name', async () => { - mocks.hasDirectAuthority.mockReturnValueOnce(true) - await expect(deploy()).rejects.toThrow('Disconnect this SSH host') - await deploy() - target = { ...target, id: 'ssh-2', owner: undefined } - await expect( - createManagedOrcadEnvironment(userDataPath, { name: 'Managed', sshTargetId: 'ssh-2' }) - ).rejects.toThrow('already exists') - }) - - it('does not contact the host when the claim cannot be made durable', async () => { - const claims = new SshTargetOrcadClaims({ - allocateSshTargetGeneration: () => 9, - flushPendingOrThrowAsync: async () => { - throw new Error('disk full') - }, - getFolderWorkspaces: () => [], - getRepos: () => [], - ...emptyDependentStateStore(), - getSshTarget: () => target, - getSshTargets: () => [target], - updateSshTarget: (_id, updates) => (target = { ...target, ...updates }) - }) - mocks.state.store = { getTarget: () => target, getOrcadRuntimeClaims: () => claims } - await expect(deploy()).rejects.toThrow('disk full') - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('returns a deferral, keeps the claim and closes nothing it did not open', async () => { - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-not-activated', - fullVersion: VERSION, - code: 'orcad_update_terminal_census_unavailable', - reason: 'unknown census' - }) - await expect(deploy()).resolves.toMatchObject({ outcome: 'deferred', forceable: false }) - expect(listEnvironments(userDataPath)).toEqual([]) - expect(getManagedOrcadOwnerEnvironmentId(target.owner)).not.toBeNull() - expect(mocks.startTunnel).not.toHaveBeenCalled() - }) - - it('resumes an interrupted deploy under the environment id its claim recorded', async () => { - mocks.probe.mockRejectedValueOnce(new Error('readiness unverifiable')) - await expect(deploy()).rejects.toThrow('readiness unverifiable') - const claimedId = getManagedOrcadOwnerEnvironmentId(target.owner) - expect(claimedId).not.toBeNull() - mocks.deploy.mockResolvedValueOnce({ outcome: 'already-active', fullVersion: VERSION }) - const result = await deploy() - expect(result).toMatchObject({ outcome: 'already-current' }) - expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual([claimedId]) - }) - - it('closes the tunnel it opened when registration fails', async () => { - mocks.probe.mockResolvedValueOnce({ - ...readiness(), - pairing: { available: false, guidance: 'off' } - }) - mocks.startTunnel.mockResolvedValueOnce(46_768) - await expect(deploy()).rejects.toThrow('did not publish a pairing offer') - expect(mocks.closeTunnel).toHaveBeenCalledOnce() - expect(JSON.stringify(mocks.closeTunnel.mock.calls)).not.toContain('device-token') - }) - - it('finishes an already registered server by ensuring its tunnel, without redeploying', async () => { - await deploy() - mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ - activationRecord: { ...emptyRecord, active: VERSION }, - serverTarget: 'linux-x64-glibc', - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target: claimed, - userDataDir: '/home/dev/.orca' - })) - await expect(deploy()).resolves.toMatchObject({ outcome: 'already-current' }) - expect(mocks.deploy).toHaveBeenCalledOnce() - expect(mocks.ensureTunnel).toHaveBeenCalledOnce() - }) - - it('stops at an interrupted activation the host cannot reconcile yet', async () => { - mocks.recover.mockResolvedValueOnce({ - outcome: 'pending', - code: 'fresh', - reason: 'still fresh' - }) - await expect(deploy()).rejects.toThrow('still fresh') - expect(mocks.deploy).not.toHaveBeenCalled() - }) -}) - -describe('getManagedOrcadRuntimeStatus', () => { - it('reports the activation record and an interrupted transaction without repairing it', async () => { - await deploy() - const [environment] = listEnvironments(userDataPath) - mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ - activationRecord: { ...emptyRecord, active: VERSION, activatedAt: 'now' }, - connection: {}, - host: getRemoteHostPlatform('linux-x64'), - remoteHome: '/home/dev', - target: claimed - })) - mocks.readTransaction.mockResolvedValueOnce({ - operation: 'activate', - phase: 'candidate-ready', - candidateVersion: '0.2.0+def', - startedAt: 'then' - }) - await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).resolves.toEqual({ - environmentId: environment?.id, - sshTargetId: 'ssh-1', - activeVersion: VERSION, - previousVersion: null, - activatedAt: 'now', - rollbackAvailable: false, - recovery: { - operation: 'activate', - phase: 'candidate-ready', - version: '0.2.0+def', - startedAt: 'then' - }, - terminals: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 39 }, - deferredUpdate: null - }) - expect(mocks.recover).toHaveBeenCalledTimes(1) - }) - - it('reports an interrupted decommission without finishing it', async () => { - await deploy() - mocks.readTransaction.mockResolvedValueOnce({ - operation: 'decommission', - phase: 'stop-dispatched', - activeVersion: VERSION, - startedAt: 'then' - }) - await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).resolves.toMatchObject({ - recovery: { operation: 'decommission', phase: 'stop-dispatched', version: VERSION } - }) - }) - - it('refuses a server whose SSH registration was re-created', async () => { - await deploy() - target = { ...target, generation: 10 } - await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).rejects.toThrow( - 'no longer valid' - ) - }) -}) diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts deleted file mode 100644 index fe4ee7b79a6..00000000000 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Deploys orcad onto an empty SSH host and pairs this client with it through a loopback tunnel. - * The target claim is the resume point: an interrupted deploy leaves the target owned by the - * environment id it was creating, and the next deploy of that target finishes the same one. - */ -import { randomUUID } from 'node:crypto' -import { assertRuntimeEnvironmentNotReconciling } from '../../shared/runtime-environment-reconciliation-record' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import { redactRuntimeEnvironment } from '../../shared/runtime-environments' -import { - ORCAD_MANAGED_REMOTE_PORT, - type OrcadManagedDeployResult -} from '../../shared/orcad-managed-runtime' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import { materializeOrcadArtifact } from './orcad-artifact-materializer' -import { - closeOrcadManagedTunnel, - ensureOrcadManagedTunnel, - startOrcadManagedTunnel -} from './orcad-managed-tunnel' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { readOrcadActivationRecord } from './orcad-activation-record-store' -import { resolveOrcadRemoteContext } from './orcad-remote-context' -import { deployOrcad } from './orcad-remote-deploy' -import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' -import { - isForceableOrcadDeferral, - managedOrcadSlot, - probeManagedOrcadReadiness, - requireManagedOrcadInfrastructure -} from './orcad-managed-runtime-context' - -export async function createManagedOrcadEnvironment( - userDataPath: string, - args: { name: string; sshTargetId: string; force?: boolean; signal?: AbortSignal } -): Promise { - return runTargetLifecycle(args.sshTargetId, async () => { - const { connectionManager, targetStore, claims } = requireManagedOrcadInfrastructure() - const environmentId = - getManagedOrcadOwnerEnvironmentId(targetStore.getTarget(args.sshTargetId)?.owner) ?? - randomUUID() - const environments = listEnvironments(userDataPath) - const registered = environments.find((entry) => entry.id === environmentId) - if (registered) { - assertRuntimeEnvironmentNotReconciling(registered) - if (registered.orcadDeployment?.sshTargetId !== args.sshTargetId) { - throw new Error('The SSH target is owned by a server that is not deployed on it.') - } - } - if (environments.some((entry) => entry.id !== environmentId && entry.name === args.name)) { - throw new Error(`A server named "${args.name}" already exists.`) - } - if (hasRegisteredDirectSshAuthority(args.sshTargetId)) { - throw new Error('Disconnect this SSH host before converting it to a managed Orca server.') - } - const claimed = claims.claim(args.sshTargetId, environmentId, args.name) - await claims.flush(args.signal) - const targetGeneration = claimed.generation - if (targetGeneration === undefined) { - throw new Error('The managed Orca SSH registration has no durable generation.') - } - if (registered && registered.orcadDeployment?.sshTargetGeneration !== targetGeneration) { - throw new Error('The saved managed Orca server has a stale SSH target generation.') - } - let environmentRegistered = false - try { - const connection = await connectionManager.connect(claimed) - let context = await resolveOrcadRemoteContext(claimed, connection, args.signal) - const slot = managedOrcadSlot(context, ORCAD_MANAGED_REMOTE_PORT, args.signal) - const recovery = await recoverInterruptedOrcadActivation(slot) - if (recovery.outcome === 'pending' || recovery.outcome === 'refused') { - throw new Error(recovery.reason) - } - if (recovery.outcome === 'recovered') { - context = { ...context, activationRecord: await readOrcadActivationRecord(slot) } - } - if (registered) { - environmentRegistered = true - await ensureOrcadManagedTunnel(userDataPath, registered.id) - const activeVersion = context.activationRecord.active - if (!activeVersion) { - throw new Error('The managed Orca server has no active runtime version.') - } - return { - outcome: 'already-current', - environment: redactRuntimeEnvironment(registered), - activeVersion - } - } - const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { - signal: args.signal - }) - const deployResult = await deployOrcad({ - ...slot, - conn: connection, - localOrcadDir, - target: context.serverTarget, - // Why unknown when a version is active: only the daemon can count its sessions, and - // a deploy that guessed zero would restart over live terminals. - census: context.activationRecord.active - ? { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null } - : { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, - force: args.force - }) - if (deployResult.outcome === 'installed-not-activated') { - return { - outcome: 'deferred', - candidateVersion: deployResult.fullVersion, - code: deployResult.code, - reason: deployResult.reason, - forceable: isForceableOrcadDeferral(deployResult.code) - } - } - const readiness = await probeManagedOrcadReadiness( - context, - localOrcadDir, - deployResult.fullVersion, - args.signal - ) - const localPort = await startOrcadManagedTunnel( - environmentId, - claimed, - connection, - ORCAD_MANAGED_REMOTE_PORT - ) - const environment = addManagedOrcadEnvironment(userDataPath, { - id: environmentId, - name: args.name, - pairingCode: tunneledOrcadPairingCode(readiness, localPort), - orcadDeployment: { - sshTargetId: claimed.id, - sshTargetGeneration: targetGeneration, - localPort, - remotePort: ORCAD_MANAGED_REMOTE_PORT - } - }) - environmentRegistered = true - return { - outcome: deployResult.outcome === 'already-active' ? 'already-current' : 'created', - environment: redactRuntimeEnvironment(environment), - activeVersion: deployResult.fullVersion - } - } finally { - if (!environmentRegistered) { - await closeOrcadManagedTunnel(environmentId).catch(() => undefined) - } - } - }) -} diff --git a/src/main/ssh/orcad-runtime-lifecycle.ts b/src/main/ssh/orcad-runtime-lifecycle.ts deleted file mode 100644 index 0562ccce7c7..00000000000 --- a/src/main/ssh/orcad-runtime-lifecycle.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createManagedOrcadEnvironment } from './orcad-runtime-deployment' -export { getManagedOrcadRuntimeStatus } from './orcad-runtime-status' -export { - recoverManagedOrcadEnvironment, - rollbackManagedOrcadEnvironment, - updateManagedOrcadEnvironment -} from './orcad-runtime-maintenance' -export { cancelManagedOrcadStop, stopManagedOrcadEnvironment } from './orcad-runtime-decommission' diff --git a/src/main/ssh/orcad-runtime-maintenance.test.ts b/src/main/ssh/orcad-runtime-maintenance.test.ts deleted file mode 100644 index c150dc7b59d..00000000000 --- a/src/main/ssh/orcad-runtime-maintenance.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { rmSync } from 'node:fs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { - createManagedLifecycleHarness, - MANAGED_PREVIOUS_VERSION, - MANAGED_VERSION, - managedReadiness -} from './orcad-managed-lifecycle-test-fixture' - -const mocks = vi.hoisted(() => { - const state: { store: unknown } = { store: null } - return { - state, - resolveContext: vi.fn(), - census: vi.fn(), - deploy: vi.fn(), - rollback: vi.fn(), - recover: vi.fn(), - probe: vi.fn(), - buildHash: vi.fn(), - ensureTunnel: vi.fn() - } -}) - -vi.mock('./ssh-target-registry', () => ({ - getSshConnectionManager: () => ({ connect: async () => ({}) }), - getSshTargetRegistryStore: () => mocks.state.store -})) -vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) -vi.mock('./orcad-terminal-census-client', () => ({ collectManagedTerminalCensus: mocks.census })) -vi.mock('./orcad-remote-deploy', () => ({ deployOrcad: mocks.deploy })) -vi.mock('./orcad-remote-rollback', () => ({ rollbackOrcad: mocks.rollback })) -vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) -vi.mock('./orcad-active-readiness', () => ({ probeActiveOrcadReadiness: mocks.probe })) -vi.mock('./orcad-remote-build-hash', () => ({ readRemoteOrcadBuildHash: mocks.buildHash })) -vi.mock('./orcad-artifact-materializer', () => ({ - materializeOrcadArtifact: async () => '/local/orcad' -})) -vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'local-hash' })) -vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) -vi.mock('./orcad-activation-transaction-store', () => ({ - readOrcadActivationTransaction: async () => null -})) - -const { - getManagedOrcadRuntimeStatus, - recoverManagedOrcadEnvironment, - rollbackManagedOrcadEnvironment, - updateManagedOrcadEnvironment -} = await import('./orcad-runtime-lifecycle') - -const idle = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 7 } -let harness: ReturnType - -beforeEach(() => { - vi.resetAllMocks() - harness = createManagedLifecycleHarness() - mocks.state.store = harness.targetStore - mocks.resolveContext.mockImplementation(async () => harness.context()) - mocks.census.mockResolvedValue(idle) - mocks.probe.mockResolvedValue(managedReadiness()) - mocks.buildHash.mockResolvedValue('previous-hash') - mocks.recover.mockResolvedValue({ outcome: 'none' }) -}) - -afterEach(() => rmSync(harness.userDataPath, { recursive: true, force: true })) - -describe('updateManagedOrcadEnvironment', () => { - it('defers over live or unverifiable terminals and reports the deferral in status', async () => { - mocks.census.mockResolvedValue({ ...idle, liveSessions: null }) - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-not-activated', - fullVersion: '0.3.0+new', - code: 'orcad_update_terminal_census_unavailable', - reason: 'The terminal daemon did not answer a session count.' - }) - const result = await updateManagedOrcadEnvironment(harness.userDataPath, { - selector: 'Managed' - }) - expect(result).toMatchObject({ outcome: 'deferred', forceable: false }) - expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({ - census: { liveSessions: null }, - port: 6_768, - nodePath: '' - }) - const status = await getManagedOrcadRuntimeStatus(harness.userDataPath, 'Managed') - expect(status.deferredUpdate).toMatchObject({ - candidateVersion: '0.3.0+new', - code: 'orcad_update_terminal_census_unavailable' - }) - expect(status.terminals.liveSessions).toBeNull() - }) - - it('clears the deferral once an update goes through and keeps an unchanged pairing as is', async () => { - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-not-activated', - fullVersion: '0.3.0+new', - code: 'orcad_update_terminals_running', - reason: 'busy' - }) - await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - mocks.deploy.mockResolvedValueOnce({ - outcome: 'installed-and-activated', - fullVersion: '0.3.0+new' - }) - const result = await updateManagedOrcadEnvironment(harness.userDataPath, { - selector: 'Managed', - force: true - }) - expect(result).toMatchObject({ outcome: 'updated', activeVersion: '0.3.0+new' }) - expect(mocks.probe).toHaveBeenCalledWith(expect.anything(), { - buildHash: 'local-hash', - fullVersion: '0.3.0+new' - }) - const [environment] = listEnvironments(harness.userDataPath) - expect(environment?.pairingRevision).toBe(harness.environment.pairingRevision) - expect(environment?.orcadDeployment).toEqual(harness.environment.orcadDeployment) - const status = await getManagedOrcadRuntimeStatus(harness.userDataPath, 'Managed') - expect(status.deferredUpdate).toBeNull() - }) - - it('re-pairs through the same tunnel, keeping the deployment link, when the offer changed', async () => { - mocks.deploy.mockResolvedValueOnce({ outcome: 'installed-and-activated', fullVersion: 'v3' }) - mocks.probe.mockResolvedValueOnce(managedReadiness('rotated-token')) - await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - const [environment] = listEnvironments(harness.userDataPath) - expect(environment?.endpoints[0]?.deviceToken).toBe('rotated-token') - expect(environment?.orcadDeployment).toEqual(harness.environment.orcadDeployment) - }) -}) - -describe('rollbackManagedOrcadEnvironment', () => { - it.each([ - [{ ...idle, liveSessions: 2 }, 'orcad_rollback_terminals_running'], - [{ ...idle, liveSessions: null }, 'orcad_rollback_census_unavailable'] - ])('refuses while terminals run or cannot be counted: %j', async (census, code) => { - mocks.census.mockResolvedValue(census) - await expect( - rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'refused', code }) - expect(mocks.rollback).not.toHaveBeenCalled() - }) - - it('refuses with no previous version without contacting the slot', async () => { - mocks.resolveContext.mockImplementation(async () => harness.context({ previous: null })) - await expect( - rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'refused', code: 'orcad_rollback_no_target' }) - expect(mocks.census).not.toHaveBeenCalled() - }) - - it('rolls back against the installed bytes of the previous slot', async () => { - mocks.rollback.mockResolvedValueOnce({ - outcome: 'rolled-back', - target: MANAGED_PREVIOUS_VERSION, - discarded: [MANAGED_VERSION], - verdict: { decision: 'activate', coverage: 'pty-spawn', warnings: [] } - }) - await expect( - rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'rolled-back', activeVersion: MANAGED_PREVIOUS_VERSION }) - expect(mocks.rollback.mock.calls[0]?.[0]).toMatchObject({ targetBuildHash: 'previous-hash' }) - expect(mocks.probe).toHaveBeenCalledWith(expect.anything(), { - buildHash: 'previous-hash', - fullVersion: MANAGED_PREVIOUS_VERSION - }) - }) -}) - -describe('recoverManagedOrcadEnvironment', () => { - it('passes a refusal through and leaves the server linked', async () => { - mocks.recover.mockResolvedValueOnce({ - outcome: 'refused', - verdict: 'unverifiable', - code: 'orcad_recovery_unverifiable', - reason: 'host fenced' - }) - await expect( - recoverManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'refused', verdict: 'unverifiable' }) - expect(listEnvironments(harness.userDataPath)).toHaveLength(1) - }) - - it('re-ensures the tunnel once a serving slot is restored', async () => { - mocks.recover.mockResolvedValueOnce({ - outcome: 'recovered', - resolution: 'restored-incumbent', - activeVersion: MANAGED_VERSION, - readiness: managedReadiness() - }) - await expect( - recoverManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'recovered', activeVersion: MANAGED_VERSION }) - expect(mocks.ensureTunnel).toHaveBeenCalledWith(harness.userDataPath, 'environment-1') - }) -}) diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts deleted file mode 100644 index 712d0d67ae0..00000000000 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Updating, rolling back and recovering a managed orcad, on T6-2's deploy, rollback and recovery. - * Every step reads the terminal census through the server's tunnel first; an unanswered census - * is unverifiable, never zero, so an update over live or uncounted terminals defers (D7). - */ -import { refreshManagedOrcadPairing } from '../../shared/runtime-environment-managed-orcad-store' -import { assertRuntimeEnvironmentNotReconciling } from '../../shared/runtime-environment-reconciliation-record' -import { - redactRuntimeEnvironment, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import type { - OrcadManagedDeployResult, - OrcadManagedRecoveryResult, - OrcadManagedRollbackResult -} from '../../shared/orcad-managed-runtime' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import type { ServeReadiness } from '../server/serve-readiness' -import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' -import { probeActiveOrcadReadiness } from './orcad-active-readiness' -import { materializeOrcadArtifact } from './orcad-artifact-materializer' -import { CURRENT_ORCAD_DAEMON_PROTOCOL } from './orcad-daemon-protocol-crossing' -import { ensureOrcadManagedTunnel } from './orcad-managed-tunnel' -import { - clearManagedOrcadUpdateDeferral, - recordManagedOrcadUpdateDeferral -} from './orcad-managed-update-deferrals' -import { - isForceableOrcadDeferral, - managedOrcadInstallDir, - managedOrcadSlot, - probeManagedOrcadReadiness, - requireManagedOrcadEnvironment, - resolveLinkedOrcadContext -} from './orcad-managed-runtime-context' -import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' -import { deployOrcad } from './orcad-remote-deploy' -import { rollbackOrcad } from './orcad-remote-rollback' -import { collectManagedTerminalCensus } from './orcad-terminal-census-client' -import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' - -type LifecycleArgs = { selector: string; signal?: AbortSignal } - -export function withManagedOrcadLifecycle( - userDataPath: string, - selector: string, - run: (managed: ReturnType) => Promise -): Promise { - const { environment, deployment } = requireManagedOrcadEnvironment(userDataPath, selector) - return runTargetLifecycle(deployment.sshTargetId, async () => { - // Re-read under the queue: a reconciliation or stop may have won the race for it. - const current = requireManagedOrcadEnvironment(userDataPath, environment.id) - assertRuntimeEnvironmentNotReconciling(current.environment) - return run(current) - }) -} - -function refreshPairing( - userDataPath: string, - environment: KnownRuntimeEnvironment, - readiness: ServeReadiness, - localPort: number -): KnownRuntimeEnvironment { - return refreshManagedOrcadPairing( - userDataPath, - environment.id, - tunneledOrcadPairingCode(readiness, localPort) - ) -} - -export function updateManagedOrcadEnvironment( - userDataPath: string, - args: LifecycleArgs & { force?: boolean } -): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const census = await collectManagedTerminalCensus( - userDataPath, - environment, - context.activationRecord - ) - const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { - signal: args.signal - }) - const result = await deployOrcad({ - ...managedOrcadSlot(context, deployment.remotePort, args.signal), - localOrcadDir, - target: context.serverTarget, - census, - force: args.force - }) - if (result.outcome === 'installed-not-activated') { - const deferral = { - outcome: 'deferred' as const, - candidateVersion: result.fullVersion, - code: result.code, - reason: result.reason, - forceable: isForceableOrcadDeferral(result.code) - } - recordManagedOrcadUpdateDeferral(environment.id, deferral) - return deferral - } - clearManagedOrcadUpdateDeferral(environment.id) - const readiness = await probeManagedOrcadReadiness( - context, - localOrcadDir, - result.fullVersion, - args.signal - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: result.outcome === 'already-active' ? 'already-current' : 'updated', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.fullVersion - } - } - ) -} - -export function rollbackManagedOrcadEnvironment( - userDataPath: string, - args: LifecycleArgs -): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const record = context.activationRecord - const target = record.previous - if (!target) { - return { - outcome: 'refused', - code: 'orcad_rollback_no_target', - reason: 'This server has no previous version to roll back to.' - } - } - const census = await collectManagedTerminalCensus(userDataPath, environment, record) - // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show - // that build would reach terminals that are still running. - if (census.liveSessions !== 0) { - return { - outcome: 'refused', - code: - census.liveSessions === null - ? 'orcad_rollback_census_unavailable' - : 'orcad_rollback_terminals_running', - reason: - census.liveSessions === null - ? 'The server did not answer how many terminals it runs. Retry when it answers.' - : 'Close the terminals running on this server before rolling it back.' - } - } - const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) - const targetDir = managedOrcadInstallDir(context, target) - const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir) - const result = await rollbackOrcad({ - ...slot, - record, - census, - targetBuildHash, - targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL - }) - if (result.outcome !== 'rolled-back') { - return result - } - const readiness = await probeActiveOrcadReadiness( - { ...slot, remoteInstallDir: targetDir }, - { buildHash: targetBuildHash, fullVersion: result.target } - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: 'rolled-back', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.target, - discarded: result.discarded - } - } - ) -} - -/** Finishes or undoes an interrupted activation, rollback or decommission on the host. */ -export function recoverManagedOrcadEnvironment( - userDataPath: string, - args: LifecycleArgs -): Promise { - return withManagedOrcadLifecycle( - userDataPath, - args.selector, - async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const result = await recoverInterruptedOrcadActivation( - managedOrcadSlot(context, deployment.remotePort, args.signal) - ) - if (result.outcome !== 'recovered') { - return result - } - const updated = result.readiness - ? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort) - : environment - if (result.activeVersion) { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } - return { - outcome: 'recovered', - resolution: result.resolution, - activeVersion: result.activeVersion, - environment: redactRuntimeEnvironment(updated) - } - } - ) -} diff --git a/src/main/ssh/orcad-runtime-status.ts b/src/main/ssh/orcad-runtime-status.ts deleted file mode 100644 index 101eb6d81e5..00000000000 --- a/src/main/ssh/orcad-runtime-status.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { OrcadManagedRuntimeStatus } from '../../shared/orcad-managed-runtime' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import type { OrcadActivationTransaction } from './orcad-activation-transaction' -import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' -import { - requireManagedOrcadEnvironment, - resolveLinkedOrcadContext -} from './orcad-managed-runtime-context' -import { readManagedOrcadUpdateDeferral } from './orcad-managed-update-deferrals' -import { collectManagedTerminalCensus } from './orcad-terminal-census-client' - -/** Read-only: what the host's activation record and journal say, without repairing either. */ -export async function getManagedOrcadRuntimeStatus( - userDataPath: string, - selector: string, - signal?: AbortSignal -): Promise { - const { environment, deployment } = requireManagedOrcadEnvironment(userDataPath, selector) - return runTargetLifecycle(deployment.sshTargetId, async () => { - const context = await resolveLinkedOrcadContext(environment, deployment, signal) - const record = context.activationRecord - const transaction = await readOrcadActivationTransaction({ - conn: context.connection, - host: context.host, - remoteHome: context.remoteHome, - signal - }) - return { - environmentId: environment.id, - sshTargetId: context.target.id, - activeVersion: record.active, - previousVersion: record.previous, - activatedAt: record.activatedAt, - rollbackAvailable: Boolean(record.previous && record.snapshot), - recovery: transaction ? managedRecoveryStatus(transaction) : null, - terminals: await collectManagedTerminalCensus(userDataPath, environment, record), - deferredUpdate: readManagedOrcadUpdateDeferral(environment.id) - } - }) -} - -function managedRecoveryStatus( - transaction: OrcadActivationTransaction -): NonNullable { - switch (transaction.operation) { - case 'activate': - return { - operation: transaction.operation, - phase: transaction.phase, - version: transaction.candidateVersion, - startedAt: transaction.startedAt - } - case 'rollback': - return { - operation: transaction.operation, - phase: transaction.phase, - version: transaction.targetVersion, - startedAt: transaction.startedAt - } - case 'decommission': - return { - operation: transaction.operation, - phase: transaction.phase, - version: transaction.activeVersion, - startedAt: transaction.startedAt - } - } -} diff --git a/src/main/ssh/orcad-ssh-provisioning.test.ts b/src/main/ssh/orcad-ssh-provisioning.test.ts deleted file mode 100644 index 4640f0b93d5..00000000000 --- a/src/main/ssh/orcad-ssh-provisioning.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshTarget } from '../../shared/ssh-types' -import { normalizeSshTarget } from '../persistence/leasing-ssh-ptys/ssh-normalization' - -const mocks = vi.hoisted(() => { - const targets: SshTarget[] = [] - const deployedTargets: string[] = [] - const targetStore: Record = {} - return { - targets, - deployedTargets, - update: vi.fn(), - deploy: vi.fn(), - flush: vi.fn(), - add: vi.fn(), - rotate: vi.fn(), - targetStore - } -}) - -vi.mock('./orcad-managed-runtime-context', () => ({ - requireManagedOrcadTargetStore: () => mocks.targetStore -})) -vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) -vi.mock('./ssh-provider-authority', () => ({ rotateSshProviderAuthority: mocks.rotate })) -vi.mock('../../shared/runtime-environment-store', () => ({ - listEnvironments: () => - mocks.deployedTargets.map((sshTargetId) => ({ orcadDeployment: { sshTargetId } })) -})) - -import { - createOrcadSshHost, - listPendingOrcadSshProvisioning, - resumeOrcadSshHost -} from './orcad-ssh-provisioning' - -const request = { - requestId: 'request-1', - name: 'Build host', - target: { label: 'Build host', host: 'builder', port: 22, username: 'dev' } -} -const deployed = { - outcome: 'created', - environment: { id: 'environment-1' }, - activeVersion: 'node-1' -} - -beforeEach(() => { - vi.clearAllMocks() - mocks.targets.length = 0 - mocks.deployedTargets.length = 0 - mocks.flush.mockReset().mockResolvedValue(undefined) - mocks.deploy.mockReset().mockResolvedValue(deployed) - mocks.add.mockImplementation((input) => { - const target = normalizeSshTarget({ - ...input, - id: `ssh-${mocks.targets.length + 1}`, - generation: 1 - }) - mocks.targets.push(target) - return target - }) - mocks.update.mockImplementation((id: string, updates: Partial) => { - const index = mocks.targets.findIndex((target) => target.id === id) - mocks.targets[index] = { ...mocks.targets[index]!, ...updates } - return mocks.targets[index] - }) - mocks.targetStore = { - addTarget: mocks.add, - updateTarget: mocks.update, - lastRepoReadoptions: [], - getOrcadRuntimeClaims: () => ({ listTargets: () => mocks.targets, flush: mocks.flush }) - } -}) - -describe('managed SSH host provisioning', () => { - it('persists intent before running the existing migration/deployment preflight', async () => { - mocks.deploy.mockImplementation(async () => { - expect(mocks.flush).toHaveBeenCalledOnce() - expect(mocks.targets[0]?.orcadProvisioning).toEqual({ - requestId: 'request-1', - name: 'Build host' - }) - return deployed - }) - await expect(createOrcadSshHost('/profile', request)).resolves.toMatchObject({ - requestId: 'request-1', - name: 'Build host', - sshTargetId: 'ssh-1', - result: deployed - }) - expect(mocks.deploy).toHaveBeenCalledWith('/profile', { - name: 'Build host', - sshTargetId: 'ssh-1' - }) - }) - - it('does not contact a host if durable intent publication fails', async () => { - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(createOrcadSshHost('/profile', request)).rejects.toThrow('disk full') - expect(mocks.deploy).not.toHaveBeenCalled() - await resumeOrcadSshHost('/profile', 'request-1') - expect(mocks.add).toHaveBeenCalledOnce() - }) - - it('retains the same request across preflight refusal and a reconstructed target store', async () => { - mocks.deploy.mockRejectedValueOnce(new Error('live-or-unverifiable terminal leases')) - await expect(createOrcadSshHost('/profile', request)).resolves.toMatchObject({ - result: { outcome: 'pending', reason: 'live-or-unverifiable terminal leases' } - }) - mocks.targets.splice(0, 1, JSON.parse(JSON.stringify(mocks.targets[0]))) - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([ - { requestId: 'request-1', name: 'Build host', sshTargetId: 'ssh-1' } - ]) - await expect(resumeOrcadSshHost('/profile', 'request-1')).resolves.toMatchObject({ - result: deployed - }) - expect(mocks.add).toHaveBeenCalledOnce() - expect(mocks.deploy).toHaveBeenCalledTimes(2) - }) - - it('restores a host with re-adopted projects as direct SSH instead of hiding them', async () => { - const readoptions = [{ oldTargetId: 'old', newTargetId: 'ssh-1', repoIds: ['repo-1'] }] - mocks.targetStore.lastRepoReadoptions = readoptions - const result = await createOrcadSshHost('/profile', request) - expect(result.repoReadoptions).toEqual(readoptions) - expect(result.result).toMatchObject({ outcome: 'pending' }) - expect(mocks.rotate).toHaveBeenCalledWith('old') - expect(mocks.rotate).toHaveBeenCalledWith('ssh-1') - expect(mocks.targets[0]?.orcadProvisioning).toBeUndefined() - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([]) - expect(mocks.deploy).not.toHaveBeenCalled() - }) - - it('returns activation deferral without force, deletion, or another target', async () => { - const deferred = { - outcome: 'deferred', - reason: 'unverifiable', - code: 'blocked', - candidateVersion: 'node-1' - } - mocks.deploy.mockResolvedValueOnce(deferred) - await expect(createOrcadSshHost('/profile', request)).resolves.toMatchObject({ - result: deferred - }) - expect(listPendingOrcadSshProvisioning('/profile')).toHaveLength(1) - expect(mocks.targets).toHaveLength(1) - }) - - it('serializes repeated requests and preserves normalization on exact retries', async () => { - const normalizedRequest = { - ...request, - target: { ...request.target, relayGracePeriodSeconds: 10800 } - } - await Promise.all([ - createOrcadSshHost('/profile', normalizedRequest), - createOrcadSshHost('/profile', normalizedRequest) - ]) - expect(mocks.add).toHaveBeenCalledOnce() - expect(mocks.targets).toHaveLength(1) - }) - - it('rejects request identity reuse for different host data or name', async () => { - await createOrcadSshHost('/profile', request) - await expect(createOrcadSshHost('/profile', { ...request, name: 'Other' })).rejects.toThrow( - 'already belongs' - ) - await expect( - createOrcadSshHost('/profile', { - ...request, - target: { ...request.target, identityFile: 'other' } - }) - ).rejects.toThrow('already belongs') - expect(mocks.deploy).toHaveBeenCalledOnce() - }) - - it('does not create a duplicate raw host when another request registered the endpoint', async () => { - await createOrcadSshHost('/profile', request) - await expect( - createOrcadSshHost('/profile', { ...request, requestId: 'request-2' }) - ).rejects.toThrow('already registered') - expect(mocks.add).toHaveBeenCalledOnce() - }) - - it('does not duplicate an occupied config alias or endpoint under a different label', async () => { - await createOrcadSshHost('/profile', request) - await expect( - createOrcadSshHost('/profile', { - ...request, - requestId: 'request-2', - target: { ...request.target, configHost: 'BUILDER', host: 'resolved-address' } - }) - ).rejects.toThrow('already registered') - await expect( - createOrcadSshHost('/profile', { - ...request, - requestId: 'request-3', - target: { ...request.target, label: 'Different', configHost: 'other-alias' } - }) - ).rejects.toThrow('already registered') - }) - - it.each([ - { configHost: 3 }, - { identityFile: false }, - { gssapiAuthentication: 'yes' }, - { systemSshConnectionReuse: 1 }, - { portForwards: [{ localPort: -1 }] } - ])('rejects malformed optional connection fields before durable registration: %j', (invalid) => { - expect(() => - createOrcadSshHost('/profile', { - ...request, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately malformed renderer input. - target: { ...request.target, ...invalid } as never - }) - ).toThrow() - expect(mocks.add).not.toHaveBeenCalled() - }) - - it('omits completed requests from pending discovery but retains their retry identity', async () => { - await createOrcadSshHost('/profile', request) - mocks.deployedTargets.push('ssh-1') - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([]) - await resumeOrcadSshHost('/profile', 'request-1') - expect(mocks.add).toHaveBeenCalledOnce() - expect(mocks.deploy).toHaveBeenCalledTimes(2) - }) - - it('does not provision imported config entries without explicit intent', () => { - mocks.targets.push({ ...request.target, id: 'imported', source: 'ssh-config' }) - expect(listPendingOrcadSshProvisioning('/profile')).toEqual([]) - expect(mocks.deploy).not.toHaveBeenCalled() - }) - - it('rejects invalid input and unknown retry IDs without writing a target', async () => { - expect(() => createOrcadSshHost('/profile', { ...request, requestId: '' })).toThrow( - 'request id' - ) - expect(() => - createOrcadSshHost('/profile', { ...request, target: { ...request.target, port: 0 } }) - ).toThrow('port') - await expect(resumeOrcadSshHost('/profile', 'missing')).rejects.toThrow('not found') - expect(mocks.add).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/orcad-ssh-provisioning.ts b/src/main/ssh/orcad-ssh-provisioning.ts deleted file mode 100644 index 8b904e7f272..00000000000 --- a/src/main/ssh/orcad-ssh-provisioning.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { z } from 'zod' -import type { - OrcadSshPendingProvisioning, - OrcadSshProvisioningRequest, - OrcadSshProvisioningResult -} from '../../shared/orcad-ssh-provisioning' -import type { SshRepoReadoption, SshTarget, SshTargetCreateInput } from '../../shared/ssh-types' -import { EphemeralVmRecipeSshTargetSchema } from '../../shared/ephemeral-vm-recipes' -import { listEnvironments } from '../../shared/runtime-environment-store' -import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import { normalizeSshTarget } from '../persistence/leasing-ssh-ptys/ssh-normalization' -import { requireManagedOrcadTargetStore } from './orcad-managed-runtime-context' -import { createManagedOrcadEnvironment } from './orcad-runtime-deployment' -import { rotateSshProviderAuthority } from './ssh-provider-authority' - -// Why no port forwards: a managed server is only created on an empty host. -const provisioningTargetSchema = EphemeralVmRecipeSshTargetSchema.omit({ - portForwards: true -}).extend({ - gssapiAuthentication: z.boolean().optional(), - systemSshConnectionReuse: z.boolean().optional(), - source: z.enum(['manual', 'ssh-config']).optional(), - lastRequiredPassphrase: z.boolean().optional() -}) - -/** Requests whose server is not registered yet; a completed one keeps its intent for idempotent retries. */ -export function listPendingOrcadSshProvisioning( - userDataPath: string -): OrcadSshPendingProvisioning[] { - const deployed = new Set( - listEnvironments(userDataPath).flatMap((environment) => - environment.orcadDeployment ? [environment.orcadDeployment.sshTargetId] : [] - ) - ) - return requireManagedOrcadTargetStore() - .getOrcadRuntimeClaims() - .listTargets() - .flatMap((target) => - target.orcadProvisioning && !deployed.has(target.id) - ? [{ ...target.orcadProvisioning, sshTargetId: target.id }] - : [] - ) -} - -export function createOrcadSshHost( - userDataPath: string, - request: OrcadSshProvisioningRequest -): Promise { - const requestId = requireRequestId(request?.requestId) - const name = requireText(request?.name, 'Server name') - const targetInput = parseTarget(request?.target) - return runTargetLifecycle(`orcad-provision:${userDataPath}:${requestId}`, async () => { - const targetStore = requireManagedOrcadTargetStore() - const targets = targetStore.getOrcadRuntimeClaims().listTargets() - const existing = targets.find((entry) => entry.orcadProvisioning?.requestId === requestId) - if (existing) { - if (existing.orcadProvisioning?.name !== name || !matchesRequest(existing, targetInput)) { - throw new Error('This provisioning request already belongs to another host or server name.') - } - return provision(userDataPath, existing, []) - } - if (targets.some((entry) => sameEndpoint(entry, targetInput))) { - throw new Error('That SSH host is already registered. Use its existing server or SSH entry.') - } - const target = targetStore.addTarget({ - ...targetInput, - source: 'manual', - orcadProvisioning: { requestId, name } - }) - const repoReadoptions = [...targetStore.lastRepoReadoptions] - targetStore.lastRepoReadoptions = [] - for (const targetId of new Set( - repoReadoptions.flatMap(({ oldTargetId, newTargetId }) => [oldTargetId, newTargetId]) - )) { - rotateSshProviderAuthority(targetId) - } - if (repoReadoptions.length > 0) { - // Why visible: re-adopted projects make the host non-empty; hiding it would hide them too. - targetStore.updateTarget(target.id, { orcadProvisioning: undefined }) - return { - requestId, - name, - sshTargetId: target.id, - repoReadoptions, - result: { - outcome: 'pending', - reason: - 'This host already has Orca projects, so it was restored as a direct SSH host. ' + - 'A managed server can only be created on an empty host.' - } - } - } - return provision(userDataPath, target, repoReadoptions) - }) -} - -export function resumeOrcadSshHost( - userDataPath: string, - requestIdInput: string -): Promise { - const requestId = requireRequestId(requestIdInput) - return runTargetLifecycle(`orcad-provision:${userDataPath}:${requestId}`, async () => { - const target = requireManagedOrcadTargetStore() - .getOrcadRuntimeClaims() - .listTargets() - .find((entry) => entry.orcadProvisioning?.requestId === requestId) - if (!target) { - throw new Error('The managed SSH provisioning request was not found.') - } - return provision(userDataPath, target, []) - }) -} - -async function provision( - userDataPath: string, - target: SshTarget, - repoReadoptions: SshRepoReadoption[] -): Promise { - const intent = target.orcadProvisioning - if (!intent) { - throw new Error('The managed SSH provisioning request was not found.') - } - // Why first: the intent must survive a crash before the host is contacted. - await requireManagedOrcadTargetStore().getOrcadRuntimeClaims().flush() - const base = { ...intent, sshTargetId: target.id, repoReadoptions } - try { - return { - ...base, - result: await createManagedOrcadEnvironment(userDataPath, { - name: intent.name, - sshTargetId: target.id - }) - } - } catch (error) { - return { - ...base, - result: { - outcome: 'pending', - reason: error instanceof Error ? error.message : 'Managed SSH provisioning failed.' - } - } - } -} - -function requireText(value: unknown, label: string): string { - if (typeof value !== 'string' || !value.trim() || value.length > 1_024) { - throw new Error(`${label} is required and must not exceed 1024 characters.`) - } - return value.trim() -} - -function requireRequestId(value: unknown): string { - if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) { - throw new Error('A stable managed SSH provisioning request id is required.') - } - return value -} - -function parseTarget(value: unknown): SshTargetCreateInput { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('A new unowned SSH host is required.') - } - const { - id: _id, - generation: _generation, - orcadProvisioning: _intent, - owner, - ...raw - }: Record = { ...value } - if (owner !== undefined) { - throw new Error('A new unowned SSH host is required.') - } - const target = provisioningTargetSchema.parse(raw) - return { - ...target, - label: requireText(target.label, 'SSH host label'), - host: requireText(target.host, 'SSH host'), - username: target.username.trim(), - configHost: target.configHost?.trim() || target.host.trim() - } -} - -function sameEndpoint(left: SshTarget, right: SshTargetCreateInput): boolean { - const alias = normalizeSshConfigAlias(right.configHost ?? right.host) - if ( - alias && - [left.configHost, left.label].some((value) => normalizeSshConfigAlias(value) === alias) - ) { - return true - } - return left.host === right.host && left.port === right.port && left.username === right.username -} - -function matchesRequest(target: SshTarget, input: SshTargetCreateInput): boolean { - const stored: Record = { ...target } - return Object.entries(normalizeSshTarget({ ...input, id: target.id })).every( - ([key, value]) => key === 'source' || JSON.stringify(stored[key]) === JSON.stringify(value) - ) -} diff --git a/src/main/ssh/orcad-state-snapshot-shell.test.ts b/src/main/ssh/orcad-state-snapshot-shell.test.ts deleted file mode 100644 index ff6c263a977..00000000000 --- a/src/main/ssh/orcad-state-snapshot-shell.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { runProcess } from '../../shared/child-process/run-process' -import { - captureOrcadStateSnapshotCommand, - clearOrcadStateSnapshotMembersCommand, - parseOrcadSnapshotCapture, - parseOrcadSnapshotRestore, - restoreOrcadStateSnapshotCommand -} from './orcad-state-snapshot' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -const posix = getRemoteHostPlatform('linux-x64') - -async function sh(command: string): Promise { - const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) - return result.stdout -} - -describe.skipIf(process.platform === 'win32')('snapshot commands on a real shell', () => { - let base: string - let root: string - let snapshot: string - - beforeEach(() => { - base = mkdtempSync(join(tmpdir(), 'orcad-snapshot-shell-')) - root = join(base, 'root') - snapshot = join(base, 'snapshots', 'pre-1') - mkdirSync(join(root, 'profiles'), { recursive: true }) - mkdirSync(join(root, 'daemon'), { recursive: true }) - writeFileSync(join(root, 'profiles', 'p.json'), 'old') - writeFileSync(join(root, 'daemon', 'token'), 'live-daemon') - }) - - afterEach(() => { - rmSync(base, { recursive: true, force: true }) - }) - - it('restores members, drops files the newer build added, and leaves the daemon alone', async () => { - expect( - parseOrcadSnapshotCapture(await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('captured') - writeFileSync(join(root, 'profiles', 'p.json'), 'migrated') - writeFileSync(join(root, 'profiles', 'added.json'), 'new') - writeFileSync(join(root, 'daemon', 'token'), 'rotated') - - expect( - parseOrcadSnapshotRestore(await sh(restoreOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('restored') - expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('old') - expect(existsSync(join(root, 'profiles', 'added.json'))).toBe(false) - expect(readFileSync(join(root, 'daemon', 'token'), 'utf8')).toBe('rotated') - expect(existsSync(join(root, '.orcad-state-restore-stage'))).toBe(false) - }) - - it('keeps live state when the archive cannot be extracted', async () => { - await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot)) - writeFileSync(join(snapshot, 'state.tar'), 'not a tar archive') - writeFileSync(join(root, 'profiles', 'p.json'), 'current') - - expect( - parseOrcadSnapshotRestore(await sh(restoreOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('failed') - expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('current') - }) - - it('reruns cleanly after a restore interrupted between removal and replacement', async () => { - await sh(captureOrcadStateSnapshotCommand(posix, root, snapshot)) - // Simulates a crash that left the stage behind and the live members already removed. - mkdirSync(join(root, '.orcad-state-restore-stage', 'profiles'), { recursive: true }) - rmSync(join(root, 'profiles'), { recursive: true }) - - expect( - parseOrcadSnapshotRestore(await sh(restoreOrcadStateSnapshotCommand(posix, root, snapshot))) - ).toBe('restored') - expect(readFileSync(join(root, 'profiles', 'p.json'), 'utf8')).toBe('old') - }) - - it('clears only the snapshot members for a root that started empty', async () => { - expect( - parseOrcadSnapshotRestore(await sh(clearOrcadStateSnapshotMembersCommand(posix, root))) - ).toBe('restored') - expect(existsSync(join(root, 'profiles'))).toBe(false) - expect(readFileSync(join(root, 'daemon', 'token'), 'utf8')).toBe('live-daemon') - }) -}) diff --git a/src/main/ssh/orcad-state-snapshot.test.ts b/src/main/ssh/orcad-state-snapshot.test.ts index c8740507698..f660e95e5a4 100644 --- a/src/main/ssh/orcad-state-snapshot.test.ts +++ b/src/main/ssh/orcad-state-snapshot.test.ts @@ -4,15 +4,12 @@ import { ORCAD_SNAPSHOT_EXCLUDED, ORCAD_SNAPSHOT_MEMBERS, captureOrcadStateSnapshotCommand, - clearOrcadStateSnapshotMembersCommand, compareOrcadStateSnapshotCommand, newestStateMtimeCommand, orcadSnapshotDirName, parseNewestStateMtimeSeconds, parseOrcadSnapshotCapture, - parseOrcadSnapshotPresence, parseOrcadSnapshotRestore, - probeOrcadStateSnapshotCommand, restoreOrcadStateSnapshotCommand } from './orcad-state-snapshot' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -63,11 +60,9 @@ describe('capturing the pre-activation snapshot', () => { }) describe('restoring the snapshot', () => { - it('proves the archive extracts before clearing the live members', () => { + it('clears the members before extracting, so files the new build added do not survive', () => { const command = restoreOrcadStateSnapshotCommand(posix, ROOT, SNAP) - const extract = command.indexOf(`tar -C '${ROOT}/.orcad-state-restore-stage'`) - expect(extract).toBeGreaterThan(-1) - expect(extract).toBeLessThan(command.indexOf(`rm -rf '${ROOT}'/'profiles'`)) + expect(command.indexOf('rm -rf')).toBeLessThan(command.indexOf('tar -C')) }) it('reports a missing archive instead of extracting nothing and claiming success', () => { @@ -76,15 +71,6 @@ describe('restoring the snapshot', () => { expect(parseOrcadSnapshotRestore('RESTORED')).toBe('restored') expect(parseOrcadSnapshotRestore('FAILED')).toBe('failed') }) - - it.each([ - ['PRESENT', 'present'], - ['ABSENT', 'absent'], - ['', 'unverifiable'], - ['bash: tar: command not found', 'unverifiable'] - ])('reads snapshot presence %j as %s, never treating silence as absence', (out, expected) => { - expect(parseOrcadSnapshotPresence(out)).toBe(expected) - }) }) describe('detecting writes since activation', () => { @@ -108,8 +94,6 @@ describe('Windows hosts', () => { it.each([ ['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)], - ['clear', () => clearOrcadStateSnapshotMembersCommand(windows, ROOT)], - ['presence', () => probeOrcadStateSnapshotCommand(windows, SNAP)], ['compare', () => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP)], ['mtime', () => newestStateMtimeCommand(windows, ROOT)] ])('refuses %s rather than emitting a POSIX command', (_label, build) => { diff --git a/src/main/ssh/orcad-state-snapshot.ts b/src/main/ssh/orcad-state-snapshot.ts index a5ce56d7339..7d062275442 100644 --- a/src/main/ssh/orcad-state-snapshot.ts +++ b/src/main/ssh/orcad-state-snapshot.ts @@ -47,8 +47,6 @@ function assertPlainMemberName(member: string): string { return member } -const RESTORE_STAGE_DIRNAME = '.orcad-state-restore-stage' - function noSymlinkedStateCommand(path: string): string { return `links=$(find ${path} -type l -print) && [ -z "$links" ]` } @@ -59,11 +57,6 @@ export function orcadSnapshotDirName(fullVersion: string, takenAtMs: number): st return `pre-${fullVersion}-${takenAtMs}` } -/** The newer build's state, kept so an interrupted rollback can put it back. */ -export function orcadRollbackRescueDirName(fullVersion: string, takenAtMs: number): string { - return `rollback-rescue-${fullVersion}-${takenAtMs}` -} - /** * Capture the snapshot, or report why there is nothing to capture. * @@ -125,25 +118,12 @@ export function probeOrcadStateSnapshotCommand( return `test -f ${archive} && echo PRESENT || echo ABSENT` } -export type OrcadSnapshotPresence = 'present' | 'absent' | 'unverifiable' - -/** A lost probe is `unverifiable`, never `absent`. */ -export function parseOrcadSnapshotPresence(output: string): OrcadSnapshotPresence { - const value = output.trim().split('\n').pop()?.trim() - if (value === 'PRESENT') { - return 'present' - } - return value === 'ABSENT' ? 'absent' : 'unverifiable' -} - /** * Restore the snapshot over the data root. * - * Three things make this safe to run: the archive is extracted into a stage first, so an - * unreadable archive fails before live state is touched; the members are then removed before - * the staged copies move in (so a file the new version added is gone rather than - * half-shadowed); and neither step can reach `/daemon`, because the member list never - * names it. + * Two things make this safe to run: the members are removed before extraction (so a file the + * new version added is gone rather than half-shadowed), and neither the removal nor the + * extraction can reach `/daemon`, because the member list never names it. * * The caller must have stopped orcad first. This does not check — it cannot, from a shell — * so `orcad-remote-deploy.ts` owns that ordering. @@ -156,43 +136,17 @@ export function restoreOrcadStateSnapshotCommand( assertPosixHost(host) const root = shellEscape(userDataDir) const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar')) - const stage = shellEscape(joinRemotePath(host, userDataDir, RESTORE_STAGE_DIRNAME)) const removals = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `rm -rf ${root}/${shellEscape(member)}` - ).join(' && ') - const replacements = ORCAD_SNAPSHOT_MEMBERS.map((member) => { - const name = shellEscape(member) - return `if [ -e ${stage}/${name} ]; then mv ${stage}/${name} ${root}/${name}; fi` - }).join(' && ') - const stagedMemberChecks = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `[ -e ${stage}/${shellEscape(member)} ]` - ).join(' || ') + (member) => `rm -rf ${root}/${shellEscape(member)};` + ).join(' ') return [ `test -f ${archive} || { echo MISSING; exit 0; };`, - 'umask 077;', `test -d ${root} || mkdir -p ${root};`, - // Re-extracting from the intact archive makes an interrupted restore safe to rerun. - `rm -rf ${stage}; mkdir -p ${stage} || { echo FAILED; exit 0; };`, - // Extraction proves every archived byte is readable before live state is removed. - `tar -C ${stage} -xf ${archive} 2>/dev/null || { rm -rf ${stage}; echo FAILED; exit 0; };`, - `${stagedMemberChecks} || { rm -rf ${stage}; echo FAILED; exit 0; };`, - `if ${removals} && ${replacements}; then rm -rf ${stage}; echo RESTORED; else echo FAILED; fi` + removals, + `tar -C ${root} -xf ${archive} && echo RESTORED || echo FAILED` ].join(' ') } -/** Restore an originally empty state root after a candidate populated it. */ -export function clearOrcadStateSnapshotMembersCommand( - host: RemoteHostPlatform, - userDataDir: string -): string { - assertPosixHost(host) - const root = shellEscape(userDataDir) - const removals = ORCAD_SNAPSHOT_MEMBERS.map( - (member) => `rm -rf ${root}/${shellEscape(member)}` - ).join(' && ') - return `test -d ${root} || mkdir -p ${root}; if ${removals}; then echo RESTORED; else echo FAILED; fi` -} - export type OrcadSnapshotRestore = 'restored' | 'missing' | 'failed' export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore { diff --git a/src/main/ssh/orcad-terminal-census-client.test.ts b/src/main/ssh/orcad-terminal-census-client.test.ts deleted file mode 100644 index feb3bb15cd9..00000000000 --- a/src/main/ssh/orcad-terminal-census-client.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import { - createEnvironmentFromPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { emptyOrcadActivationRecord } from './orcad-activation-record' - -const mocks = vi.hoisted(() => ({ send: vi.fn(), ensure: vi.fn() })) -vi.mock('../../shared/remote-runtime-client', () => ({ - sendRemoteRuntimeRequestWithStatusPreflight: mocks.send -})) -vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensure })) - -const { collectManagedTerminalCensus, collectRemoteOrcadTerminalCensus } = - await import('./orcad-terminal-census-client') - -const environment: KnownRuntimeEnvironment = createEnvironmentFromPairingOffer({ - id: 'environment-1', - name: 'Managed', - now: 1, - offer: { v: 2, endpoint: 'ws://127.0.0.1:46768', deviceToken: 't', publicKeyB64: 'k' }, - connectionDependency: 'ssh-tunnel' -}) -const active = { - ...emptyOrcadActivationRecord(), - active: '1.0.0', - activatedAt: '2026-01-01T00:00:00.000Z' -} -const unverifiable = { - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null -} -const census = { liveSessions: 2, startedSinceActivation: 1, daemonProtocolVersion: 7 } - -function answerWith(capabilities: string[], response: unknown) { - mocks.send.mockImplementation(async (_pairing, _method, _params, _timeout, validate) => { - validate({ ok: true, result: { capabilities } }) - return response - }) -} - -describe('managed orcad terminal census client', () => { - beforeEach(() => { - vi.resetAllMocks() - mocks.ensure.mockResolvedValue(undefined) - }) - - it('reads an idle census without contacting a host that has nothing active', async () => { - await expect( - collectRemoteOrcadTerminalCensus(environment, { ...active, active: null }) - ).resolves.toEqual({ liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }) - expect(mocks.send).not.toHaveBeenCalled() - }) - - it('asks an advertising host with the activation time', async () => { - answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { ok: true, result: census }) - await expect(collectRemoteOrcadTerminalCensus(environment, active)).resolves.toEqual(census) - expect(mocks.send.mock.calls[0]?.slice(1, 3)).toEqual([ - 'orcad.terminalCensus', - { activatedAt: Date.parse(active.activatedAt) } - ]) - }) - - it.each([ - ['an older host without the capability', () => answerWith([], { ok: true, result: census })], - [ - 'method not found', - () => - answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { - ok: false, - error: { code: 'method_not_found', message: 'no' } - }) - ], - [ - 'a malformed answer', - () => answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { ok: true, result: {} }) - ], - ['loss of contact', () => mocks.send.mockRejectedValue(new Error('socket closed'))] - ])('reads %s as unverifiable, never as zero', async (_label, arrange) => { - arrange() - await expect(collectRemoteOrcadTerminalCensus(environment, active)).resolves.toEqual( - unverifiable - ) - }) - - it('reads an unparseable activation time as unverifiable', async () => { - await expect( - collectRemoteOrcadTerminalCensus(environment, { ...active, activatedAt: 'later' }) - ).resolves.toEqual(unverifiable) - expect(mocks.send).not.toHaveBeenCalled() - }) - - it('reads a tunnel that cannot open as unverifiable', async () => { - mocks.ensure.mockRejectedValue(new Error('SSH unavailable')) - await expect(collectManagedTerminalCensus('/profile', environment, active)).resolves.toEqual( - unverifiable - ) - }) -}) diff --git a/src/main/ssh/orcad-terminal-census-client.ts b/src/main/ssh/orcad-terminal-census-client.ts deleted file mode 100644 index d039fa8e120..00000000000 --- a/src/main/ssh/orcad-terminal-census-client.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Asks a managed orcad, through its tunnel, how many terminals its daemon runs. Every failure, - * including an older host without the method, reads as an unverifiable census, never as zero. - */ -import { ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' -import { - getPreferredPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' -import { - ORCAD_TERMINAL_CENSUS_METHOD, - OrcadTerminalCensusSchema, - type OrcadTerminalCensus -} from '../../shared/orcad-terminal-census' -import { sendRemoteRuntimeRequestWithStatusPreflight } from '../../shared/remote-runtime-client' -import type { OrcadActivationRecord } from './orcad-activation-record' -import { ensureOrcadManagedTunnel } from './orcad-managed-tunnel' - -export const UNVERIFIABLE_ORCAD_TERMINAL_CENSUS: OrcadTerminalCensus = { - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: null -} - -export async function collectRemoteOrcadTerminalCensus( - environment: KnownRuntimeEnvironment, - record: OrcadActivationRecord, - timeoutMs = 15_000 -): Promise { - if (!record.active) { - return collectIdle() - } - const activatedAt = record.activatedAt ? Date.parse(record.activatedAt) : Number.NaN - if (!Number.isFinite(activatedAt) || activatedAt < 0) { - return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } - try { - const response = await sendRemoteRuntimeRequestWithStatusPreflight( - getPreferredPairingOffer(environment), - ORCAD_TERMINAL_CENSUS_METHOD, - { activatedAt }, - timeoutMs, - (status) => { - if ( - !status.ok || - !status.result.capabilities?.includes(ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY) - ) { - throw new Error('The managed Orca server does not report a terminal census.') - } - }, - undefined, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - return response.ok - ? OrcadTerminalCensusSchema.parse(response.result) - : UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } catch { - return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } -} - -/** The census through the server's ensured tunnel; a tunnel that cannot open is unverifiable. */ -export async function collectManagedTerminalCensus( - userDataPath: string, - environment: KnownRuntimeEnvironment, - record: OrcadActivationRecord -): Promise { - if (!record.active) { - return collectIdle() - } - try { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } catch { - return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS - } - return collectRemoteOrcadTerminalCensus(environment, record) -} - -function collectIdle(): OrcadTerminalCensus { - return { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null } -} diff --git a/src/main/ssh/orcad-tunneled-pairing.test.ts b/src/main/ssh/orcad-tunneled-pairing.test.ts deleted file mode 100644 index ee49783ab62..00000000000 --- a/src/main/ssh/orcad-tunneled-pairing.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { encodePairingOffer, parsePairingCode, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import type { ServeReadiness } from '../server/serve-readiness' -import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' - -function readiness(endpoint = 'ws://[::1]:6768/runtime?mode=paired#fragment'): ServeReadiness { - return { - runtimeId: 'runtime-1', - boundEndpoint: 'ws://127.0.0.1:6768', - advertisedEndpoint: null, - managedWslCliReconciliation: 'settled', - pairing: { - available: true, - url: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken: 'device-token', - publicKeyB64: 'public-key', - pairedDeviceId: 'device-1' - }), - endpoint, - deviceId: 'device-1', - webClientUrl: null, - scope: 'runtime', - qr: null - } - } -} - -describe('tunneledOrcadPairingCode', () => { - it('rewrites only the endpoint authority for the local tunnel', () => { - const rewritten = parsePairingCode(tunneledOrcadPairingCode(readiness(), 46_768)) - - expect(rewritten).toEqual({ - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768/runtime?mode=paired#fragment', - deviceToken: 'device-token', - publicKeyB64: 'public-key', - pairedDeviceId: 'device-1' - }) - }) - - it('refuses a non-loopback offer', () => { - expect(() => tunneledOrcadPairingCode(readiness('wss://runtime.example.com'), 46_768)).toThrow( - 'not loopback-only' - ) - }) -}) diff --git a/src/main/ssh/orcad-tunneled-pairing.ts b/src/main/ssh/orcad-tunneled-pairing.ts deleted file mode 100644 index 53432181884..00000000000 --- a/src/main/ssh/orcad-tunneled-pairing.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { encodePairingOffer, parsePairingCode } from '../../shared/pairing' -import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' -import type { ServeReadiness } from '../server/serve-readiness' - -export function tunneledOrcadPairingCode(readiness: ServeReadiness, localPort: number): string { - if (!readiness.pairing.available) { - throw new Error( - `The managed Orca server did not publish a pairing offer: ${readiness.pairing.guidance}` - ) - } - const offer = parsePairingCode(readiness.pairing.url) - if (!offer) { - throw new Error('The managed Orca server published an invalid pairing offer.') - } - const endpoint = new URL(offer.endpoint) - if ( - (endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') || - classifyRemotePairingHostname(endpoint.hostname) !== 'loopback' - ) { - throw new Error('The managed Orca server pairing endpoint is not loopback-only.') - } - endpoint.hostname = '127.0.0.1' - endpoint.port = String(localPort) - return encodePairingOffer({ ...offer, endpoint: endpoint.toString() }) -} diff --git a/src/main/ssh/orcad-update-plan.test.ts b/src/main/ssh/orcad-update-plan.test.ts index edd74796409..5d90c597e3c 100644 --- a/src/main/ssh/orcad-update-plan.test.ts +++ b/src/main/ssh/orcad-update-plan.test.ts @@ -7,8 +7,6 @@ import { type OrcadStateSnapshot } from './orcad-activation-record' -const PROTOCOL = { protocolVersion: 3, previousProtocolVersions: [1, 2] } - const SNAPSHOT: OrcadStateSnapshot = { dirName: 'pre-0.2.0+bb01-1000', takenBeforeVersion: '0.2.0+bb01', @@ -30,20 +28,18 @@ function record(overrides: Partial = {}): OrcadActivation describe('planOrcadUpdate', () => { it('does nothing when the candidate is already active', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.2.0+bb01', - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { liveSessions: 0, startedSinceActivation: 0 } }) expect(plan).toMatchObject({ action: 'noop' }) }) it('defers rather than restarting a host with live terminals', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 3, startedSinceActivation: 1, daemonProtocolVersion: 3 } + census: { liveSessions: 3, startedSinceActivation: 1 } }) expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_terminals_running' }) expect(plan.action === 'defer' && plan.reason).toContain('would not kill them') @@ -51,10 +47,9 @@ describe('planOrcadUpdate', () => { it('defers when the session count cannot be established', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: 3 } + census: { liveSessions: null, startedSinceActivation: null } }) expect(plan).toMatchObject({ action: 'defer', @@ -64,10 +59,9 @@ describe('planOrcadUpdate', () => { it('plans a forced update with an unknown census as if terminals were live', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: 3 }, + census: { liveSessions: null, startedSinceActivation: null }, force: true }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) @@ -75,10 +69,9 @@ describe('planOrcadUpdate', () => { it('carries the daemon across a forced update with live terminals', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 2, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 2, startedSinceActivation: 0 }, force: true }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) @@ -86,10 +79,9 @@ describe('planOrcadUpdate', () => { it('replaces the daemon only when nothing is running under it', () => { const plan = planOrcadUpdate({ - candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { liveSessions: 0, startedSinceActivation: 0 } }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: false }) }) @@ -98,10 +90,9 @@ describe('planOrcadUpdate', () => { describe('assessOrcadRollback', () => { it('is clean when the snapshot is intact and nothing happened since activation', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'clean', target: '0.1.0+aa01' }) @@ -109,10 +100,9 @@ describe('assessOrcadRollback', () => { it('is lossy, and names what goes, once the store has been written since activation', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 1, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 1, startedSinceActivation: 0 }, stateWritesSinceActivation: true }) expect(safety).toMatchObject({ safety: 'lossy', target: '0.1.0+aa01' }) @@ -121,10 +111,9 @@ describe('assessOrcadRollback', () => { it('treats an unreadable store mtime as writes, not as a clean rollback', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: null }) expect(safety).toMatchObject({ safety: 'lossy' }) @@ -133,10 +122,9 @@ describe('assessOrcadRollback', () => { // The point past which rollback is unsafe: the first terminal created after activation. it('refuses once a terminal started after activation, because restoring would orphan it', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 4, startedSinceActivation: 1, daemonProtocolVersion: 3 }, + census: { liveSessions: 4, startedSinceActivation: 1 }, stateWritesSinceActivation: true }) expect(safety).toMatchObject({ @@ -148,10 +136,9 @@ describe('assessOrcadRollback', () => { it('refuses when the snapshot the record names is gone from the host', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: false, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) @@ -160,10 +147,9 @@ describe('assessOrcadRollback', () => { it('refuses when no snapshot was ever recorded', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record({ snapshot: null }), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) @@ -171,10 +157,9 @@ describe('assessOrcadRollback', () => { it('refuses when the post-activation session count is unverifiable', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 2, startedSinceActivation: null, daemonProtocolVersion: 3 }, + census: { liveSessions: 2, startedSinceActivation: null }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_census_unavailable' }) @@ -182,83 +167,11 @@ describe('assessOrcadRollback', () => { it('refuses when there is no previous version to go back to', () => { const safety = assessOrcadRollback({ - targetDaemonProtocol: PROTOCOL, record: record({ previous: null }), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { liveSessions: 0, startedSinceActivation: 0 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_no_target' }) }) }) - -describe('D7 daemon protocol crossing', () => { - const census = (daemonProtocolVersion: number | null, liveSessions: number | null = 2) => ({ - liveSessions, - startedSinceActivation: 0, - daemonProtocolVersion - }) - - it.each([false, true])( - 'defers an update that would strand live terminals (force %s)', - (force) => { - const plan = planOrcadUpdate({ - record: record(), - candidateVersion: '0.3.0+cc01', - candidateDaemonProtocol: PROTOCOL, - census: census(4), - force - }) - expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_strands_live_terminals' }) - } - ) - - it('will not force past live terminals whose daemon protocol is unknown', () => { - const plan = planOrcadUpdate({ - record: record(), - candidateVersion: '0.3.0+cc01', - candidateDaemonProtocol: PROTOCOL, - census: census(null), - force: true - }) - expect(plan).toMatchObject({ - action: 'defer', - code: 'orcad_update_daemon_protocol_unverifiable' - }) - }) - - it('needs no protocol answer when no terminals are running', () => { - const plan = planOrcadUpdate({ - record: record(), - candidateVersion: '0.3.0+cc01', - candidateDaemonProtocol: PROTOCOL, - census: census(null, 0) - }) - expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: false }) - }) - - it.each([ - [4, 'orcad_rollback_strands_live_terminals'], - [null, 'orcad_rollback_daemon_protocol_unverifiable'] - ])('refuses a rollback when the daemon speaks %s', (daemonProtocolVersion, code) => { - const safety = assessOrcadRollback({ - record: record(), - snapshotPresent: true, - census: census(daemonProtocolVersion), - targetDaemonProtocol: PROTOCOL, - stateWritesSinceActivation: false - }) - expect(safety).toMatchObject({ safety: 'unsafe', code }) - }) - - it('does not read an unverifiable snapshot probe as a missing snapshot', () => { - const safety = assessOrcadRollback({ - record: record(), - snapshotPresent: null, - census: census(3, 0), - targetDaemonProtocol: PROTOCOL, - stateWritesSinceActivation: false - }) - expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_unverifiable' }) - }) -}) diff --git a/src/main/ssh/orcad-update-plan.ts b/src/main/ssh/orcad-update-plan.ts index 97c94acfdb1..2653956ebe5 100644 --- a/src/main/ssh/orcad-update-plan.ts +++ b/src/main/ssh/orcad-update-plan.ts @@ -19,13 +19,20 @@ * pre-activation snapshot rather than against a version comparison. */ import type { OrcadActivationRecord } from './orcad-activation-record' -import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' -import { - assessOrcadLiveDaemonCrossing, - type OrcadDaemonProtocolFacts -} from './orcad-daemon-protocol-crossing' -export type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census' +export type OrcadTerminalCensus = { + /** + * Sessions the live daemon owns right now. `null` means the probe could not answer — + * never treated as zero, because loss of contact is not evidence of process death + * (docs/reference/ssh-execution-boundary.md). + */ + liveSessions: number | null + /** + * Of those, how many started at or after `record.activatedAt`. These are the sessions the + * pre-activation snapshot does not describe. + */ + startedSinceActivation: number | null +} export type OrcadUpdateDecision = | { action: 'noop'; reason: string } @@ -40,8 +47,6 @@ export type OrcadUpdateDecision = export type OrcadUpdateDeferCode = | 'orcad_update_terminals_running' | 'orcad_update_terminal_census_unavailable' - | 'orcad_update_strands_live_terminals' - | 'orcad_update_daemon_protocol_unverifiable' /** * Decide whether to restart orcad onto `candidateVersion`. @@ -55,8 +60,6 @@ export function planOrcadUpdate(input: { record: OrcadActivationRecord candidateVersion: string census: OrcadTerminalCensus - /** The candidate's daemon protocol and the older ones it can still attach to. */ - candidateDaemonProtocol: OrcadDaemonProtocolFacts force?: boolean }): OrcadUpdateDecision { if (input.record.active === input.candidateVersion) { @@ -65,29 +68,7 @@ export function planOrcadUpdate(input: { reason: `${input.candidateVersion} is already the active version; nothing to restart.` } } - const crossing = assessOrcadLiveDaemonCrossing(input.census, input.candidateDaemonProtocol) - // Why force cannot override: the operator can accept a mixed pair, not unreachable terminals. - if (crossing === 'strands-live-terminals') { - return { - action: 'defer', - code: 'orcad_update_strands_live_terminals', - reason: - `The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` + - `which orcad ${input.candidateVersion} cannot attach to. Restarting now would leave ` + - 'every running terminal unreachable. Update when no terminals are running.' - } - } const { liveSessions } = input.census - if (input.force && crossing === 'unverifiable') { - return { - action: 'defer', - code: 'orcad_update_daemon_protocol_unverifiable', - reason: - 'The terminal daemon did not report its protocol, so this update cannot show that ' + - `orcad ${input.candidateVersion} will reach the terminals it is forced past. Retry ` + - 'when the daemon answers.' - } - } if (liveSessions === null) { if (!input.force) { return { @@ -155,9 +136,6 @@ export type OrcadRollbackUnsafeCode = | 'orcad_rollback_snapshot_missing' | 'orcad_rollback_orphans_live_terminals' | 'orcad_rollback_census_unavailable' - | 'orcad_rollback_snapshot_unverifiable' - | 'orcad_rollback_strands_live_terminals' - | 'orcad_rollback_daemon_protocol_unverifiable' /** * How safe it is to switch back to `record.previous`. @@ -178,11 +156,9 @@ export type OrcadRollbackUnsafeCode = */ export function assessOrcadRollback(input: { record: OrcadActivationRecord - /** Whether the snapshot is still on the host; `null` means the probe was unverifiable. */ - snapshotPresent: boolean | null + /** Whether the snapshot named by the record is actually still on the host. */ + snapshotPresent: boolean census: OrcadTerminalCensus - /** The rollback target's daemon protocol facts, from the client's copy of its bytes. */ - targetDaemonProtocol: OrcadDaemonProtocolFacts /** * Whether the shared store has been written since activation, from its mtime against * `record.activatedAt`. `null` means unknown, which is treated as "yes" — claiming a @@ -200,15 +176,6 @@ export function assessOrcadRollback(input: { 'to. Deploy a known-good build instead.' } } - if (input.record.snapshot && input.snapshotPresent === null) { - return { - safety: 'unsafe', - code: 'orcad_rollback_snapshot_unverifiable', - reason: - 'The host did not give a trustworthy answer about the pre-activation snapshot. Retry ' + - 'when the host is reachable; loss of contact is not evidence the snapshot is gone.' - } - } if (!input.record.snapshot || !input.snapshotPresent) { return { safety: 'unsafe', @@ -242,26 +209,6 @@ export function assessOrcadRollback(input: { 'reattach. Close them (or let them exit) and roll back then.' } } - const crossing = assessOrcadLiveDaemonCrossing(input.census, input.targetDaemonProtocol) - if (crossing === 'unverifiable') { - return { - safety: 'unsafe', - code: 'orcad_rollback_daemon_protocol_unverifiable', - reason: - 'The terminal daemon did not report its protocol, so this rollback cannot show that ' + - `${target} would reach the terminals still running. Retry when the daemon answers.` - } - } - if (crossing === 'strands-live-terminals') { - return { - safety: 'unsafe', - code: 'orcad_rollback_strands_live_terminals', - reason: - `The live terminal daemon speaks protocol ${String(input.census.daemonProtocolVersion)}, ` + - `which ${target} does not list. Rolling back now would leave every running terminal ` + - 'unreachable. Roll back when no terminals are running, or deploy forward.' - } - } if (input.stateWritesSinceActivation === false) { return { safety: 'clean', diff --git a/src/main/ssh/runtime-ssh-access-verification.test.ts b/src/main/ssh/runtime-ssh-access-verification.test.ts deleted file mode 100644 index d847b801a23..00000000000 --- a/src/main/ssh/runtime-ssh-access-verification.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createEnvironmentFromPairingOffer } from '../../shared/runtime-environments' -import { RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' -import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' -import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification' - -const send = vi.hoisted(() => vi.fn()) -vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: send })) - -const pairing = { - v: 2 as const, - endpoint: 'wss://public.example/reverse-proxy/runtime', - deviceToken: 'existing-device-token', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - pairedDeviceId: 'existing-paired-client' -} -function environment() { - return createEnvironmentFromPairingOffer({ - id: 'environment', - name: 'Existing host', - now: 1, - offer: pairing, - runtimeId: 'host-runtime' - }) -} -function success() { - return { - id: 'status', - ok: true, - result: { - runtimeId: 'host-runtime', - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - deviceScope: 'runtime', - pairedDeviceId: 'existing-paired-client' - }, - _meta: { runtimeId: 'host-runtime' } - } -} - -describe('existing paired host verification over SSH', () => { - beforeEach(() => { - send.mockReset().mockResolvedValue(success()) - }) - - it('uses the native tunnel listener with the existing E2EE key and grant', async () => { - const original = environment() - const signal = new AbortController().signal - const result = await verifyRuntimeEnvironmentSshTunnel(original, 41000, signal) - expect(send).toHaveBeenCalledExactlyOnceWith( - { ...pairing, endpoint: 'ws://127.0.0.1:41000' }, - 'status.get', - undefined, - 15_000, - undefined, - signal, - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES - ) - expect(result.verifiedRuntimeId).toBe('host-runtime') - expect(result.verifiedPairing).toEqual({ ...pairing, endpoint: 'ws://127.0.0.1:41000' }) - expect(original).toEqual(environment()) - }) - - it('can learn an unrecorded runtime id only after authenticated status verification', async () => { - const original = { ...environment(), runtimeId: null } - expect((await verifyRuntimeEnvironmentSshTunnel(original, 41000)).verifiedRuntimeId).toBe( - 'host-runtime' - ) - expect(original.runtimeId).toBeNull() - }) - - it.each([0, -1, 65536, 1.5, Number.NaN, Infinity])( - 'rejects invalid bound port %s before contact', - async (port) => { - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), port)).rejects.toThrow( - 'valid local port' - ) - expect(send).not.toHaveBeenCalled() - } - ) - - it.each(['host-identity', 'access-grant', 'connect'] as const)( - 'does not fallback when %s fails', - async (pairingStage) => { - const error = new RemoteRuntimeClientError('unauthorized', 'verification failed', { - pairingStage - }) - send.mockRejectedValue(error) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toBe(error) - expect(send).toHaveBeenCalledTimes(1) - } - ) - - it('refuses an RPC failure even when it carries the expected runtime metadata', async () => { - send.mockResolvedValue({ - id: 'status', - ok: false, - error: { code: 'unauthorized', message: 'Access revoked' }, - _meta: { runtimeId: 'host-runtime' } - }) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toThrow( - 'Access revoked' - ) - }) - - it.each([ - { runtimeId: 'other-host' }, - { pairedDeviceId: 'other-client' }, - { deviceScope: 'mobile' }, - { minCompatibleRuntimeClientVersion: RUNTIME_PROTOCOL_VERSION + 1 }, - { rendererGraphEpoch: -1 } - ])( - 'refuses wrong identity, grant scope, compatibility or malformed status: %j', - async (change) => { - const response = success() - send.mockResolvedValue({ ...response, result: { ...response.result, ...change } }) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toThrow() - } - ) - - it('refuses inconsistent runtime identity between status and its envelope', async () => { - send.mockResolvedValue({ ...success(), _meta: { runtimeId: 'different-runtime' } }) - await expect(verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).rejects.toThrow( - 'runtime identity' - ) - }) - - it('accepts a compatible host that does not publish an optional paired device id', async () => { - const response = success() - send.mockResolvedValue({ - ...response, - result: { ...response.result, pairedDeviceId: undefined } - }) - expect((await verifyRuntimeEnvironmentSshTunnel(environment(), 41000)).verifiedRuntimeId).toBe( - 'host-runtime' - ) - }) -}) diff --git a/src/main/ssh/runtime-ssh-access-verification.ts b/src/main/ssh/runtime-ssh-access-verification.ts deleted file mode 100644 index 9bf6058d1dd..00000000000 --- a/src/main/ssh/runtime-ssh-access-verification.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' -import { verifyRuntimeEnvironmentIdentity } from '../runtime/runtime-environment-identity-verification' -import type { PairingOffer } from '../../shared/pairing' -import type { RuntimeStatus } from '../../shared/runtime-types' - -export async function verifyRuntimeEnvironmentSshTunnel( - environment: KnownRuntimeEnvironment, - localPort: number, - signal?: AbortSignal -): Promise<{ - verifiedPairing: PairingOffer - verifiedRuntimeId: string - runtimeStatus: RuntimeStatus -}> { - if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65_535) { - throw new Error('The SSH tunnel did not provide a valid local port.') - } - // The tunnel targets the native listener, not the public reverse proxy; E2EE still pins its key. - return verifyRuntimeEnvironmentIdentity(environment, { - endpoint: `ws://127.0.0.1:${localPort}`, - signal - }) -} diff --git a/src/main/ssh/runtime-ssh-access.test.ts b/src/main/ssh/runtime-ssh-access.test.ts deleted file mode 100644 index 2a7cbee781b..00000000000 --- a/src/main/ssh/runtime-ssh-access.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { encodePairingOffer } from '../../shared/pairing' -import { - addEnvironmentFromPairingCode, - resolveEnvironment -} from '../../shared/runtime-environment-store' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { readPersistedEnvironmentStore } from '../../shared/runtime-environment-store-file' -import { writeRuntimeEnvironmentSidecarEntry } from '../../shared/runtime-environment-sidecar' -import type { SshTarget } from '../../shared/ssh-types' - -const mocks = vi.hoisted(() => ({ - infrastructure: vi.fn(), - connect: vi.fn(), - start: vi.fn(), - close: vi.fn(), - ensure: vi.fn(), - verify: vi.fn(), - flush: vi.fn(), - preflight: vi.fn(), - hasDirectAuthority: vi.fn() -})) -vi.mock('./orcad-managed-runtime-context', () => ({ - requireManagedOrcadInfrastructure: mocks.infrastructure -})) -vi.mock('./orcad-managed-tunnel', () => ({ - startOrcadManagedTunnel: mocks.start, - closeOrcadManagedTunnel: mocks.close, - ensureOrcadManagedTunnel: mocks.ensure -})) -vi.mock('./runtime-ssh-access-verification', () => ({ - verifyRuntimeEnvironmentSshTunnel: mocks.verify -})) -vi.mock('./ssh-target-registry', () => ({ - hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority -})) -import { - fingerprintRuntimeSshTarget, - linkRuntimeSshAccess, - unlinkRuntimeSshAccess -} from './runtime-ssh-access' - -const pairing = { - v: 2 as const, - endpoint: 'wss://example.test/runtime', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - deviceToken: 'secret', - pairedDeviceId: 'client' -} - -describe('independent runtime SSH access coordinator', () => { - let userDataPath: string - let environmentId: string - let target: SshTarget - let events: string[] - const request = () => ({ - selector: environmentId, - requestId: 'request', - sshTargetId: 'target', - remotePort: 6768 - }) - const current = () => resolveEnvironment(userDataPath, environmentId) - const unlink = (requestId = 'unlink') => - unlinkRuntimeSshAccess( - userDataPath, - { selector: environmentId, requestId }, - { - invalidateTransport: () => { - events.push('invalidate') - expect(current().sshAccess).toBeUndefined() - } - } - ) - beforeEach(() => { - vi.resetAllMocks() - events = [] - userDataPath = mkdtempSync(join(tmpdir(), 'runtime-access-test-')) - environmentId = addEnvironmentFromPairingCode(userDataPath, { - name: 'server', - pairingCode: encodePairingOffer(pairing) - }).id - target = { - id: 'target', - label: 'host', - host: 'host.example', - port: 22, - username: 'user', - source: 'manual', - generation: 3 - } - mocks.preflight.mockReturnValue({ blockers: [] }) - mocks.flush.mockImplementation(async () => { - expect(current().pendingSshAccessOperation).toBeDefined() - events.push('flush') - }) - mocks.infrastructure.mockReturnValue({ - connectionManager: { connect: mocks.connect }, - claims: { - listTargets: () => [target], - preflight: mocks.preflight, - flush: mocks.flush, - claim: () => { - expect(current().pendingSshAccessOperation?.operation).toBe('link') - events.push('claim') - target = { ...target, owner: createManagedOrcadSshOwner(environmentId) } - return target - }, - release: () => { - expect(current().pendingSshAccessOperation?.operation).toBe('unlink') - events.push('release') - target = { ...target, owner: undefined } - return target - } - } - }) - mocks.connect.mockImplementation(async () => { - events.push('connect') - return {} - }) - mocks.start.mockImplementation(async () => { - events.push('tunnel') - return 41000 - }) - mocks.close.mockImplementation(async () => { - events.push('close') - }) - mocks.verify.mockImplementation(async () => { - events.push('verify') - return { - verifiedRuntimeId: 'runtime', - verifiedPairing: { ...pairing, endpoint: 'ws://127.0.0.1:41000' }, - runtimeStatus: {} - } - }) - }) - afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) - - it.each(['link', 'unlink'])( - 'refuses %s before side effects during host reconciliation', - async (operation) => { - const environments = readPersistedEnvironmentStore(userDataPath).environments - const persisted = environments.find((entry) => entry.id === environmentId) - if (!persisted) { - throw new Error('missing environment') - } - writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, persisted, { - reconciliation: { - version: 1, - stage: 'prepared', - requestId: 'reconciliation', - canonicalEnvironmentId: environmentId, - runtimeId: 'host-runtime', - preparedAt: 1, - registrations: [ - { environmentId, authorityDigest: 'a'.repeat(64) }, - { environmentId: 'peer', authorityDigest: 'b'.repeat(64) } - ] - } - }) - await expect( - operation === 'link' ? linkRuntimeSshAccess(userDataPath, request()) : unlink() - ).rejects.toThrow('reconciliation') - expect(mocks.connect).not.toHaveBeenCalled() - expect(mocks.start).not.toHaveBeenCalled() - expect(mocks.close).not.toHaveBeenCalled() - expect(mocks.flush).not.toHaveBeenCalled() - } - ) - - it('persists intent then claim before connecting and publishes redacted authenticated access', async () => { - const result = await linkRuntimeSshAccess(userDataPath, request()) - expect(events).toEqual(['flush', 'claim', 'flush', 'connect', 'tunnel', 'verify']) - expect(result.sshAccess?.sshTargetId).toBe('target') - expect(result.orcadDeployment).toBeUndefined() - expect(JSON.stringify(result)).not.toContain('secret') - expect(current().pendingSshAccessOperation).toBeUndefined() - expect(mocks.preflight).toHaveBeenCalledWith('target') - }) - - it('handles lost link responses without connecting or verifying again', async () => { - const first = await linkRuntimeSshAccess(userDataPath, request()) - expect(await linkRuntimeSshAccess(userDataPath, request())).toEqual(first) - expect(mocks.verify).toHaveBeenCalledTimes(1) - expect(mocks.ensure).toHaveBeenCalledWith(userDataPath, environmentId) - }) - - it('invalidates old transport only after authenticated access is published', async () => { - const invalidateTransport = vi.fn(() => { - expect(current().sshAccess).toBeDefined() - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - expect(invalidateTransport).toHaveBeenCalledTimes(2) - }) - - it('rejects changed request tuple after a completed link', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - await expect( - linkRuntimeSshAccess(userDataPath, { ...request(), remotePort: 6769 }) - ).rejects.toThrow('different') - expect(mocks.verify).toHaveBeenCalledTimes(1) - }) - - it('preserves the committed tunnel when transport refresh fails and retries the same link', async () => { - const invalidateTransport = vi.fn().mockRejectedValueOnce(new Error('refresh failed')) - await expect( - linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - ).rejects.toThrow('refresh failed') - const committed = current() - expect(committed.sshAccess?.requestId).toBe('request') - expect(committed.pendingSshAccessOperation).toBeUndefined() - expect(target.owner).toEqual(createManagedOrcadSshOwner(environmentId)) - expect(mocks.close).not.toHaveBeenCalled() - - await linkRuntimeSshAccess(userDataPath, request(), { invalidateTransport }) - expect(current()).toEqual(committed) - expect(mocks.ensure).toHaveBeenCalledExactlyOnceWith(userDataPath, environmentId) - expect(mocks.connect).toHaveBeenCalledTimes(1) - expect(mocks.verify).toHaveBeenCalledTimes(1) - expect(mocks.start).toHaveBeenCalledTimes(1) - expect(invalidateTransport).toHaveBeenCalledTimes(2) - expect(mocks.close).not.toHaveBeenCalled() - }) - - it('fences connection configuration changes before opening a forward', async () => { - mocks.connect.mockImplementationOnce(async () => { - target = { ...target, username: 'other' } - return {} - }) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('changed') - expect(mocks.start).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation?.operation).toBe('link') - }) - - it('restores pairing before invalidating and closes before releasing the exact claim', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - events = [] - await unlink() - expect(events).toEqual(['invalidate', 'close', 'release', 'flush']) - expect(current().endpoints[0].endpoint).toBe(pairing.endpoint) - expect(current().sshAccess).toBeUndefined() - expect(target.owner).toBeUndefined() - }) - - it('retains claim and pending intent on wrong-host proof, then permits cancellation', async () => { - mocks.verify.mockRejectedValueOnce(new Error('wrong host')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('wrong host') - expect(current().pendingSshAccessOperation?.operation).toBe('link') - expect(current().sshAccess).toBeUndefined() - expect(target.owner).toBeDefined() - expect(mocks.close).toHaveBeenCalledTimes(1) - await unlink('request') - expect(target.owner).toBeUndefined() - }) - - it('retries verification failure with the same durable intent', async () => { - mocks.verify.mockRejectedValueOnce(new Error('unverifiable')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow() - await linkRuntimeSshAccess(userDataPath, request()) - expect(current().sshAccess).toBeDefined() - }) - - it.each(['host', 'generation', 'owner'] as const)( - 'fences %s reassignment during authenticated proof', - async (field) => { - mocks.verify.mockImplementationOnce(async () => { - target = { - ...target, - ...(field === 'host' - ? { host: 'elsewhere' } - : field === 'generation' - ? { generation: 4 } - : { owner: createManagedOrcadSshOwner('other') }) - } - return { verifiedRuntimeId: 'runtime', verifiedPairing: pairing } - }) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('changed') - expect(current().sshAccess).toBeUndefined() - expect(mocks.close).toHaveBeenCalled() - await expect(unlink('request')).rejects.toThrow('changed') - expect(events).not.toContain('release') - } - ) - - it.each([1, 2])('never connects when durable flush %s fails', async (flushNumber) => { - if (flushNumber === 2) { - mocks.flush.mockResolvedValueOnce(undefined) - } - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('disk full') - expect(mocks.connect).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation).toBeDefined() - expect(!!target.owner).toBe(flushNumber === 2) - }) - - it('resumes unlink after release flush failed without releasing another owner', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(unlink()).rejects.toThrow('disk full') - expect(target.owner).toBeUndefined() - await unlink() - expect(events.filter((event) => event === 'release')).toHaveLength(1) - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - - it('does not release a new owner when retrying an interrupted unlink', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - mocks.flush.mockRejectedValueOnce(new Error('disk full')) - await expect(unlink()).rejects.toThrow('disk full') - target = { ...target, owner: createManagedOrcadSshOwner('other') } - await expect(unlink()).rejects.toThrow('changed') - expect(events.filter((event) => event === 'release')).toHaveLength(1) - expect(current().pendingSshAccessOperation?.operation).toBe('unlink') - }) - - it('refuses direct authority even on an already claimed retry', async () => { - mocks.verify.mockRejectedValueOnce(new Error('offline')) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow() - mocks.preflight.mockReturnValue({ - blockers: [{ code: 'orcad_migration_direct_ssh_repositories' }] - }) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow( - 'direct SSH authority' - ) - expect(mocks.connect).toHaveBeenCalledTimes(1) - }) - - it('rejects provisioning and missing durable generations without connecting', async () => { - target.orcadProvisioning = { name: 'host', requestId: 'provision' } - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('provisioning') - target.orcadProvisioning = undefined - target.generation = undefined - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow('generation') - expect(mocks.connect).not.toHaveBeenCalled() - }) - - it('refuses a connected direct provider even when it has no catalog or leases', async () => { - mocks.hasDirectAuthority.mockReturnValue(true) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow( - 'Disconnect direct SSH authority' - ) - expect(mocks.connect).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - - it('treats a lost successful unlink response as an idempotent no-op', async () => { - await linkRuntimeSshAccess(userDataPath, request()) - const unlinked = await unlink() - const before = [...events] - expect(await unlink()).toEqual(unlinked) - expect(events).toEqual(before) - }) - - it('hashes connection identity, not display state or owner', () => { - const hash = fingerprintRuntimeSshTarget(target) - expect( - fingerprintRuntimeSshTarget({ - ...target, - label: 'renamed', - lastRequiredPassphrase: true, - owner: createManagedOrcadSshOwner('other') - }) - ).toBe(hash) - expect(fingerprintRuntimeSshTarget({ ...target, proxyCommand: 'other-proxy' })).not.toBe(hash) - }) - - it('strictly validates ports and request IDs', () => { - expect(() => linkRuntimeSshAccess(userDataPath, { ...request(), remotePort: 0 })).toThrow() - expect(() => - linkRuntimeSshAccess(userDataPath, { ...request(), requestId: '../other' }) - ).toThrow() - expect(mocks.infrastructure).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ssh/runtime-ssh-access.ts b/src/main/ssh/runtime-ssh-access.ts deleted file mode 100644 index 6092a501052..00000000000 --- a/src/main/ssh/runtime-ssh-access.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { createHash } from 'node:crypto' -import { - RuntimeSshAccessLinkRequestSchema, - RuntimeSshAccessUnlinkRequestSchema, - type RuntimeSshAccessLinkRequest, - type RuntimeSshAccessUnlinkRequest -} from '../../shared/runtime-ssh-access' -import { resolveEnvironment } from '../../shared/runtime-environment-store' -import { assertRuntimeEnvironmentNotReconciling } from '../../shared/runtime-environment-reconciliation-record' -import { redactRuntimeEnvironment } from '../../shared/runtime-environments' -import { - cancelRuntimeEnvironmentSshAccessLink, - completeRuntimeEnvironmentSshAccessUnlink, - linkVerifiedRuntimeEnvironmentSshAccess, - prepareRuntimeEnvironmentSshAccessLink, - prepareRuntimeEnvironmentSshAccessUnlink -} from '../../shared/runtime-environment-ssh-access-store' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { SshTarget } from '../../shared/ssh-types' -import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' -import { requireManagedOrcadInfrastructure } from './orcad-managed-runtime-context' -import { - closeOrcadManagedTunnel, - ensureOrcadManagedTunnel, - startOrcadManagedTunnel -} from './orcad-managed-tunnel' -import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' - -type TargetClaims = ReturnType['claims'] - -export function fingerprintRuntimeSshTarget(target: SshTarget): string { - return createHash('sha256') - .update( - JSON.stringify({ - host: target.host, - port: target.port, - username: target.username, - configHost: target.configHost, - identityFile: target.identityFile, - identityAgent: target.identityAgent, - identitiesOnly: target.identitiesOnly, - gssapiAuthentication: target.gssapiAuthentication, - proxyCommand: target.proxyCommand, - jumpHost: target.jumpHost, - systemSshConnectionReuse: target.systemSshConnectionReuse - }) - ) - .digest('hex') -} - -function requireTarget(claims: TargetClaims, targetId: string): SshTarget & { generation: number } { - const target = claims.listTargets().find((entry) => entry.id === targetId) - const generation = target?.generation - if (!target || generation === undefined || !Number.isSafeInteger(generation) || generation <= 0) { - throw new Error('SSH access requires an existing durable SSH target generation.') - } - if (target.orcadProvisioning) { - throw new Error('This SSH target has pending server provisioning.') - } - return { ...target, generation } -} - -function requireAccessOnlyTarget(claims: TargetClaims, targetId: string, environmentId: string) { - const target = requireTarget(claims, targetId) - if (hasRegisteredDirectSshAuthority(targetId)) { - throw new Error('Disconnect direct SSH authority before linking access to this paired server.') - } - // Omitting the environment bypasses the same-owner shortcut and rechecks direct authority. - const blockers = claims - .preflight(targetId) - .blockers.filter( - (entry) => - !( - entry.code === 'orcad_migration_target_owned' && - getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId - ) - ) - if (blockers.length) { - throw new Error(`SSH access cannot claim direct SSH authority: ${blockers[0].code}`) - } - return target -} - -function requireFence( - claims: TargetClaims, - environmentId: string, - fence: { - sshTargetId: string - sshTargetGeneration: number - targetFingerprint?: string - }, - allowUnowned = false -): SshTarget { - const target = requireTarget(claims, fence.sshTargetId) - const owner = getManagedOrcadOwnerEnvironmentId(target.owner) - if ( - target.generation !== fence.sshTargetGeneration || - !fence.targetFingerprint || - fingerprintRuntimeSshTarget(target) !== fence.targetFingerprint || - (owner !== environmentId && !(allowUnowned && !target.owner)) - ) { - throw new Error('The SSH target registration, connection configuration, or owner changed.') - } - return target -} - -export function linkRuntimeSshAccess( - userDataPath: string, - input: RuntimeSshAccessLinkRequest, - options: { - signal?: AbortSignal - invalidateTransport?: (environmentId: string) => void | Promise - } = {} -) { - const args = RuntimeSshAccessLinkRequestSchema.parse(input) - const environmentId = resolveEnvironment(userDataPath, args.selector).id - return runTargetLifecycle(`runtime-ssh-access:${userDataPath}:${environmentId}`, () => - runTargetLifecycle(args.sshTargetId, async () => { - const { claims, connectionManager } = requireManagedOrcadInfrastructure() - const environment = resolveEnvironment(userDataPath, environmentId) - assertRuntimeEnvironmentNotReconciling(environment) - if (environment.orcadDeployment) { - throw new Error('Managed deployments cannot use independent SSH access.') - } - const target = requireAccessOnlyTarget(claims, args.sshTargetId, environmentId) - const fence = { - sshTargetId: target.id, - sshTargetGeneration: target.generation, - targetFingerprint: fingerprintRuntimeSshTarget(target) - } - if (environment.sshAccess) { - const access = environment.sshAccess - if ( - environment.pendingSshAccessOperation || - access.requestId !== args.requestId || - access.sshTargetId !== args.sshTargetId || - access.remotePort !== args.remotePort - ) { - throw new Error('This server already has a different SSH access request.') - } - requireFence(claims, environmentId, access) - await ensureOrcadManagedTunnel(userDataPath, environmentId) - requireFence(claims, environmentId, access) - await options.invalidateTransport?.(environmentId) - return redactRuntimeEnvironment(resolveEnvironment(userDataPath, environmentId)) - } - const prepared = prepareRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: environment, - requestId: args.requestId, - remotePort: args.remotePort, - ...fence - }) - await claims.flush() - requireAccessOnlyTarget(claims, target.id, environmentId) - requireFence(claims, environmentId, fence, true) - claims.claim(target.id, environmentId) - await claims.flush() - let tunnelStarted = false - let linkCommitted = false - try { - options.signal?.throwIfAborted() - const claimed = requireFence(claims, environmentId, fence) - const connection = await connectionManager.connect(claimed) - requireFence(claims, environmentId, fence) - tunnelStarted = true - const localPort = await startOrcadManagedTunnel( - environmentId, - claimed, - connection, - args.remotePort - ) - const proof = await verifyRuntimeEnvironmentSshTunnel(prepared, localPort, options.signal) - options.signal?.throwIfAborted() - requireAccessOnlyTarget(claims, target.id, environmentId) - requireFence(claims, environmentId, fence) - const linked = redactRuntimeEnvironment( - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepared, - requestId: args.requestId, - ...proof, - tunnel: { - sshTargetId: target.id, - sshTargetGeneration: fence.sshTargetGeneration, - localPort, - remotePort: args.remotePort - } - }) - ) - linkCommitted = true - await options.invalidateTransport?.(environmentId) - return linked - } catch (error) { - if (tunnelStarted && !linkCommitted) { - await closeOrcadManagedTunnel(environmentId) - } - throw error - } - }) - ) -} - -export function unlinkRuntimeSshAccess( - userDataPath: string, - input: RuntimeSshAccessUnlinkRequest, - options: { invalidateTransport: (environmentId: string) => void | Promise } -) { - const args = RuntimeSshAccessUnlinkRequestSchema.parse(input) - const environmentId = resolveEnvironment(userDataPath, args.selector).id - return runTargetLifecycle(`runtime-ssh-access:${userDataPath}:${environmentId}`, async () => { - const environment = resolveEnvironment(userDataPath, environmentId) - assertRuntimeEnvironmentNotReconciling(environment) - if (environment.orcadDeployment) { - throw new Error('Managed deployments cannot unlink independent SSH access.') - } - const access = environment.pendingSshAccessOperation ?? environment.sshAccess - if (!access) { - return redactRuntimeEnvironment(environment) - } - return runTargetLifecycle(access.sshTargetId, async () => { - const { claims } = requireManagedOrcadInfrastructure() - requireFence(claims, environmentId, access, !!environment.pendingSshAccessOperation) - const prepared = - environment.pendingSshAccessOperation?.operation === 'link' - ? cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: environment, - requestId: args.requestId - }) - : prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: environment, - requestId: args.requestId - }) - await options.invalidateTransport(environmentId) - await closeOrcadManagedTunnel(environmentId) - const target = requireFence(claims, environmentId, access, true) - if (target.owner && !claims.release(target.id, environmentId)) { - throw new Error('The SSH target owner changed before access could be released.') - } - await claims.flush() - requireFence(claims, environmentId, access, true) - return redactRuntimeEnvironment( - completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: args.requestId - }) - ) - }) - }) -} diff --git a/src/main/ssh/ssh-channel-open.ts b/src/main/ssh/ssh-channel-open.ts deleted file mode 100644 index e46d2c28f62..00000000000 --- a/src/main/ssh/ssh-channel-open.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { trackSshConnectionChannelLifetime } from './ssh-connection-channel-lifetime' -import { CONNECT_TIMEOUT_MS, createSshOperationAbortError } from './ssh-connection-utils' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import { isSshSessionLimitError } from './ssh-session-limit-error' - -type SshChannelOpenWork = ReturnType - -// Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. -const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000 - -// Why: MaxSessions servers can transiently refuse a channel open; a refused open never ran the command, so retry is safe. -const SESSION_LIMIT_OPEN_RETRIES = 4 -const SESSION_LIMIT_OPEN_RETRY_DELAY_MS = 150 - -export async function openSshSessionChannelWithRetry( - open: () => Promise, - signal?: AbortSignal -): Promise { - let lastError: unknown - for (let attempt = 0; attempt < SESSION_LIMIT_OPEN_RETRIES; attempt++) { - if (attempt > 0) { - // Why: an abort must release the backoff immediately, not after it. - if (!signal?.aborted) { - await new Promise((resolve) => { - const onDelayDone = (): void => { - clearTimeout(delayTimer) - signal?.removeEventListener('abort', onDelayDone) - resolve() - } - const delayTimer = setTimeout(onDelayDone, SESSION_LIMIT_OPEN_RETRY_DELAY_MS) - signal?.addEventListener('abort', onDelayDone, { once: true }) - }) - } - if (signal?.aborted) { - throw createSshOperationAbortError() - } - } - try { - return await open() - } catch (err) { - if (!isSshSessionLimitError(err)) { - throw err - } - lastError = err - } - } - throw lastError -} - -/** - * Waits for an ssh2 channel open, bounded by CONNECT_TIMEOUT_MS and an abort grace. The channel, - * late ones included, stays on `work` until it physically closes. - */ -export function waitForSshChannelOpen( - work: SshChannelOpenWork, - timeoutMessage: string, - register: (callback: (error: Error | undefined, value: T) => void) => void, - cleanupLateValue?: (value: T) => void, - signal?: AbortSignal, - trackRemoteCommandTermination = false, - onUnhandledError?: (error: Error) => void -): Promise { - return new Promise((resolve, reject) => { - type ChannelOpenTerminationError = Error & { sshChannelCloseConfirmed: boolean } - let settled = false - let unconfirmedOpenError: ChannelOpenTerminationError | null = null - const markOpenUnconfirmed = (error: Error): Error => { - work.markUnverifiable(error) - if (!trackRemoteCommandTermination) { - return error - } - unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false }) - return unconfirmedOpenError - } - // Why: an in-flight open holds a MaxSessions slot; reject the caller now, then settle from the open callback once the late channel closes. - let abortRequested = false - let abortDeadlineTimer: NodeJS.Timeout | undefined - const cleanup = (): void => { - clearTimeout(timer) - clearTimeout(abortDeadlineTimer) - signal?.removeEventListener('abort', onAbort) - } - const onAbort = (): void => { - abortRequested = true - // Why: a hung socket may never invoke the open callback; bound the aborted caller's wait instead of pinning it for CONNECT_TIMEOUT_MS. - abortDeadlineTimer = setTimeout(() => { - settled = true - cleanup() - reject(markOpenUnconfirmed(createSshOperationAbortError())) - }, ABORTED_CHANNEL_CLOSE_GRACE_MS) - } - const timer = setTimeout(() => { - settled = true - cleanup() - reject( - markOpenUnconfirmed( - abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage) - ) - ) - }, CONNECT_TIMEOUT_MS) - const discardLateValue = (value: T, onClose?: () => void): void => { - const emitter = value as Partial & { - resume?: () => void - stderr?: Partial & { resume?: () => void } - } - const swallowLateError = (): void => {} - emitter.on?.('error', swallowLateError) - emitter.stderr?.on?.('error', swallowLateError) - if (onClose) { - emitter.once?.('close', onClose) - } - // Why: ssh2 withholds CHANNEL_CLOSE while discarded exec streams remain unread, and teardown errors have no other owner. - emitter.resume?.() - emitter.stderr?.resume?.() - try { - cleanupLateValue?.(value) - } catch { - /* best effort */ - } - } - const rejectAfterClose = (value: T): void => { - const abortError = markOpenUnconfirmed(createSshOperationAbortError()) - const emitter = value as Partial & { - resume?: () => void - stderr?: { resume?: () => void } - } - let finished = false - const done = (): void => { - if (finished) { - return - } - finished = true - clearTimeout(closeGraceTimer) - emitter.removeListener?.('close', confirmAndDone) - reject(abortError) - } - const confirmAndDone = (): void => { - if (unconfirmedOpenError === abortError) { - unconfirmedOpenError.sshChannelCloseConfirmed = true - } - done() - } - // Why: bounded so a remote that never confirms the close can't hang the aborted operation forever. - const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS) - if (typeof emitter.once === 'function') { - emitter.once('close', confirmAndDone) - } - // Why: ssh2 withholds 'close' until the channel's streams are drained; nobody else will read this discarded channel. - discardLateValue(value) - if (typeof emitter.once !== 'function') { - done() - } - } - const finish = (error: Error | undefined, value?: T): void => { - // Late channels stay tracked until physical close, after the caller has given up. - if (!error && value !== undefined) { - trackSshConnectionChannelLifetime(work, value, onUnhandledError) - } else { - work.close(error) - } - if (settled) { - // Why: ssh2 can invoke the open callback after our timeout rejected; close that late channel so it isn't left open with no owner. - if (!error && value !== undefined) { - discardLateValue(value, () => { - if (unconfirmedOpenError) { - unconfirmedOpenError.sshChannelCloseConfirmed = true - } - }) - } - return - } - settled = true - cleanup() - if (abortRequested) { - if (!error && value !== undefined) { - rejectAfterClose(value) - } else { - reject(createSshOperationAbortError()) - } - return - } - if (error) { - reject(error) - return - } - resolve(value as T) - } - if (signal?.aborted) { - // No open is in flight yet, so failing fast leaks nothing. - cleanup() - work.close() - reject(createSshOperationAbortError()) - return - } - signal?.addEventListener('abort', onAbort, { once: true }) - - try { - // Why: higher-level channel timers start only after ssh2's open callback; a stale SSH socket can otherwise keep exec/sftp stuck. - register(finish) - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))) - } - }) -} diff --git a/src/main/ssh/ssh-connection-channel-error.test.ts b/src/main/ssh/ssh-connection-channel-error.test.ts deleted file mode 100644 index 8ecede18213..00000000000 --- a/src/main/ssh/ssh-connection-channel-error.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { createCallbacks, createTarget } from './ssh-connection-test-fixtures' -import { resetSshConnectionMocks, ssh2Mock } from './ssh-connection-test-harness' -import * as sshClientMock from './__tests__/ssh-connection-test-client' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(resetSshConnectionMocks) -afterEach(() => { - vi.restoreAllMocks() -}) - -it('logs an exec channel error nothing else handles, naming the target and channel kind', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.execBehavior = 'pending' - const opening = conn.exec('true') - await vi.waitFor(() => expect(sshClientMock.pendingExecCallback).not.toBeNull()) - const channel = Object.assign(new EventEmitter(), { close: vi.fn() }) - sshClientMock.pendingExecCallback?.(undefined, channel) - await opening - - expect(() => channel.emit('error', new Error('channel reset'))).not.toThrow() - expect(warn).toHaveBeenCalledWith( - '[ssh] Unhandled exec channel error for Test Server: channel reset' - ) - await conn.disconnect() -}) - -it('does not log a forwarded channel error its owner handles', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - const channel = new EventEmitter() - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback(undefined, channel as never) - return client - }) - const owner = vi.fn() - conn.forwardStreamLocal(client, '/owned.sock', () => channel.on('error', owner)) - - channel.emit('error', new Error('handled')) - expect(owner).toHaveBeenCalledOnce() - expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('Unhandled')) - await conn.disconnect() -}) diff --git a/src/main/ssh/ssh-connection-channel-lifetime.test.ts b/src/main/ssh/ssh-connection-channel-lifetime.test.ts deleted file mode 100644 index 82371f36880..00000000000 --- a/src/main/ssh/ssh-connection-channel-lifetime.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { trackSshConnectionChannelLifetime } from './ssh-connection-channel-lifetime' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -const signal = () => new AbortController().signal - -it('settles the opening only when the channel emits close', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), channel) - const fence = ledger.fenceForReset() - channel.emit('end') - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - channel.emit('close') - await fence.drain(signal()) - expect(channel.listenerCount('error')).toBe(0) -}) - -it('treats an already closed channel as settled', async () => { - const ledger = new SshConnectionWorkLedger() - trackSshConnectionChannelLifetime( - ledger.beginChannelOpen(), - Object.assign(new EventEmitter(), { closed: true }) - ) - await ledger.fenceForReset().drain(signal()) -}) - -it('keeps a fenced channel error unverifiable even after it closes', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), channel) - const fence = ledger.fenceForReset() - const failure = new Error('channel write failed') - channel.emit('error', failure) - channel.emit('close') - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('does not carry a pre-fence error from a channel that closed before the fence', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), channel) - channel.emit('error', new Error('closed before reset')) - channel.emit('close') - await ledger.fenceForReset().drain(signal()) -}) - -it.each([undefined, null, {}, { on: () => {} }])( - 'marks an untrackable open result (%s) unverifiable instead of settled', - async (value) => { - const ledger = new SshConnectionWorkLedger() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), value) - await expect(ledger.fenceForReset().drain(signal())).rejects.toThrow( - 'ssh_connection_channel_lifetime_unverifiable' - ) - } -) - -it('runs the injected admission check before admitting work', () => { - const ledger = new SshConnectionWorkLedger(undefined, () => { - throw new Error('connection disposed') - }) - expect(() => ledger.beginChannelOpen()).toThrow('connection disposed') -}) - -it('reports a channel error that nothing else handles instead of hiding it', () => { - const report = vi.fn() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime( - new SshConnectionWorkLedger().beginChannelOpen(), - channel, - report - ) - const failure = new Error('channel reset') - expect(() => channel.emit('error', failure)).not.toThrow() - expect(report).toHaveBeenCalledWith(failure) -}) - -it('leaves a channel error to the owner that handles it', () => { - const report = vi.fn() - const channel = new EventEmitter() - trackSshConnectionChannelLifetime( - new SshConnectionWorkLedger().beginChannelOpen(), - channel, - report - ) - const owner = vi.fn() - channel.on('error', owner) - channel.emit('error', new Error('handled')) - expect(owner).toHaveBeenCalledOnce() - expect(report).not.toHaveBeenCalled() -}) - -it('logs an unhandled channel error with the [ssh] prefix when no reporter is given', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const channel = new EventEmitter() - trackSshConnectionChannelLifetime(new SshConnectionWorkLedger().beginChannelOpen(), channel) - channel.emit('error', new Error('orphaned')) - expect(warn).toHaveBeenCalledWith('[ssh] Unhandled SSH channel error: orphaned') - warn.mockRestore() -}) diff --git a/src/main/ssh/ssh-connection-channel-lifetime.ts b/src/main/ssh/ssh-connection-channel-lifetime.ts deleted file mode 100644 index b09a7d288c9..00000000000 --- a/src/main/ssh/ssh-connection-channel-lifetime.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { - SshConnectionWorkChannel, - SshConnectionWorkLedger -} from './ssh-connection-work-ledger' - -type TrackableChannel = SshConnectionWorkChannel & { - closed?: unknown -} - -function isTrackableChannel(value: unknown): value is TrackableChannel { - if (typeof value !== 'object' || value === null) { - return false - } - return ( - 'on' in value && - typeof value.on === 'function' && - 'once' in value && - typeof value.once === 'function' && - 'removeListener' in value && - typeof value.removeListener === 'function' - ) -} - -export type SshChannelErrorReporter = (error: Error) => void - -const reportOrphanChannelError: SshChannelErrorReporter = (error) => { - console.warn(`[ssh] Unhandled SSH channel error: ${error.message}`) -} - -// The tracker's own listener must not be what keeps an error from surfacing. -function hasOnlyTrackerErrorListener(channel: TrackableChannel): boolean { - return ( - !('listenerCount' in channel) || - typeof channel.listenerCount !== 'function' || - channel.listenerCount('error') <= 1 - ) -} - -export function openTrackedSshSocket( - ledger: SshConnectionWorkLedger, - open: () => T, - onUnhandledError?: SshChannelErrorReporter -): T { - const work = ledger.beginChannelOpen() - try { - const socket = open() - trackSshConnectionChannelLifetime(work, socket, onUnhandledError) - return socket - } catch (error) { - work.markUnverifiable(error instanceof Error ? error : new Error(String(error))) - throw error - } -} - -/** Start tracking before the open callback hands the channel to another owner. */ -export function trackSshConnectionChannelLifetime( - work: ReturnType, - value: unknown, - onUnhandledError: SshChannelErrorReporter = reportOrphanChannelError -): void { - if (!isTrackableChannel(value)) { - work.markUnverifiable(new Error('ssh_connection_channel_lifetime_unverifiable')) - return - } - const channel = value - work.bind(channel) - if (channel.closed === true) { - work.close() - return - } - const onError = (error: Error) => { - work.markUnverifiable(error) - if (hasOnlyTrackerErrorListener(channel)) { - onUnhandledError(error) - } - } - channel.on('error', onError) - channel.once('close', () => { - channel.removeListener('error', onError) - work.close() - }) -} diff --git a/src/main/ssh/ssh-connection-channel-open.test.ts b/src/main/ssh/ssh-connection-channel-open.test.ts index 58d4a5633b2..f0f6454af64 100644 --- a/src/main/ssh/ssh-connection-channel-open.test.ts +++ b/src/main/ssh/ssh-connection-channel-open.test.ts @@ -286,7 +286,7 @@ describe('SshConnection', () => { await expect(outcomePromise).resolves.toBe('AbortError') }) - it('removes the abort-grace waiter while retaining physical-close tracking', async () => { + it('removes the late-channel close listener when abort grace expires', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() vi.useFakeTimers() @@ -302,14 +302,12 @@ describe('SshConnection', () => { await vi.advanceTimersByTimeAsync(0) controller.abort() pendingSftpCallback?.(undefined, lateSftp) - expect(lateSftp.listenerCount('close')).toBe(2) + expect(lateSftp.listenerCount('close')).toBe(1) expect(() => lateSftp.emit('error', new Error('late SFTP teardown'))).not.toThrow() await vi.advanceTimersByTimeAsync(5_000) await expect(outcomePromise).resolves.toBe('AbortError') - expect(lateSftp.listenerCount('close')).toBe(1) - lateSftp.emit('close') expect(lateSftp.listenerCount('close')).toBe(0) } finally { vi.useRealTimers() @@ -378,7 +376,7 @@ describe('SshConnection', () => { expect(lateSftp.end).toHaveBeenCalledTimes(1) }) - it('retains SFTP physical-close tracking after the bounded observation expires', async () => { + it('removes the late SFTP close listener when the bounded grace expires', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() ssh2Mock.sftpBehavior = 'pending' @@ -395,13 +393,11 @@ describe('SshConnection', () => { await Promise.resolve() controller.abort() pendingSftpCallback?.(undefined, lateSftp) - expect(lateSftp.listenerCount('close')).toBe(2) + expect(lateSftp.listenerCount('close')).toBe(1) await vi.advanceTimersByTimeAsync(5_000) await expect(outcomePromise).resolves.toBe('AbortError') - expect(lateSftp.listenerCount('close')).toBe(1) - lateSftp.emit('close') expect(lateSftp.listenerCount('close')).toBe(0) expect(lateSftp.end).toHaveBeenCalledTimes(1) } finally { diff --git a/src/main/ssh/ssh-connection-close-drain.ts b/src/main/ssh/ssh-connection-close-drain.ts deleted file mode 100644 index e905a62ceaa..00000000000 --- a/src/main/ssh/ssh-connection-close-drain.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { EventEmitter } from 'node:events' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import type { SshTransportCloseLedger } from './ssh-transport-close-ledger' -import { observeSshTransportClose } from './ssh-transport-close-observation' - -type WorkFence = ReturnType - -/** - * Disconnects, then waits for the live transport resources, every client ever allocated and all - * fenced work to close. Listeners go on before close starts so a fast 'close' is not missed. - */ -export async function disconnectAndAwaitSshTransportClose(options: { - liveResources: readonly EventEmitter[] - disconnect: () => Promise - transportCloseLedger: SshTransportCloseLedger - fence: WorkFence - signal: AbortSignal -}): Promise { - const observation = observeSshTransportClose(options.liveResources) - const waiting = new AbortController() - const waitSignal = AbortSignal.any([options.signal, waiting.signal]) - try { - await options.disconnect() - await Promise.all([ - observation.wait(waitSignal), - options.transportCloseLedger.drain(waitSignal), - options.fence.drain(waitSignal) - ]) - options.fence.assertDrained() - } finally { - waiting.abort() - observation.dispose() - } -} diff --git a/src/main/ssh/ssh-connection-destination-capture.test.ts b/src/main/ssh/ssh-connection-destination-capture.test.ts deleted file mode 100644 index 6f0ce04a808..00000000000 --- a/src/main/ssh/ssh-connection-destination-capture.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createHash } from 'node:crypto' -import { beforeEach, expect, it, vi } from 'vitest' -import { - clientInstances, - nextSshClientCreation, - resetSshConnectionMocks, - resolveWithSshGMock, - ssh2Mock, - VALID_ED25519_HOST_KEY -} from './ssh-connection-test-harness' -import { createCallbacks, createTarget, createResolvedConfig } from './ssh-connection-test-fixtures' -import { SshConnection } from './ssh-connection' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(() => { - resetSshConnectionMocks() -}) - -it('captures only the successful endpoint and accepted key, not the saved alias', async () => { - resolveWithSshGMock.mockResolvedValue( - createResolvedConfig({ - hostname: 'resolved.example', - port: 2222, - user: 'resolved-user', - proxyUseFdpass: false - }) - ) - const conn = new SshConnection(createTarget({ configHost: 'alias' }), createCallbacks()) - expect(conn.getExecutionDestination()).toBeUndefined() - try { - await conn.connect() - const destination = conn.getExecutionDestination() - expect(destination).toEqual({ - version: 1, - transport: 'ssh2', - host: 'resolved.example', - port: 2222, - username: 'resolved-user', - hostKeyFingerprint: `SHA256:${createHash('sha256').update(VALID_ED25519_HOST_KEY).digest('base64').replace(/=+$/, '')}`, - proxyRouteDigest: createHash('sha256').update('null').digest('hex') - }) - expect(Object.isFrozen(destination)).toBe(true) - expect(conn.getExecutionDestination()).toBe(destination) - } finally { - await conn.disconnect() - } - expect(conn.getExecutionDestination()).toBeUndefined() -}) - -it('does not publish an accepted key until authentication succeeds', async () => { - ssh2Mock.connectBehavior = 'pending' - const conn = new SshConnection(createTarget(), createCallbacks()) - const created = nextSshClientCreation() - const connecting = conn.connect() - await created - expect(conn.getExecutionDestination()).toBeUndefined() - const rejected = expect(connecting).rejects.toThrow() - await conn.disconnect() - await rejected - clientInstances[0].emit('ready') - expect(conn.getExecutionDestination()).toBeUndefined() -}) - -it('invalidates identity immediately on transport loss', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - try { - await conn.connect() - expect(conn.getExecutionDestination()).toBeDefined() - clientInstances[0].emit('end') - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) - -it('does not publish an accepted key from failed authentication', async () => { - ssh2Mock.connectBehavior = 'error' - ssh2Mock.connectErrorMessage = 'Authentication failed' - const conn = new SshConnection(createTarget(), createCallbacks()) - try { - await expect(conn.connect()).rejects.toThrow() - expect(ssh2Mock.lastHostKeyAccepted).toBe(true) - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) - -it('never publishes identity from a rejected host key', async () => { - ssh2Mock.presentedHostKey = Buffer.from('invalid host key') - const conn = new SshConnection(createTarget(), createCallbacks()) - try { - await expect(conn.connect()).rejects.toThrow() - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) - -it('does not treat a system SSH probe as command-channel destination evidence', async () => { - const conn = new SshConnection(createTarget({ configHost: 'alias' }), createCallbacks()) - resolveWithSshGMock.mockResolvedValue(createResolvedConfig({ proxyUseFdpass: true })) - try { - await conn.connect() - expect(conn.usesSystemSshTransport()).toBe(true) - expect(conn.getExecutionDestination()).toBeUndefined() - } finally { - await conn.disconnect() - } -}) diff --git a/src/main/ssh/ssh-connection-destination.test.ts b/src/main/ssh/ssh-connection-destination.test.ts deleted file mode 100644 index 7240113e108..00000000000 --- a/src/main/ssh/ssh-connection-destination.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseSshConnectionDestination } from './ssh-connection-destination' - -const destination = { - version: 1, - transport: 'ssh2', - host: 'resolved.example', - port: 2222, - username: 'owner', - hostKeyFingerprint: `SHA256:${Buffer.alloc(32, 255).toString('base64').replace(/=+$/, '')}`, - proxyRouteDigest: 'a'.repeat(64) -} - -describe('parseSshConnectionDestination', () => { - it('makes an immutable canonical snapshot without normalizing identity', () => { - const input = { ...destination, host: 'MixedCase.example', ignored: true } - const saved = parseSshConnectionDestination(input) - input.host = 'another.example' - expect(saved).toEqual({ ...destination, host: 'MixedCase.example' }) - expect(Object.isFrozen(saved)).toBe(true) - expect(parseSshConnectionDestination(JSON.parse(JSON.stringify(saved)))).toEqual(saved) - }) - - it.each([ - { version: 2 }, - { transport: 'system-ssh' }, - { host: '' }, - { host: 'host\nother' }, - { host: 'h'.repeat(8193) }, - { username: '' }, - { username: 'user\0name' }, - { username: 3 }, - { port: 0 }, - { port: 65536 }, - { port: 22.5 }, - { port: '22' }, - { port: Number.NaN }, - { hostKeyFingerprint: 'SHA256:short' }, - { hostKeyFingerprint: `SHA256:${'A'.repeat(43)}=` }, - { hostKeyFingerprint: `SHA256:${'A'.repeat(42)}B` }, - { proxyRouteDigest: 'A'.repeat(64) }, - { proxyRouteDigest: 'a'.repeat(63) }, - { proxyRouteDigest: undefined } - ])('refuses malformed binding %j', (change) => { - expect(() => parseSshConnectionDestination({ ...destination, ...change })).toThrow( - 'ssh_connection_destination_invalid' - ) - }) - - it.each([null, undefined, [], 'host', 1])('refuses nonrecords %j', (value) => { - expect(() => parseSshConnectionDestination(value)).toThrow('ssh_connection_destination_invalid') - }) - - it.each([1, 65535])('accepts port boundary %i', (port) => { - expect(parseSshConnectionDestination({ ...destination, port }).port).toBe(port) - }) -}) diff --git a/src/main/ssh/ssh-connection-destination.ts b/src/main/ssh/ssh-connection-destination.ts deleted file mode 100644 index 49bbbe8545a..00000000000 --- a/src/main/ssh/ssh-connection-destination.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createHash } from 'node:crypto' - -/** The execution host a successful ssh2 handshake proved: endpoint, accepted key and proxy route. */ -export type SshConnectionDestination = Readonly<{ - version: 1 - transport: 'ssh2' - host: string - port: number - username: string - hostKeyFingerprint: string - proxyRouteDigest: string -}> - -/** Digest of the resolved proxy route; a changed ProxyCommand/ProxyJump names a different path. */ -export function sshProxyRouteDigest(effectiveProxy: unknown): string { - return createHash('sha256') - .update(JSON.stringify(effectiveProxy ?? null)) - .digest('hex') -} - -function isValidText(text: unknown): text is string { - return ( - typeof text === 'string' && - text.length > 0 && - text.length <= 8192 && - !Array.from(text).some( - (character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 - ) - ) -} - -function isCanonicalSha256Fingerprint(value: unknown): value is string { - if (typeof value !== 'string' || !/^SHA256:[A-Za-z0-9+/]{43}$/.test(value)) { - return false - } - const encoded = value.slice(7) - // Why: a non-canonical trailing digit decodes to the same bytes but names a different string. - return ( - Buffer.from(encoded, 'base64').toString('base64url') === - encoded.replace(/\+/g, '-').replace(/\//g, '_') - ) -} - -export function parseSshConnectionDestination(value: unknown): SshConnectionDestination { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error('ssh_connection_destination_invalid') - } - const record: Record = { ...value } - const { host, port, username, hostKeyFingerprint, proxyRouteDigest } = record - if ( - record.version !== 1 || - record.transport !== 'ssh2' || - !isValidText(host) || - typeof port !== 'number' || - !Number.isSafeInteger(port) || - port < 1 || - port > 65535 || - !isValidText(username) || - !isCanonicalSha256Fingerprint(hostKeyFingerprint) || - typeof proxyRouteDigest !== 'string' || - !/^[a-f0-9]{64}$/.test(proxyRouteDigest) - ) { - throw new Error('ssh_connection_destination_invalid') - } - return Object.freeze({ - version: 1, - transport: 'ssh2', - host, - port, - username, - hostKeyFingerprint, - proxyRouteDigest - }) -} diff --git a/src/main/ssh/ssh-connection-direct-startup.test.ts b/src/main/ssh/ssh-connection-direct-startup.test.ts index 09bb39e653b..a2344930eea 100644 --- a/src/main/ssh/ssh-connection-direct-startup.test.ts +++ b/src/main/ssh/ssh-connection-direct-startup.test.ts @@ -56,16 +56,12 @@ describe('SshConnection', () => { await connect expect(channel.close).toHaveBeenCalled() expect(channel.listenerCount('data')).toBe(0) - // Lifetime observers remain until physical close, even after the probe timed out. - expect(channel.listenerCount('error')).toBe(2) - expect(channel.listenerCount('close')).toBe(2) + expect(channel.listenerCount('error')).toBe(1) + expect(channel.listenerCount('close')).toBe(1) expect(channel.stderr.listenerCount('data')).toBe(0) expect( (conn as unknown as { systemCommandChannels: Set }).systemCommandChannels.size ).toBe(0) - channel.emit('close', 0) - expect(channel.listenerCount('close')).toBe(0) - expect(channel.listenerCount('error')).toBe(1) } finally { vi.useRealTimers() } diff --git a/src/main/ssh/ssh-connection-disconnect-drain.test.ts b/src/main/ssh/ssh-connection-disconnect-drain.test.ts deleted file mode 100644 index 4b04eae0e9a..00000000000 --- a/src/main/ssh/ssh-connection-disconnect-drain.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { SshConnectionManager } from './ssh-connection-manager' -import { assertManagerTargetTransportsClosed } from './ssh-connection-manager-test-probes' -import { createCallbacks, createResolvedConfig, createTarget } from './ssh-connection-test-fixtures' -import { clientInstances, resetSshConnectionMocks, ssh2Mock } from './ssh-connection-test-harness' -import { resolveWithSshG } from './ssh-config-parser' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) -beforeEach(resetSshConnectionMocks) -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() -}) - -async function fixture(conn = new SshConnection(createTarget(), createCallbacks())) { - await conn.connect() - const client = conn.getClient()! - // The shared SSH mock omits once/removeListener; retain its lifecycle handlers alongside real events. - const observed = new EventEmitter() - const emit = client.emit.bind(client) - Object.assign(client, { - once: observed.once.bind(observed), - removeListener: observed.removeListener.bind(observed), - listenerCount: observed.listenerCount.bind(observed), - emit: (event: string, ...args: unknown[]) => { - emit(event, ...args) - return observed.emit(event, ...args) - } - }) - const end = vi.spyOn(client, 'end').mockImplementation(() => client) - const run = (signal = new AbortController().signal) => conn.disconnectAndDrain(signal) - const forward = () => { - const channel = new EventEmitter() - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback(undefined, channel as never) - return client - }) - conn.forwardStreamLocal(client, '/owned.sock', vi.fn()) - return channel - } - return { conn, client, end, run, forward } -} - -it('waits for failed startup sockets even after another attempt connects successfully', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - ssh2Mock.connectSequence = [new Error('startup refused')] - ssh2Mock.destroyErrorMessage = 'socket still closing' - await expect(conn.connect()).rejects.toThrow('startup refused') - const failed = clientInstances[0]! - ssh2Mock.destroyErrorMessage = '' - const f = await fixture(conn) - const done = vi.fn() - const draining = f.run().then(done) - f.client.emit('close') - // Let all resolved drain promises settle; the failed socket remains open. - await new Promise((resolve) => setImmediate(resolve)) - expect(done).not.toHaveBeenCalled() - failed.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() -}) - -it('drains a settled failed exclusive startup only after its socket physically closes', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - ssh2Mock.connectSequence = [new Error('startup refused')] - ssh2Mock.destroyErrorMessage = 'socket still closing' - await expect(conn.connect()).rejects.toThrow('startup refused') - const failed = clientInstances[0]! - const done = vi.fn() - const draining = conn.disconnectAndDrain(new AbortController().signal).then(done) - await new Promise((resolve) => setImmediate(resolve)) - expect(done).not.toHaveBeenCalled() - failed.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() - await expect(conn.connect()).rejects.toThrow('ssh_connection_reset_replacement_refused') -}) - -it('remembers failed socket closure across an early ordinary disconnect', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - ssh2Mock.connectSequence = [new Error('startup refused')] - await expect(conn.connect()).rejects.toThrow('startup refused') - await conn.disconnect() - await conn.disconnectAndDrain(new AbortController().signal) -}) - -it('allows a fenced exclusive connection with no allocations to finish cleanup', async () => { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - await conn.disconnectAndDrain(new AbortController().signal) - await expect(conn.connect()).rejects.toThrow('ssh_connection_reset_replacement_refused') -}) - -it('refuses drain while configuration can still allocate a startup transport', async () => { - const resolved = Promise.withResolvers() - vi.mocked(resolveWithSshG).mockReturnValueOnce(resolved.promise) - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - const connecting = conn.connect().catch((error: unknown) => error) - await expect(conn.disconnectAndDrain(new AbortController().signal)).rejects.toThrow( - 'ssh_connection_close_transport_unproven' - ) - resolved.resolve(null) - expect(await connecting).toBeInstanceOf(Error) - await conn.disconnectAndDrain(new AbortController().signal) - expect(clientInstances).toHaveLength(0) -}) - -it('does not treat logical disconnection as physical client closure', async () => { - const f = await fixture() - const done = vi.fn() - const draining = f.run().then(done) - await Promise.resolve() - expect(f.end).toHaveBeenCalledOnce() - expect(f.conn.getState().status).toBe('disconnected') - expect(done).not.toHaveBeenCalled() - f.client.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() -}) - -it.each(['client', 'channel'] as const)( - 'requires both physical client and channel close when %s closes first', - async (first) => { - const f = await fixture() - const channel = f.forward() - const done = vi.fn() - const draining = f.run().then(done) - ;(first === 'client' ? f.client : channel).emit('close') - await Promise.resolve() - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - ;(first === 'client' ? channel : f.client).emit('close') - await draining - expect(done).toHaveBeenCalledOnce() - } -) - -it('subscribes before disconnect can synchronously close the client', async () => { - const f = await fixture() - f.end.mockImplementation(() => { - f.client.emit('close') - return f.client - }) - await f.run() - expect(f.end).toHaveBeenCalledOnce() -}) - -it('aborts waiting for physical close and removes its listener', async () => { - const f = await fixture() - const before = f.client.listenerCount('close') - const controller = new AbortController() - const draining = f.run(controller.signal) - expect(f.client.listenerCount('close')).toBeGreaterThan(before) - controller.abort() - await expect(draining).rejects.toThrow() - expect(f.client.listenerCount('close')).toBe(before) - expect(f.conn.getClient()).toBeNull() -}) - -it('pre-aborted admission does not close the connection', async () => { - const f = await fixture() - await expect(f.run(AbortSignal.abort())).rejects.toThrow() - expect(f.end).not.toHaveBeenCalled() - await f.conn.disconnect() -}) - -it('aborts unresolved channel drain even after the physical client closes', async () => { - const f = await fixture() - const channel = f.forward() - const controller = new AbortController() - const draining = f.run(controller.signal) - f.client.emit('close') - controller.abort() - await expect(draining).rejects.toThrow() - expect(f.client.listenerCount('close')).toBe(0) - channel.emit('close') -}) - -it('cleans owned overlapping clients but refuses unproven drain', async () => { - const f = await fixture() - const pending = { end: vi.fn(), destroy: vi.fn() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads SshConnection's own private pending-client set. - const clients = (f.conn as unknown as { pendingSsh2Clients: Set }).pendingSsh2Clients - clients.add(pending) - await expect(f.run()).rejects.toThrow() - expect(f.end).toHaveBeenCalledOnce() - expect(pending.destroy).toHaveBeenCalledOnce() - clients.delete(pending) - await f.conn.disconnect() -}) - -it('waits for the owned proxy close as well as client close', async () => { - const f = await fixture() - const proxy = Object.assign(new EventEmitter(), { kill: vi.fn() }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: installs a stub proxy process into SshConnection's private field. - ;(f.conn as unknown as { proxyProcess: typeof proxy }).proxyProcess = proxy - const done = vi.fn() - const draining = f.run().then(done) - expect(proxy.kill).toHaveBeenCalledOnce() - f.client.emit('close') - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - proxy.emit('close') - await draining -}) - -it('never schedules reconnect when a drain closes its client', async () => { - const f = await fixture() - vi.useFakeTimers() - const draining = f.run() - f.client.emit('close') - await draining - await vi.advanceTimersByTimeAsync(60_000) - expect(f.conn.getClient()).toBeNull() - expect(f.conn.getState().reconnectAttempt).toBe(0) - expect(vi.getTimerCount()).toBe(0) -}) - -it('refuses a disconnected connection without claiming drain proof', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await expect(conn.disconnectAndDrain(new AbortController().signal)).rejects.toThrow() -}) - -it('retains ordinary manager transport debt after pool removal until physical close', async () => { - const manager = new SshConnectionManager(createCallbacks()) - const target = createTarget() - const conn = await manager.connect(target) - const client = conn.getClient()! - await manager.disconnect(target.id) - expect(manager.getConnection(target.id)).toBeUndefined() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - client.emit('close') - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('cleans failed ordinary startup without mistaking destroy for physical close', async () => { - const manager = new SshConnectionManager(createCallbacks()) - const target = createTarget() - ssh2Mock.connectSequence = [new Error('startup refused')] - ssh2Mock.destroyErrorMessage = 'socket still closing' - await expect(manager.connect(target)).rejects.toThrow('startup refused') - expect(manager.getConnection(target.id)).toBeUndefined() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - clientInstances[0]!.emit('close') - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('closes owned system SSH but refuses unproven drain', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - await conn.connect() - await expect(conn.disconnectAndDrain(new AbortController().signal)).rejects.toThrow() - expect(conn.getState().status).toBe('disconnected') - await conn.disconnect() -}) diff --git a/src/main/ssh/ssh-connection-file-transfer.test.ts b/src/main/ssh/ssh-connection-file-transfer.test.ts index 610d24c08d9..0d2e830d6bb 100644 --- a/src/main/ssh/ssh-connection-file-transfer.test.ts +++ b/src/main/ssh/ssh-connection-file-transfer.test.ts @@ -182,6 +182,11 @@ describe('SshConnection', () => { await expect( uploadSession.uploadFile('/tmp/late.txt', '/remote/late.txt') ).rejects.toMatchObject({ name: 'AbortError' }) - expect(uploadFileViaSystemSsh).not.toHaveBeenCalled() + expect(uploadFileViaSystemSsh).toHaveBeenCalledWith( + expect.anything(), + '/tmp/late.txt', + '/remote/late.txt', + expect.objectContaining({ signal: expect.objectContaining({ aborted: true }) }) + ) }) }) diff --git a/src/main/ssh/ssh-connection-file-transfers.ts b/src/main/ssh/ssh-connection-file-transfers.ts deleted file mode 100644 index c9cc36dcdd7..00000000000 --- a/src/main/ssh/ssh-connection-file-transfers.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { SFTPWrapper } from 'ssh2' -import type { SshTarget } from '../../shared/ssh-types' -import type { FileUploadSession } from '../providers/types' -import { - resolveSftpTransferPathIfMapped, - type SftpNamespacePathMapping -} from './sftp-namespace-resolution' -import { - createLinkedSshFileTransferSignal, - raceSftpFileTransferWithAbort -} from './ssh-file-transfer-abort' -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { - downloadFileViaSystemSsh, - uploadDirectoryViaSystemSsh, - uploadFileViaSystemSsh, - writeBufferViaSystemSsh, - writeFileViaSystemSsh, - type SystemSshBuildArgsOptions -} from './ssh-system-fallback' - -export type SshRemoteFileOptions = { - hostPlatform?: RemoteHostPlatform - // Only uploadDirectory and writeFile honor this, and only on the non-Windows ssh2 branch. - sftpNamespace?: SftpNamespacePathMapping -} - -/** What a transfer reads from its connection; getters so each read sees the live transport. */ -export type SshFileTransferHost = { - target: SshTarget - usesSystemSshTransport: () => boolean - systemOperationSignal: () => AbortSignal - systemSshBuildArgsOptions: () => SystemSshBuildArgsOptions - sftp: (signal?: AbortSignal) => Promise -} - -export async function uploadSshDirectory( - host: SshFileTransferHost, - localDir: string, - remoteDir: string, - options?: SshRemoteFileOptions & { signal?: AbortSignal } -): Promise { - // Why: relay-deploy timeout and connection teardown are independent owners; either must stop a transfer that could outlive its lock. - const linkedSignal = createLinkedSshFileTransferSignal( - [host.systemOperationSignal(), options?.signal].filter( - (signal): signal is AbortSignal => signal !== undefined - ) - ) - try { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp(linkedSignal.signal) - const swallowLateSftpError = (): void => {} - let sftpEndRequested = false - const endSftp = (): void => { - if (!sftpEndRequested) { - sftpEndRequested = true - sftp.end() - } - } - sftp.on('error', swallowLateSftpError) - sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) - try { - // Why: resolve on the same session that transfers — a later session is not authoritative for this one's namespace. - const transfer = (async (): Promise => { - const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options) - linkedSignal.signal.throwIfAborted() - const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') - await uploadDirectory(sftp, localDir, targetDir, localDir, { - signal: linkedSignal.signal - }) - })() - await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => { - sftp.once('close', onClose) - endSftp() - return () => sftp.removeListener('close', onClose) - }) - } finally { - endSftp() - } - return - } - await uploadDirectoryViaSystemSsh(host.target, localDir, remoteDir, { - signal: linkedSignal.signal, - hostPlatform: options?.hostPlatform, - ...host.systemSshBuildArgsOptions() - }) - } finally { - linkedSignal.dispose() - } -} - -export async function downloadSshFile( - host: SshFileTransferHost, - remotePath: string, - localPath: string, - options?: SshRemoteFileOptions -): Promise { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp() - try { - const { fastGetViaSftp } = await import('../providers/ssh-filesystem-provider-sftp') - await fastGetViaSftp(sftp, remotePath, localPath) - } finally { - sftp.end() - } - return - } - await downloadFileViaSystemSsh(host.target, remotePath, localPath, { - signal: host.systemOperationSignal(), - hostPlatform: options?.hostPlatform, - ...host.systemSshBuildArgsOptions() - }) -} - -export async function createSshFileUploadSession( - host: SshFileTransferHost, - options?: SshRemoteFileOptions -): Promise { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp() - const { uploadFile } = await import('./sftp-upload') - return { - uploadFile: (localPath, remotePath, uploadOptions) => - uploadFile(sftp, localPath, remotePath, uploadOptions), - close: () => sftp.end() - } - } - // Why: disconnect replaces the connection controller, so an existing import session must stay bound to the signal and SSH config it opened with. - const signal = host.systemOperationSignal() - const buildArgsOptions = host.systemSshBuildArgsOptions() - return { - uploadFile: (localPath, remotePath, uploadOptions) => - uploadFileViaSystemSsh(host.target, localPath, remotePath, { - signal, - hostPlatform: options?.hostPlatform, - exclusive: uploadOptions?.exclusive, - ...buildArgsOptions - }), - close: () => {} - } -} - -export async function writeSshFile( - host: SshFileTransferHost, - remotePath: string, - contents: string, - options?: SshRemoteFileOptions & { signal?: AbortSignal } -): Promise { - // Keep package/version writes under the same dual cancellation contract as uploads. - const linkedSignal = createLinkedSshFileTransferSignal( - [host.systemOperationSignal(), options?.signal].filter( - (signal): signal is AbortSignal => signal !== undefined - ) - ) - try { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp(linkedSignal.signal) - const swallowLateSftpError = (): void => {} - let sftpEndRequested = false - const endSftp = (): void => { - if (!sftpEndRequested) { - sftpEndRequested = true - sftp.end() - } - } - sftp.on('error', swallowLateSftpError) - sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) - try { - // Why: resolve on the same session that writes — a later session is not authoritative for this one's namespace. - const write = (async (): Promise => { - const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options) - linkedSignal.signal.throwIfAborted() - const { writeStringViaSftp } = await import('./sftp-upload') - await writeStringViaSftp(sftp, targetPath, contents) - })() - await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { - sftp.once('close', onClose) - endSftp() - return () => sftp.removeListener('close', onClose) - }) - } finally { - endSftp() - } - return - } - await writeFileViaSystemSsh(host.target, remotePath, contents, { - signal: linkedSignal.signal, - hostPlatform: options?.hostPlatform, - ...host.systemSshBuildArgsOptions() - }) - } finally { - linkedSignal.dispose() - } -} - -export async function writeSshBuffer( - host: SshFileTransferHost, - remotePath: string, - contents: Buffer, - options?: SshRemoteFileOptions & { append?: boolean; exclusive?: boolean } -): Promise { - if (!host.usesSystemSshTransport()) { - const sftp = await host.sftp() - try { - const { uploadBuffer } = await import('./sftp-upload') - await uploadBuffer(sftp, contents, remotePath, options) - } finally { - sftp.end() - } - return - } - await writeBufferViaSystemSsh(host.target, remotePath, contents, { - signal: host.systemOperationSignal(), - hostPlatform: options?.hostPlatform, - append: options?.append, - exclusive: options?.exclusive, - ...host.systemSshBuildArgsOptions() - }) -} diff --git a/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts b/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts index 68d3b6ec19a..9dd1e6d22a2 100644 --- a/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts +++ b/src/main/ssh/ssh-connection-host-key-store-wiring.test.ts @@ -16,7 +16,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' // Type-only, so it is erased before vi.mock's hoisted factory runs. import type * as SshConfigParser from './ssh-config-parser' -import type * as Ssh2 from 'ssh2' const VALID_ED25519_HOST_KEY = Buffer.from( 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', @@ -32,8 +31,8 @@ let eventHandlers: Map void>> let presentedHostKey: Buffer let hostKeyAccepted: boolean | undefined -vi.mock('ssh2', async (importOriginal) => { - const { utils } = await importOriginal() +vi.mock('ssh2', () => { + const utils = { parseKey: vi.fn(() => new Error('parse failed')) } class MockSshClient { setNoDelay = vi.fn() _sock: Socket | undefined = new Socket() diff --git a/src/main/ssh/ssh-connection-manager-closure.test.ts b/src/main/ssh/ssh-connection-manager-closure.test.ts deleted file mode 100644 index 79e456b1653..00000000000 --- a/src/main/ssh/ssh-connection-manager-closure.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - assertManagerTargetTransportsClosed, - managerTargetActive, - managerUnclosedTransportTargets -} from './ssh-connection-manager-test-probes' -import { beforeEach, expect, it, vi } from 'vitest' -import type { SshTarget } from '../../shared/ssh-types' - -type ClosureMockState = { - clients: { close: () => void; status: string }[] - connectError?: Error - disconnectError?: Error -} - -const state = vi.hoisted((): ClosureMockState => ({ clients: [] })) -vi.mock('./ssh-connection', () => ({ - SshConnection: class { - status = 'connected' - close = () => {} - constructor() { - state.clients.push(this) - } - subscribeTransportClosure(callback: () => void) { - this.close = callback - return () => {} - } - async connect() { - if (state.connectError) { - throw state.connectError - } - } - async disconnect() { - if (state.disconnectError) { - throw state.disconnectError - } - this.status = 'disconnected' - } - async disconnectAndDrain() { - await this.disconnect() - this.close() - } - getState() { - return { status: this.status } - } - setCallbacks() {} - } -})) -import { SshConnectionManager } from './ssh-connection-manager' - -const target: SshTarget = { - id: 'owned', - label: 'Owned', - host: 'example.test', - port: 22, - username: 'deploy' -} -beforeEach(() => { - state.clients.length = 0 - state.connectError = undefined - state.disconnectError = undefined -}) - -it('retains failed startup transport evidence until its disposed connection physically closes', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - state.connectError = new Error('authentication rejected') - await expect(manager.connect(target)).rejects.toThrow('authentication rejected') - expect(state.clients[0].status).toBe('disconnected') - expect(manager.getConnection(target.id)).toBeUndefined() - expect(managerTargetActive(manager, target.id)).toBe(false) - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - state.clients[0].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('requires every allocation and ignores duplicate close notifications', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - await manager.connect(target) - await manager.disconnect(target.id) - await manager.connect(target) - await manager.disconnect(target.id) - state.clients[0].close() - state.clients[0].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow('closure_unproven') - state.clients[1].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() -}) - -it('does not let an old close remove a replacement or taint an unrelated target', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - await manager.connect(target) - await manager.disconnect(target.id) - const replacement = await manager.connect(target) - state.clients[0].close() - expect(manager.getConnection(target.id)).toBe(replacement) - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).toThrow() - expect(() => assertManagerTargetTransportsClosed(manager, 'unrelated')).not.toThrow() -}) - -it('releases bookkeeping after repeated observed closure', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - for (let index = 0; index < 100; index++) { - await manager.connect(target) - await manager.disconnect(target.id) - state.clients[index].close() - expect(() => assertManagerTargetTransportsClosed(manager, target.id)).not.toThrow() - } - expect(managerUnclosedTransportTargets(manager)).toBe(0) -}) diff --git a/src/main/ssh/ssh-connection-manager-registry.test.ts b/src/main/ssh/ssh-connection-manager-registry.test.ts index a7b2b06e805..e6a6bc24843 100644 --- a/src/main/ssh/ssh-connection-manager-registry.test.ts +++ b/src/main/ssh/ssh-connection-manager-registry.test.ts @@ -88,57 +88,4 @@ describe('SshConnectionManager', () => { expect(mgr.getConnection('a')).toBeUndefined() expect(mgr.getConnection('b')).toBeUndefined() }) - - it('disconnectAll preserves excluded reset connections in the registry', async () => { - const mgr = new SshConnectionManager(createCallbacks()) - const retained = await mgr.connect(createTarget({ id: 'reset' })) - await mgr.connect(createTarget({ id: 'ordinary' })) - const disconnect = vi.spyOn(retained, 'disconnect') - await mgr.disconnectAll((id) => id !== 'reset') - expect(disconnect).not.toHaveBeenCalled() - expect(mgr.getConnection('reset')).toBe(retained) - expect(mgr.getConnection('ordinary')).toBeUndefined() - await mgr.disconnectAll() - }) - - it('checks disconnect admission immediately before each transport effect', async () => { - const mgr = new SshConnectionManager(createCallbacks()) - const first = await mgr.connect(createTarget({ id: 'first' })) - const second = await mgr.connect(createTarget({ id: 'second' })) - const disconnectSecond = vi.spyOn(second, 'disconnect') - let reserved = false - const originalDisconnect = first.disconnect.bind(first) - vi.spyOn(first, 'disconnect').mockImplementation(() => { - reserved = true - return originalDisconnect() - }) - await mgr.disconnectAll((id) => id !== 'second' || !reserved) - expect(disconnectSecond).not.toHaveBeenCalled() - expect(mgr.getConnection('second')).toBe(second) - await mgr.disconnectAll() - }) - - it('does not erase a replacement registered while an admitted disconnect settles', async () => { - const mgr = new SshConnectionManager(createCallbacks()) - const target = createTarget({ id: 'target' }) - const old = await mgr.connect(target) - const state = old.getState() - vi.spyOn(old, 'getState').mockReturnValue({ ...state, status: 'disconnected' }) - let finish!: () => void - vi.spyOn(old, 'disconnect') - .mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - .mockResolvedValue(undefined) - const drain = mgr.disconnectAll() - const replacement = await mgr.connect(target) - expect(replacement).not.toBe(old) - finish() - await drain - expect(mgr.getConnection('target')).toBe(replacement) - await mgr.disconnectAll() - }) }) diff --git a/src/main/ssh/ssh-connection-manager-test-probes.ts b/src/main/ssh/ssh-connection-manager-test-probes.ts deleted file mode 100644 index 3c85210d596..00000000000 --- a/src/main/ssh/ssh-connection-manager-test-probes.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { SshConnectionManager } from './ssh-connection-manager' - -type ManagerBookkeeping = { - connections: Map - connectingTargets: Map - pendingTargetOperations: Map - unconfirmedTargetTeardowns: Set - unclosedTransportsByTarget: Map -} - -function bookkeeping(manager: SshConnectionManager): ManagerBookkeeping { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are SshConnectionManager's own private fields; tests read them until T3/T8 expose readers. - return manager as unknown as ManagerBookkeeping -} - -/** Test-side reading of the target activity T3's hasTargetActivity will expose. */ -export function managerTargetActive(manager: SshConnectionManager, targetId: string): boolean { - const state = bookkeeping(manager) - return ( - state.connections.has(targetId) || - state.connectingTargets.has(targetId) || - state.unconfirmedTargetTeardowns.has(targetId) || - state.pendingTargetOperations.has(targetId) - ) -} - -/** Test-side reading of the closure proof T8's assertTargetTransportsClosed will expose. */ -export function assertManagerTargetTransportsClosed( - manager: SshConnectionManager, - targetId: string -): void { - if ( - managerTargetActive(manager, targetId) || - bookkeeping(manager).unclosedTransportsByTarget.has(targetId) - ) { - throw new Error('ssh_target_transport_closure_unproven') - } -} - -/** How many targets still owe a physical transport close. */ -export function managerUnclosedTransportTargets(manager: SshConnectionManager): number { - return bookkeeping(manager).unclosedTransportsByTarget.size -} diff --git a/src/main/ssh/ssh-connection-manager.test.ts b/src/main/ssh/ssh-connection-manager.test.ts index b5c61574fec..b2de497a006 100644 --- a/src/main/ssh/ssh-connection-manager.test.ts +++ b/src/main/ssh/ssh-connection-manager.test.ts @@ -1,4 +1,3 @@ -import { managerTargetActive } from './ssh-connection-manager-test-probes' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SshTarget } from '../../shared/ssh-types' @@ -13,7 +12,6 @@ const mockState = vi.hoisted(() => ({ vi.mock('./ssh-connection', () => ({ SshConnection: class MockSshConnection { - subscribeTransportClosure = vi.fn(() => () => {}) status: 'connecting' | 'connected' | 'disconnected' = 'connecting' connect = vi.fn(async () => { await (mockState.connectResults.shift() ?? Promise.resolve()) @@ -22,7 +20,6 @@ vi.mock('./ssh-connection', () => ({ disconnect = vi.fn(async () => { this.status = 'disconnected' }) - reconnect = vi.fn(async () => {}) constructor() { mockState.instances.push(this) @@ -48,53 +45,6 @@ const target = { } as SshTarget describe('SshConnectionManager', () => { - it('reports invalidated pending attempts even after disconnect removes the registration', async () => { - let reject!: (error: Error) => void - mockState.connectResults.push( - new Promise((_resolve, fail) => { - reject = fail - }) - ) - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - const pending = manager.connect(target) - const rejected = expect(pending).rejects.toThrow('cancelled') - await manager.disconnect(target.id) - expect(manager.getConnection(target.id)).toBeUndefined() - expect(managerTargetActive(manager, target.id)).toBe(true) - expect(managerTargetActive(manager, 'unrelated')).toBe(false) - reject(new Error('cancelled')) - await rejected - expect(managerTargetActive(manager, target.id)).toBe(false) - }) - - it('tracks detached exact-connection teardown until its promise settles', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - const conn = await manager.connect(target) - await manager.disconnect(target.id) - let finish!: () => void - mockState.instances[0].disconnect.mockImplementationOnce( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const pending = manager.disconnectConnection(target.id, conn) - expect(managerTargetActive(manager, target.id)).toBe(true) - finish() - await pending - expect(managerTargetActive(manager, target.id)).toBe(false) - }) - - it('retains uncertainty after bulk teardown fails and removes the registration', async () => { - const manager = new SshConnectionManager({ onStateChange: vi.fn() }) - await manager.connect(target) - mockState.instances[0].disconnect.mockRejectedValueOnce(new Error('close unconfirmed')) - await manager.disconnectAll() - expect(manager.getConnection(target.id)).toBeUndefined() - expect(managerTargetActive(manager, target.id)).toBe(true) - expect(managerTargetActive(manager, 'unrelated')).toBe(false) - }) - beforeEach(() => { mockState.connectResults.length = 0 mockState.instances.length = 0 diff --git a/src/main/ssh/ssh-connection-manager.ts b/src/main/ssh/ssh-connection-manager.ts index aad5bdde561..e553bf906b5 100644 --- a/src/main/ssh/ssh-connection-manager.ts +++ b/src/main/ssh/ssh-connection-manager.ts @@ -6,46 +6,17 @@ import { SshConnection, type SshConnectionCallbacks } from './ssh-connection' // 300-line oxlint max-lines threshold while preserving a clear // single-responsibility boundary (connection lifecycle vs. pool management). -// Bounds an owned drain so a host that never confirms close cannot hang its caller. -const OWNED_CONNECTION_DRAIN_TIMEOUT_MS = 10_000 - export class SshConnectionManager { private connections = new Map() private callbacks: SshConnectionCallbacks // Why: attempt identity lets disconnect unblock a replacement without the // cancelled attempt later clearing the replacement's state. private connectingTargets = new Map() - // Local operation and teardown bookkeeping; never a verdict on remote process exit. - private pendingTargetOperations = new Map() - private unconfirmedTargetTeardowns = new Set() - // Counts every connection a target allocated, retired pool entries included, until it closes. - private readonly unclosedTransportsByTarget = new Map() constructor(callbacks: SshConnectionCallbacks) { this.callbacks = callbacks } - private registerConnection(targetId: string, connection: SshConnection): void { - this.unclosedTransportsByTarget.set( - targetId, - (this.unclosedTransportsByTarget.get(targetId) ?? 0) + 1 - ) - let observed = false - connection.subscribeTransportClosure(() => { - if (observed) { - return - } - observed = true - const remaining = (this.unclosedTransportsByTarget.get(targetId) ?? 1) - 1 - if (remaining === 0) { - this.unclosedTransportsByTarget.delete(targetId) - } else { - this.unclosedTransportsByTarget.set(targetId, remaining) - } - }) - this.connections.set(targetId, connection) - } - setCallbacks(callbacks: SshConnectionCallbacks): void { this.callbacks = callbacks for (const connection of this.connections.values()) { @@ -54,10 +25,6 @@ export class SshConnectionManager { } async connect(target: SshTarget): Promise { - return this.trackTargetOperation(target.id, () => this.connectTarget(target)) - } - - private async connectTarget(target: SshTarget): Promise { const existing = this.connections.get(target.id) if (existing?.getState().status === 'connected') { return existing @@ -76,16 +43,13 @@ export class SshConnectionManager { } const conn = new SshConnection(target, this.callbacks) - this.registerConnection(target.id, conn) + this.connections.set(target.id, conn) try { await conn.connect() } catch (err) { - // Why: a failed startup can still hold sockets, so it is disconnected, not just forgotten. - try { - await this.disconnectConnection(target.id, conn) - } catch (cleanupError) { - throw new AggregateError([err, cleanupError], 'ssh_connection_startup_cleanup_failed') + if (this.connections.get(target.id) === conn) { + this.connections.delete(target.id) } throw err } @@ -99,29 +63,6 @@ export class SshConnectionManager { } async disconnect(targetId: string): Promise { - return this.trackTargetOperation(targetId, () => this.disconnectTarget(targetId), true) - } - - /** Drains only the registered connection for this target; local closure is not remote exit. */ - async disconnectAndDrain(targetId: string, signal: AbortSignal): Promise { - return this.trackTargetOperation( - targetId, - async () => { - this.connectingTargets.delete(targetId) - const conn = this.connections.get(targetId) - if (!conn) { - return - } - await conn.disconnectAndDrain(signal) - if (this.connections.get(targetId) === conn) { - this.connections.delete(targetId) - } - }, - true - ) - } - - private async disconnectTarget(targetId: string): Promise { // Why: disconnect invalidates the old attempt immediately so a reconnect // need not wait for the cancelled socket's late completion. this.connectingTargets.delete(targetId) @@ -140,19 +81,11 @@ export class SshConnectionManager { * Why: a cancelled connect whose transport opened late owns that exact connection — disconnecting * by target id would tear down the replacement's live transport instead. */ - async disconnectConnection(targetId: string, conn: SshConnection, drain = false): Promise { - return this.trackTargetOperation( - targetId, - async () => { - await (drain - ? conn.disconnectAndDrain(AbortSignal.timeout(OWNED_CONNECTION_DRAIN_TIMEOUT_MS)) - : conn.disconnect()) - if (this.connections.get(targetId) === conn) { - this.connections.delete(targetId) - } - }, - true - ) + async disconnectConnection(targetId: string, conn: SshConnection): Promise { + await conn.disconnect() + if (this.connections.get(targetId) === conn) { + this.connections.delete(targetId) + } } async reconnect(targetId: string): Promise { @@ -160,33 +93,7 @@ export class SshConnectionManager { if (!conn) { return } - await this.trackTargetOperation(targetId, () => conn.reconnect()) - } - - private async trackTargetOperation( - targetId: string, - operation: () => Promise, - retainFailure = false - ): Promise { - this.pendingTargetOperations.set( - targetId, - (this.pendingTargetOperations.get(targetId) ?? 0) + 1 - ) - try { - return await operation() - } catch (error) { - if (retainFailure) { - this.unconfirmedTargetTeardowns.add(targetId) - } - throw error - } finally { - const remaining = (this.pendingTargetOperations.get(targetId) ?? 1) - 1 - if (remaining === 0) { - this.pendingTargetOperations.delete(targetId) - } else { - this.pendingTargetOperations.set(targetId, remaining) - } - } + await conn.reconnect() } getConnection(targetId: string): SshConnection | undefined { @@ -205,27 +112,9 @@ export class SshConnectionManager { return states } - async disconnectAll(shouldDisconnect: (targetId: string) => boolean = () => true): Promise { - await Promise.allSettled( - Array.from(this.connections).map(async ([targetId, connection]) => { - if (!shouldDisconnect(targetId)) { - return - } - await this.trackTargetOperation( - targetId, - async () => { - try { - await connection.disconnect() - } finally { - // A later registration or an excluded target is not this drain's to remove. - if (this.connections.get(targetId) === connection) { - this.connections.delete(targetId) - } - } - }, - true - ) - }) - ) + async disconnectAll(): Promise { + const disconnects = Array.from(this.connections.values()).map((c) => c.disconnect()) + await Promise.allSettled(disconnects) + this.connections.clear() } } diff --git a/src/main/ssh/ssh-connection-reconnect-ladder.test.ts b/src/main/ssh/ssh-connection-reconnect-ladder.test.ts index 42d61b4d92d..d62856611ba 100644 --- a/src/main/ssh/ssh-connection-reconnect-ladder.test.ts +++ b/src/main/ssh/ssh-connection-reconnect-ladder.test.ts @@ -256,41 +256,6 @@ describe('SshConnection', () => { } }) - it('recovers the same owner after a temporary DNS resolution failure', async () => { - vi.useFakeTimers() - try { - const states: string[] = [] - const conn = new SshConnection( - createTarget(), - createCallbacks({ - onStateChange: vi.fn((_id, state) => states.push(state.status)) - }) - ) - await connectWithFakeTimers(conn) - const connectedGeneration = conn.getTransportGeneration() - const dnsFailure = Object.assign( - new Error('getaddrinfo EAI_AGAIN temporary failure in name resolution'), - { code: 'EAI_AGAIN' } - ) - ssh2Mock.connectSequence = [dnsFailure, 'ready'] - - emitSshEvent('close') - await advanceToNextSshClient(RECONNECT_BACKOFF_MS[0]) - - expect(conn.getState().status).toBe('reconnecting') - expect(states).not.toContain('error') - expect(states).not.toContain('reconnection-failed') - - await advanceToNextSshClient(RECONNECT_BACKOFF_MS[1]) - - expect(conn.getState().status).toBe('connected') - expect(conn.getTransportGeneration()).toBeGreaterThan(connectedGeneration) - expect(clientInstances).toHaveLength(3) - } finally { - vi.useRealTimers() - } - }) - it('keeps a system-transport target on the ladder after a probe timeout', async () => { vi.useFakeTimers() try { diff --git a/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts b/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts deleted file mode 100644 index 261c4288aa1..00000000000 --- a/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner' -import { isRuntimeOwnedSshTarget, SshConnectionStore } from './ssh-connection-store' -import { createMockStore } from './ssh-connection-store-test-fixture' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ - loadUserSshConfigMock: vi.fn(), - sshConfigHostsToTargetsMock: vi.fn() -})) - -vi.mock('./ssh-config-parser', () => ({ - loadUserSshConfig: loadUserSshConfigMock, - sshConfigHostsToTargets: sshConfigHostsToTargetsMock -})) - -const base = { label: 'cluster', host: 'cluster.example.com', port: 22, username: 'dev' } - -describe('managed orcad ownership of SSH targets', () => { - let mockStore: ReturnType - let sshStore: SshConnectionStore - - beforeEach(() => { - mockStore = Object.assign(createMockStore(), emptyDependentStateStore()) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements the store methods SshConnectionStore calls. - sshStore = new SshConnectionStore(mockStore as never) - loadUserSshConfigMock.mockReset() - sshConfigHostsToTargetsMock.mockReset() - }) - - it('hides claimed and provisioning targets from direct SSH lists', () => { - const visible = sshStore.addTarget(base) - sshStore.addTarget({ - ...base, - label: 'provisioning', - orcadProvisioning: { requestId: 'r', name: 'n' } - }) - const claimed = sshStore.addTarget({ ...base, label: 'claimed' }) - sshStore.getOrcadRuntimeClaims().claim(claimed.id, 'environment-1') - - expect(sshStore.listTargets()).toEqual([visible]) - expect(sshStore.getOrcadRuntimeClaims().listTargets()).toHaveLength(3) - }) - - it('keeps ~/.ssh/config sync from rewriting a claimed config-sourced host', () => { - mockStore.addSshTarget({ - ...base, - id: 'ssh-config-host', - configHost: 'cluster', - source: 'ssh-config', - owner: createManagedOrcadSshOwner('environment-1') - }) - loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }]) - sshConfigHostsToTargetsMock.mockReturnValue([ - { ...base, id: 'tmp', configHost: 'cluster', host: '10.0.0.9', port: 2222 } - ]) - - expect(sshStore.importFromSshConfig()).toEqual([]) - expect(mockStore.updateSshTarget).not.toHaveBeenCalled() - }) - - it('treats ownership or a provisioning intent as runtime-owned', () => { - const target = { ...base, id: 'ssh-1' } - expect(isRuntimeOwnedSshTarget(target)).toBe(false) - expect( - isRuntimeOwnedSshTarget({ ...target, orcadProvisioning: { requestId: 'r', name: 'n' } }) - ).toBe(true) - expect(isRuntimeOwnedSshTarget({ ...target, owner: createManagedOrcadSshOwner('e') })).toBe( - true - ) - }) -}) diff --git a/src/main/ssh/ssh-connection-store-test-fixture.ts b/src/main/ssh/ssh-connection-store-test-fixture.ts deleted file mode 100644 index 1908112a8ed..00000000000 --- a/src/main/ssh/ssh-connection-store-test-fixture.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { vi } from 'vitest' -import type { FolderWorkspace } from '../../shared/folder-workspace-types' -import type { Repo } from '../../shared/repo-types' -import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' - -export function createMockStore() { - const targets: SshTarget[] = [] - const repos: Repo[] = [] - const folderWorkspaces: FolderWorkspace[] = [] - const projectGroups: { id: string; name: string; connectionId?: string | null }[] = [] - const leases: { - state: 'attached' | 'detached' | 'terminated' | 'expired' - ptyId?: string - worktreeId?: string - tabId?: string - leafId?: string - updatedAt?: number - }[] = [] - let deletedAliases: string[] = [] - const removedTombstones: RemovedSshTargetTombstone[] = [] - const reassignments: { oldTargetId: string; newTargetId: string }[] = [] - let generationCounter = 0 - - const dropTombstone = (oldTargetId: string) => { - const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId) - removedTombstones.length = 0 - removedTombstones.push(...kept) - } - - return { - allocateSshTargetGeneration: vi.fn(() => { - generationCounter += 1 - return generationCounter - }), - getSshTargets: vi.fn(() => [...targets]), - getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)), - getRepos: vi.fn(() => [...repos]), - getProjectGroups: vi.fn(() => [...projectGroups]), - getFolderWorkspaces: vi.fn(() => [...folderWorkspaces]), - getSshRemotePtyLeases: vi.fn(() => [...leases]), - inspectOrcadMigrationSourceDependencies: vi.fn(() => ({ - totalCount: 0, - counts: { - automation: 0, - 'automation-run': 0, - 'mobile-tab-selection': 0, - 'retired-worktree-name': 0, - 'saved-port-forward': 0, - 'sparse-preset': 0, - 'terminal-lease': 0, - 'terminal-recovery': 0, - 'ui-routing': 0, - 'workspace-lineage': 0, - 'workspace-session': 0, - 'worktree-lineage': 0, - 'worktree-metadata': 0 - } - })), - addSshTarget: vi.fn((target: SshTarget) => targets.push(target)), - updateSshTarget: vi.fn((id: string, updates: Partial>) => { - const target = targets.find((t) => t.id === id) - if (!target) { - return null - } - Object.assign(target, updates) - return { ...target } - }), - removeSshTarget: vi.fn((id: string) => { - const idx = targets.findIndex((t) => t.id === id) - if (idx !== -1) { - targets.splice(idx, 1) - } - }), - getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]), - addDeletedSshConfigAlias: vi.fn((alias: string) => { - if (!deletedAliases.includes(alias)) { - deletedAliases.push(alias) - } - }), - removeDeletedSshConfigAlias: vi.fn((alias: string) => { - deletedAliases = deletedAliases.filter((entry) => entry !== alias) - }), - clearDeletedSshConfigAliases: vi.fn(() => { - deletedAliases = [] - }), - removedTombstones, - reassignments, - repos, - projectGroups, - folderWorkspaces, - leases, - getRemovedSshTargetTombstones: vi.fn(() => [...removedTombstones]), - addRemovedSshTargetTombstone: vi.fn((tombstone: RemovedSshTargetTombstone) => { - const filtered = removedTombstones.filter((t) => t.oldTargetId !== tombstone.oldTargetId) - removedTombstones.length = 0 - removedTombstones.push(...filtered, tombstone) - }), - removeRemovedSshTargetTombstone: vi.fn(dropTombstone), - // Nothing in this mock stores automations, so releasing always drops. - releaseRemovedSshTargetTombstone: vi.fn(dropTombstone), - reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { - reassignments.push({ oldTargetId, newTargetId }) - // Pretend one repo referenced the old id. - return ['repo-1'] - }) - } -} diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index fc2b39e88af..24845d2d373 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshConnectionStore } from './ssh-connection-store' -import { createMockStore } from './ssh-connection-store-test-fixture' -import type { SshTarget } from '../../shared/ssh-types' +import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ loadUserSshConfigMock: vi.fn(), @@ -13,6 +12,72 @@ vi.mock('./ssh-config-parser', () => ({ sshConfigHostsToTargets: sshConfigHostsToTargetsMock })) +function createMockStore() { + const targets: SshTarget[] = [] + let deletedAliases: string[] = [] + const removedTombstones: RemovedSshTargetTombstone[] = [] + const reassignments: { oldTargetId: string; newTargetId: string }[] = [] + let generationCounter = 0 + + const dropTombstone = (oldTargetId: string) => { + const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...kept) + } + + return { + allocateSshTargetGeneration: vi.fn(() => { + generationCounter += 1 + return generationCounter + }), + getSshTargets: vi.fn(() => [...targets]), + getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)), + addSshTarget: vi.fn((target: SshTarget) => targets.push(target)), + updateSshTarget: vi.fn((id: string, updates: Partial>) => { + const target = targets.find((t) => t.id === id) + if (!target) { + return null + } + Object.assign(target, updates) + return { ...target } + }), + removeSshTarget: vi.fn((id: string) => { + const idx = targets.findIndex((t) => t.id === id) + if (idx !== -1) { + targets.splice(idx, 1) + } + }), + getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]), + addDeletedSshConfigAlias: vi.fn((alias: string) => { + if (!deletedAliases.includes(alias)) { + deletedAliases.push(alias) + } + }), + removeDeletedSshConfigAlias: vi.fn((alias: string) => { + deletedAliases = deletedAliases.filter((entry) => entry !== alias) + }), + clearDeletedSshConfigAliases: vi.fn(() => { + deletedAliases = [] + }), + removedTombstones, + reassignments, + getRemovedSshTargetTombstones: vi.fn(() => [...removedTombstones]), + addRemovedSshTargetTombstone: vi.fn((tombstone: RemovedSshTargetTombstone) => { + const filtered = removedTombstones.filter((t) => t.oldTargetId !== tombstone.oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...filtered, tombstone) + }), + removeRemovedSshTargetTombstone: vi.fn(dropTombstone), + // Nothing in this mock stores automations, so releasing always drops. + releaseRemovedSshTargetTombstone: vi.fn(dropTombstone), + reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { + reassignments.push({ oldTargetId, newTargetId }) + // Pretend one repo referenced the old id. + return ['repo-1'] + }) + } +} + describe('SshConnectionStore', () => { let mockStore: ReturnType let sshStore: SshConnectionStore diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index a329c935fee..0d67587b920 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -3,7 +3,6 @@ import type { SshRepoReadoption, SshTarget } from '../../shared/ssh-types' import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host' import { normalizeSshConfigAlias } from '../../shared/ssh-config-alias' import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' import { buildRemovedSshTargetTombstone, readoptOrphanedWorkspacesForTarget @@ -85,11 +84,6 @@ export class SshConnectionStore { return next } - /** Exclusive managed-orcad ownership of a target; see ssh-target-orcad-claims. */ - getOrcadRuntimeClaims(): SshTargetOrcadClaims { - return new SshTargetOrcadClaims(this.store) - } - updateTarget(id: string, updates: Partial>): SshTarget | null { const existing = this.store.getSshTarget(id) // Why: a new runtime choice or endpoint must re-run the ladder, not replay the old rung. @@ -167,7 +161,6 @@ export class SshConnectionStore { const alias = normalizeSshConfigAlias(existing.configHost ?? existing.label) if ( existing.source === 'manual' || - isRuntimeOwnedSshTarget(existing) || (existing.source === undefined && !isLegacyConfigImportTarget(existing)) ) { manualAliases.add(alias) @@ -260,7 +253,7 @@ export function getRuntimeOwnedSshTargetId(runtimeId: string): string { } export function isRuntimeOwnedSshTarget(target: SshTarget): boolean { - return target.owner !== undefined || target.orcadProvisioning !== undefined + return target.owner?.type === 'on-demand-runtime' } function isLegacyConfigImportTarget(target: SshTarget): boolean { diff --git a/src/main/ssh/ssh-connection-system-work-drain.test.ts b/src/main/ssh/ssh-connection-system-work-drain.test.ts deleted file mode 100644 index e4785495b20..00000000000 --- a/src/main/ssh/ssh-connection-system-work-drain.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { - createCallbacks, - createResolvedConfig, - createTarget, - fenceSshConnectionWork -} from './ssh-connection-test-fixtures' -import { resetSshConnectionMocks } from './ssh-connection-test-harness' -import { resolveWithSshG } from './ssh-config-parser' -import { - downloadFileViaSystemSsh, - uploadDirectoryViaSystemSsh, - uploadFileViaSystemSsh, - writeFileViaSystemSsh, - writeBufferViaSystemSsh -} from './ssh-system-fallback' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(resetSshConnectionMocks) - -it('holds an admitted system upload session across reset until close and uploads settle', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - await conn.connect() - const session = await conn.openFileUploadSession() - const fence = fenceSshConnectionWork(conn) - await expect(conn.openFileUploadSession()).rejects.toThrow('admission_closed') - await session.uploadFile('/first', '/remote-first') - const pending = Promise.withResolvers() - vi.mocked(uploadFileViaSystemSsh).mockReturnValueOnce(pending.promise) - const upload = session.uploadFile('/second', '/remote-second') - session.close() - await expect(session.uploadFile('/third', '/remote-third')).rejects.toThrow('session_closed') - expect(() => fence.assertDrained()).toThrow('not_drained') - const draining = fence.drain(new AbortController().signal) - expect(conn.getState().status).toBe('connected') - pending.resolve() - await upload - await draining - fence.assertDrained() - expect(uploadFileViaSystemSsh).toHaveBeenCalledTimes(2) -}) - -it.each(['download', 'upload', 'writeFile', 'writeBuffer'])( - 'drains the whole admitted system SSH %s without canceling it', - async (kind) => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - await conn.connect() - const pending = Promise.withResolvers() - const invoke = () => { - if (kind === 'download') { - return conn.downloadFile('/remote', '/local') - } - if (kind === 'upload') { - return conn.uploadDirectory('/local', '/remote') - } - if (kind === 'writeFile') { - return conn.writeFile('/remote', 'text') - } - return conn.writeBuffer('/remote', Buffer.from('bytes')) - } - const operation = - kind === 'download' - ? downloadFileViaSystemSsh - : kind === 'upload' - ? uploadDirectoryViaSystemSsh - : kind === 'writeFile' - ? writeFileViaSystemSsh - : writeBufferViaSystemSsh - vi.mocked(operation).mockReturnValueOnce(pending.promise) - const running = invoke() - const fence = fenceSshConnectionWork(conn) - const done = vi.fn() - const draining = fence.drain(new AbortController().signal).then(done) - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - await expect(invoke()).rejects.toThrow('admission_closed') - expect(operation).toHaveBeenCalledTimes(1) - expect(conn.getState().status).toBe('connected') - pending.resolve() - await running - await draining - fence.assertDrained() - } -) diff --git a/src/main/ssh/ssh-connection-test-fixtures.ts b/src/main/ssh/ssh-connection-test-fixtures.ts index dce4a05cb98..cfed6a01061 100644 --- a/src/main/ssh/ssh-connection-test-fixtures.ts +++ b/src/main/ssh/ssh-connection-test-fixtures.ts @@ -1,8 +1,7 @@ import { EventEmitter } from 'node:events' import { vi } from 'vitest' import type { Mock } from 'vitest' -import type { SshConnection, SshConnectionCallbacks } from './ssh-connection' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' +import type { SshConnectionCallbacks } from './ssh-connection' import type { SshResolvedConfig } from './ssh-config-parser' import type { SshTarget } from '../../shared/ssh-types' @@ -125,9 +124,3 @@ export function createFailingSystemSshProcess(code: number): MockSystemSshProces }) return proc } - -// The public reset fence lands with T3; until then the connection's own ledger fence drains the same work. -export function fenceSshConnectionWork(conn: SshConnection) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnection owns exactly one private workLedger. - return (conn as unknown as { workLedger: SshConnectionWorkLedger }).workLedger.fenceForReset() -} diff --git a/src/main/ssh/ssh-connection-transport-closure.test.ts b/src/main/ssh/ssh-connection-transport-closure.test.ts deleted file mode 100644 index 858c264bf2f..00000000000 --- a/src/main/ssh/ssh-connection-transport-closure.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { createCallbacks, createResolvedConfig, createTarget } from './ssh-connection-test-fixtures' -import { resetSshConnectionMocks } from './ssh-connection-test-harness' -import { resolveWithSshG } from './ssh-config-parser' -import type { SshTransportCloseLedger } from './ssh-transport-close-ledger' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) -beforeEach(resetSshConnectionMocks) -afterEach(() => { - vi.restoreAllMocks() - vi.useRealTimers() -}) - -async function fixture() { - const conn = new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - const closed = vi.fn() - const remove = conn.subscribeTransportClosure(closed) - await conn.connect() - const client = conn.getClient()! - vi.spyOn(client, 'end').mockImplementation(() => client) - return { conn, client, closed, remove } -} - -it('notifies only after disposal and physical client closure, including late subscribers', async () => { - const f = await fixture() - expect(f.closed).not.toHaveBeenCalled() - await f.conn.disconnect() - expect(f.closed).not.toHaveBeenCalled() - f.client.emit('close') - expect(f.closed).toHaveBeenCalledOnce() - const late = vi.fn() - f.conn.subscribeTransportClosure(late) - expect(late).toHaveBeenCalledOnce() - f.client.emit('close') - expect(f.closed).toHaveBeenCalledOnce() - expect(late).toHaveBeenCalledOnce() -}) - -it('remembers physical closure before disposal but does not report it early', async () => { - const f = await fixture() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() - await f.conn.disconnect() - expect(f.closed).toHaveBeenCalledOnce() -}) - -it('holds notification through tracked work and both forwarded resources', async () => { - const f = await fixture() - const pending = Promise.withResolvers() - const operation = f.conn.prepareForwardRoute(() => pending.promise) - const socket = new EventEmitter() - const channel = new EventEmitter() - f.client.forwardOut = vi.fn((_a, _b, _c, _d, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback?.(undefined, channel as never) - return f.client - }) - f.conn.forwardOut(f.client, socket, '127.0.0.1', 0, 'host', 443, vi.fn()) - await f.conn.disconnect() - f.client.emit('close') - channel.emit('close') - expect(f.closed).not.toHaveBeenCalled() - socket.emit('close') - expect(f.closed).not.toHaveBeenCalled() - pending.resolve() - await operation - expect(f.closed).toHaveBeenCalledOnce() -}) - -it('waits for every allocated proxy even after its active pointer is cleared', async () => { - const f = await fixture() - const proxy = new EventEmitter() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads SshConnection's own private transport close ledger. - const ledger = (f.conn as unknown as { transportCloseLedger: SshTransportCloseLedger }) - .transportCloseLedger - ledger.track(proxy) - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() - proxy.emit('close') - expect(f.closed).toHaveBeenCalledOnce() -}) - -it('unsubscribes without changing ordinary disconnection', async () => { - const f = await fixture() - f.remove() - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() -}) - -it('isolates observer failures from disconnect and other observers', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - conn.subscribeTransportClosure(() => { - throw new Error('observer') - }) - const observed = vi.fn() - conn.subscribeTransportClosure(observed) - await expect(conn.disconnect()).resolves.toBeUndefined() - expect(observed).toHaveBeenCalledOnce() -}) - -it('never treats unproven system SSH cleanup as physical closure', async () => { - vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) - const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) - const observed = vi.fn() - conn.subscribeTransportClosure(observed) - await conn.connect() - await conn.disconnect() - expect(observed).not.toHaveBeenCalled() -}) - -it.each(['initial', 'reconnect', 'system'] as const)( - 'waits for active %s allocation work', - async (kind) => { - const f = kind === 'reconnect' ? await fixture() : null - const conn = - f?.conn ?? new SshConnection(createTarget(), createCallbacks(), { automaticReconnect: false }) - const observed = vi.fn() - conn.subscribeTransportClosure(observed) - const config = Promise.withResolvers() - vi.mocked(resolveWithSshG).mockReturnValueOnce(config.promise) - const opening = ( - kind === 'initial' - ? conn.connect() - : kind === 'reconnect' - ? conn.reconnect() - : conn.connectViaSystemSsh() - ).catch(() => {}) - await conn.disconnect() - f?.client.emit('close') - expect(observed).not.toHaveBeenCalled() - config.resolve(null) - await opening - expect(observed).toHaveBeenCalledTimes(kind === 'system' ? 0 : 1) - } -) - -it('retains failed ledger drainage rather than signaling successful closure', async () => { - const f = await fixture() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reads SshConnection's own private work ledger. - const work = (f.conn as unknown as { workLedger: SshConnectionWorkLedger }).workLedger - const opening = work.beginChannelOpen() - work.fenceForReset() - opening.close(new Error('unproven channel')) - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).not.toHaveBeenCalled() -}) - -it('refuses fresh forwarding work after notifying physical closure', async () => { - const f = await fixture() - await f.conn.disconnect() - f.client.emit('close') - expect(f.closed).toHaveBeenCalledOnce() - const allocate = vi.fn(() => new EventEmitter()) - const prepare = vi.fn(async () => {}) - expect(() => f.conn.openForwardSocket(allocate)).toThrow( - expect.objectContaining({ name: 'AbortError' }) - ) - await expect(f.conn.prepareForwardRoute(prepare)).rejects.toMatchObject({ name: 'AbortError' }) - expect(allocate).not.toHaveBeenCalled() - expect(prepare).not.toHaveBeenCalled() -}) diff --git a/src/main/ssh/ssh-connection-transport-closure.ts b/src/main/ssh/ssh-connection-transport-closure.ts deleted file mode 100644 index 3a92bc29b42..00000000000 --- a/src/main/ssh/ssh-connection-transport-closure.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * One-shot subscribers told when every transport resource a connection allocated has physically - * closed and its tracked work has drained. That is local lifetime evidence only: it never proves a - * remote process exited. - */ -export class SshTransportClosureSubscribers { - private readonly subscribers = new Set<() => void>() - - constructor(private readonly isPhysicallyClosed: () => boolean) {} - - subscribe(onClosed: () => void): () => void { - this.subscribers.add(onClosed) - this.notify() - return () => { - this.subscribers.delete(onClosed) - } - } - - notify(): void { - if (!this.subscribers.size || !this.isPhysicallyClosed()) { - return - } - const subscribers = [...this.subscribers] - this.subscribers.clear() - for (const onClosed of subscribers) { - try { - onClosed() - } catch { - // Observers must not interrupt physical transport cleanup. - } - } - } -} diff --git a/src/main/ssh/ssh-connection-work-drain.test.ts b/src/main/ssh/ssh-connection-work-drain.test.ts deleted file mode 100644 index 24448fe7b06..00000000000 --- a/src/main/ssh/ssh-connection-work-drain.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { EventEmitter } from 'node:events' -import { beforeEach, expect, it, vi } from 'vitest' -import { SshConnection } from './ssh-connection' -import { - createCallbacks, - createTarget, - fenceSshConnectionWork -} from './ssh-connection-test-fixtures' -import { - resetSshConnectionMocks, - pendingSftpCallback, - ssh2Mock -} from './ssh-connection-test-harness' - -vi.mock('ssh2', async () => (await import('./ssh-connection-test-harness')).createSsh2Module()) -vi.mock('./system-ssh-binary', async () => - (await import('./ssh-connection-test-harness')).createSystemSshBinaryModule() -) -vi.mock('./ssh-system-fallback', async () => - (await import('./ssh-connection-test-harness')).createSystemFallbackModule() -) -vi.mock('./ssh-control-socket', async () => - (await import('./ssh-connection-test-harness')).createControlSocketModule() -) -vi.mock('./ssh-config-parser', async () => - (await import('./ssh-connection-test-harness')).createSshConfigParserModule() -) - -beforeEach(resetSshConnectionMocks) - -it('does not automatically replace an explicitly single-lifetime SSH transport', async () => { - const options = { automaticReconnect: false } - const conn = new SshConnection(createTarget(), createCallbacks(), options) - options.automaticReconnect = true - await conn.connect() - const client = conn.getClient()! - vi.useFakeTimers() - try { - client.emit('close') - expect(conn.getClient()).toBeNull() - expect(conn.getState().status).toBe('disconnected') - expect(vi.getTimerCount()).toBe(0) - await vi.advanceTimersByTimeAsync(60_000) - expect(conn.getClient()).toBeNull() - expect(conn.getState().reconnectAttempt).toBe(0) - } finally { - await conn.disconnect() - vi.useRealTimers() - } -}) - -it('accounts for forward-route startup before reset and blocks new socket factories', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const release = Promise.withResolvers() - const preparing = conn.prepareForwardRoute(async () => { - await release.promise - return conn.openForwardSocket(() => new EventEmitter()) - }) - const fence = fenceSshConnectionWork(conn) - const forbidden = vi.fn(() => new EventEmitter()) - expect(() => conn.openForwardSocket(forbidden)).toThrow('admission_closed') - expect(forbidden).not.toHaveBeenCalled() - expect(() => fence.assertDrained()).toThrow('not_drained') - release.resolve() - const admitted = await preparing - expect(() => fence.assertDrained()).toThrow('not_drained') - admitted.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('routes actual connection forwarding through admission and rejects a replaced client', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - const channel = new EventEmitter() - const socket = new EventEmitter() - client.forwardOut = vi.fn((_a, _b, _c, _d, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback?.(undefined, channel as never) - return client - }) - conn.forwardOut(client, socket, '127.0.0.1', 0, 'remote', 443, vi.fn()) - const fence = fenceSshConnectionWork(conn) - expect(() => - conn.forwardOut(client, new EventEmitter(), '127.0.0.1', 0, 'remote', 443, vi.fn()) - ).toThrow('admission_closed') - expect(() => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a stand-in for a different ssh2 client; only its identity is compared. - conn.forwardOut({} as never, new EventEmitter(), '127.0.0.1', 0, 'remote', 443, vi.fn()) - ).toThrow('client_changed') - channel.emit('close') - expect(() => fence.assertDrained()).toThrow('not_drained') - socket.emit('close') - await fence.drain(new AbortController().signal) - expect(client.forwardOut).toHaveBeenCalledOnce() -}) - -it('tracks exact remote socket forwarding through a late open and physical close', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - let accept: Parameters[1] | undefined - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - accept = callback - return client - }) - const received = vi.fn() - conn.forwardStreamLocal(client, '/saved/incumbent.sock', received) - expect(client.openssh_forwardOutStreamLocal).toHaveBeenCalledWith( - '/saved/incumbent.sock', - expect.any(Function) - ) - const fence = fenceSshConnectionWork(conn) - expect(() => fence.assertDrained()).toThrow('not_drained') - expect(() => conn.forwardStreamLocal(client, '/other.sock', vi.fn())).toThrow('admission_closed') - const channel = new EventEmitter() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - accept!(undefined, channel as never) - expect(received).toHaveBeenCalledWith(undefined, channel) - expect(() => fence.assertDrained()).toThrow('not_drained') - channel.emit('close') - await fence.drain(new AbortController().signal) - expect(client.openssh_forwardOutStreamLocal).toHaveBeenCalledOnce() -}) - -it('rejects stale clients and non-Unix endpoints before remote socket forwarding', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - client.openssh_forwardOutStreamLocal = vi.fn() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a stand-in for a different ssh2 client; only its identity is compared. - expect(() => conn.forwardStreamLocal({} as never, '/saved.sock', vi.fn())).toThrow( - 'client_changed' - ) - for (const endpoint of ['relative.sock', '\\\\.\\pipe\\saved', '/saved\0.sock']) { - expect(() => conn.forwardStreamLocal(client, endpoint, vi.fn())).toThrow('endpoint_invalid') - } - expect(client.openssh_forwardOutStreamLocal).not.toHaveBeenCalled() -}) - -it('does not treat an uncertain remote socket open as drained', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - client.openssh_forwardOutStreamLocal = vi.fn(() => { - throw new Error('uncertain-open') - }) - expect(() => conn.forwardStreamLocal(client, '/saved.sock', vi.fn())).toThrow('uncertain-open') - const fence = fenceSshConnectionWork(conn) - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('uncertain-open') -}) - -it('surfaces remote socket extension refusal without a fallback', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - let accept: Parameters[1] | undefined - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - accept = callback - return client - }) - const received = vi.fn() - conn.forwardStreamLocal(client, '/saved.sock', received) - const fence = fenceSshConnectionWork(conn) - const refused = new Error('extension unsupported') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh2 passes no channel on a refused open; the callback type omits that case. - accept!(refused, undefined as never) - expect(received).toHaveBeenCalledWith(refused, undefined) - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('extension unsupported') - expect(client.openssh_forwardOutStreamLocal).toHaveBeenCalledOnce() -}) - -it('retains remote socket channel errors through physical close', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - const client = conn.getClient()! - const channel = new EventEmitter() - client.openssh_forwardOutStreamLocal = vi.fn((_path, callback) => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock ssh2 client hands back this EventEmitter as its channel. - callback(undefined, channel as never) - return client - }) - conn.forwardStreamLocal(client, '/saved.sock', vi.fn()) - const fence = fenceSshConnectionWork(conn) - channel.emit('error', new Error('connection lost')) - channel.emit('close') - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('connection lost') -}) - -it('keeps the upload session SFTP channel tracked after logical close', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.sftpBehavior = 'pending' - const opening = conn.openFileUploadSession() - const fence = fenceSshConnectionWork(conn) - const channel = Object.assign(new EventEmitter(), { end: vi.fn() }) - pendingSftpCallback?.(undefined, channel) - const session = await opening - session.close() - expect(channel.end).toHaveBeenCalledOnce() - expect(() => fence.assertDrained()).toThrow('not_drained') - channel.emit('close') - await fence.drain(new AbortController().signal) - fence.assertDrained() -}) - -it('accounts for an SFTP open begun before fencing until its channel physically closes', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.sftpBehavior = 'pending' - const opening = conn.sftp() - const fence = fenceSshConnectionWork(conn) - const done = vi.fn() - const draining = fence.drain(new AbortController().signal).then(done) - const channel = Object.assign(new EventEmitter(), { end: vi.fn() }) - pendingSftpCallback?.(undefined, channel) - await opening - expect(done).not.toHaveBeenCalled() - expect(channel.end).not.toHaveBeenCalled() - await expect(conn.sftp()).rejects.toThrow('admission_closed') - channel.emit('close') - await draining - fence.assertDrained() -}) - -it('retains an opening timeout as unverifiable and never force-closes it to make drain pass', async () => { - const conn = new SshConnection(createTarget(), createCallbacks()) - await conn.connect() - ssh2Mock.sftpBehavior = 'pending' - vi.useFakeTimers() - try { - const opening = conn.sftp().catch((error: unknown) => error) - const fence = fenceSshConnectionWork(conn) - const draining = expect(fence.drain(new AbortController().signal)).rejects.toThrow('timed out') - await vi.advanceTimersByTimeAsync(30_000) - await opening - await draining - await expect(fence.drain(new AbortController().signal)).rejects.toThrow('timed out') - expect(conn.getState().status).toBe('connected') - } finally { - vi.useRealTimers() - } -}) diff --git a/src/main/ssh/ssh-connection-work-ledger.test.ts b/src/main/ssh/ssh-connection-work-ledger.test.ts deleted file mode 100644 index 25fa62e3653..00000000000 --- a/src/main/ssh/ssh-connection-work-ledger.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it } from 'vitest' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -const signal = () => new AbortController().signal - -it('allows admitted work to open nested channels after fencing and drains children independently', async () => { - const ledger = new SshConnectionWorkLedger() - const continueOperation = Promise.withResolvers() - let child!: ReturnType - const parent = ledger.run(async () => { - await continueOperation.promise - await ledger.run(async () => { - child = ledger.beginChannelOpen() - child.bind(new EventEmitter()) - }) - }) - const fence = ledger.fenceForReset() - let drained = false - const draining = fence.drain(signal()).then(() => { - drained = true - }) - continueOperation.resolve() - await parent - expect(drained).toBe(false) - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - child.close() - await draining - fence.assertDrained() -}) - -it('refuses stale async scope work once its admitted parent has settled', async () => { - const ledger = new SshConnectionWorkLedger() - const releaseStale = Promise.withResolvers() - const staleResult = Promise.withResolvers() - await ledger.run(async () => { - void releaseStale.promise.then(() => { - try { - ledger.beginChannelOpen() - staleResult.resolve('incorrectly admitted') - } catch (error) { - staleResult.resolve(error) - } - }) - }) - const fence = ledger.fenceForReset() - releaseStale.resolve() - expect(await staleResult.promise).toMatchObject({ - message: 'ssh_connection_work_admission_closed' - }) - await fence.drain(signal()) -}) - -it('exempts only the exact captured control channel and keeps ordinary admission fenced', async () => { - const ledger = new SshConnectionWorkLedger() - const control = ledger.beginChannelOpen() - const resource = new EventEmitter() - control.bind(resource) - const transfer = ledger.beginChannelOpen() - transfer.bind(new EventEmitter()) - expect(() => ledger.fenceForReset(new EventEmitter())).toThrow( - 'ssh_connection_work_control_channel_unproven' - ) - const fence = ledger.fenceForReset(resource) - expect(() => ledger.beginChannelOpen()).toThrow('ssh_connection_work_admission_closed') - await expect(ledger.run(async () => undefined)).rejects.toThrow( - 'ssh_connection_work_admission_closed' - ) - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - transfer.close() - await fence.drain(signal()) - fence.assertDrained() - control.close(new Error('expected reset transport close')) - fence.assertDrained() - expect(() => ledger.fenceForReset(resource)).toThrow('ssh_connection_work_already_fenced') -}) - -it('refuses ambiguous control-channel bindings without publishing a fence', async () => { - const ledger = new SshConnectionWorkLedger() - const resource = new EventEmitter() - const first = ledger.beginChannelOpen() - const second = ledger.beginChannelOpen() - first.bind(resource) - second.bind(resource) - expect(() => ledger.fenceForReset(resource)).toThrow( - 'ssh_connection_work_control_channel_unproven' - ) - await ledger.run(async () => undefined) - second.close() - await ledger.fenceForReset(resource).drain(signal()) -}) - -it('keeps uncertain pre-fence openings unconfirmed after late channel close', async () => { - const ledger = new SshConnectionWorkLedger() - const opening = ledger.beginChannelOpen() - const uncertainty = new Error('open timed out without closure evidence') - opening.markUnverifiable(uncertainty) - const fence = ledger.fenceForReset() - await expect(fence.drain(signal())).rejects.toBe(uncertainty) - opening.bind(new EventEmitter()) - opening.close() - expect(fence.assertDrained).toThrow(uncertainty) - await expect(fence.drain(signal())).rejects.toBe(uncertainty) -}) - -it('retains operation failures observed while draining', async () => { - const ledger = new SshConnectionWorkLedger() - const operation = Promise.withResolvers() - const failure = new Error('upload failed') - const running = ledger.run(() => operation.promise).catch((error: unknown) => error) - const fence = ledger.fenceForReset() - const draining = fence.drain(signal()).catch((error: unknown) => error) - operation.reject(failure) - expect(await running).toBe(failure) - expect(await draining).toBe(failure) - expect(fence.assertDrained).toThrow(failure) - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('retains a channel failure even after repeated successful close calls', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const failure = new Error('channel failed') - channel.close(failure) - channel.close() - expect(fence.assertDrained).toThrow(failure) - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('wakes an in-flight drain when an opening becomes unverifiable without waiting for close', async () => { - const ledger = new SshConnectionWorkLedger() - const channel = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const failure = new Error('opening outcome became unverifiable') - let observed: unknown - const draining = fence.drain(signal()).catch((error: unknown) => { - observed = error - }) - channel.markUnverifiable(failure) - await new Promise((resolve) => setImmediate(resolve)) - const observedBeforeClose = observed - channel.close() - await draining - expect(observedBeforeClose).toBe(failure) -}) - -it('reports one failed channel without waiting for unrelated admitted channels to close', async () => { - const ledger = new SshConnectionWorkLedger() - const failedChannel = ledger.beginChannelOpen() - const liveChannel = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const failure = new Error('selected transfer failed') - let observed: unknown - const draining = fence.drain(signal()).catch((error: unknown) => { - observed = error - }) - failedChannel.close(failure) - await new Promise((resolve) => setImmediate(resolve)) - const observedBeforeOtherClose = observed - liveChannel.close() - await draining - expect(observedBeforeOtherClose).toBe(failure) -}) - -it('cancels only the wait and permits exact drain retry without reopening admission', async () => { - const ledger = new SshConnectionWorkLedger() - const opening = ledger.beginChannelOpen() - const fence = ledger.fenceForReset() - const controller = new AbortController() - const cancellation = new Error('user cancelled waiting') - const draining = fence.drain(controller.signal).catch((error: unknown) => error) - controller.abort(cancellation) - expect(await draining).toBe(cancellation) - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - expect(() => ledger.beginChannelOpen()).toThrow('ssh_connection_work_admission_closed') - opening.bind(new EventEmitter()) - opening.close() - await fence.drain(signal()) - fence.assertDrained() -}) - -it('isolates admission, async ancestry, failures, and control identities between connections', async () => { - const first = new SshConnectionWorkLedger() - const second = new SshConnectionWorkLedger() - const resource = new EventEmitter() - first.beginChannelOpen().bind(resource) - const firstFence = first.fenceForReset(resource) - await second.run(async () => { - expect(() => first.beginChannelOpen()).toThrow('ssh_connection_work_admission_closed') - }) - expect(() => second.fenceForReset(resource)).toThrow( - 'ssh_connection_work_control_channel_unproven' - ) - const channel = second.beginChannelOpen() - const secondFence = second.fenceForReset() - channel.close(new Error('second connection failure')) - await expect(secondFence.drain(signal())).rejects.toThrow('second connection failure') - await firstFence.drain(signal()) -}) - -it('rejects rebinding and binding an already settled opening', () => { - const ledger = new SshConnectionWorkLedger() - const channel = ledger.beginChannelOpen() - channel.bind(new EventEmitter()) - expect(() => channel.bind(new EventEmitter())).toThrow( - 'ssh_connection_work_channel_binding_changed' - ) - const closed = ledger.beginChannelOpen() - closed.close() - expect(() => closed.bind(new EventEmitter())).toThrow( - 'ssh_connection_work_channel_binding_changed' - ) -}) - -it.each([false, true])( - 'preserves drained proof after exact exempt control closes (error=%s)', - async (withError) => { - const ledger = new SshConnectionWorkLedger() - const control = ledger.beginChannelOpen() - const resource = new EventEmitter() - control.bind(resource) - const fence = ledger.fenceForReset(resource) - await fence.drain(signal()) - if (withError) { - control.markUnverifiable(new Error('control stream closed after response')) - } - control.close(withError ? new Error('control closed') : undefined) - fence.assertDrained() - await fence.drain(signal()) - expect(() => ledger.beginChannelOpen()).toThrow('admission_closed') - } -) - -it('control closure cannot erase retained unrelated work or transport failure', async () => { - for (const transportFailure of [false, true]) { - const ledger = new SshConnectionWorkLedger() - const control = ledger.beginChannelOpen() - const resource = new EventEmitter() - control.bind(resource) - const work = ledger.beginChannelOpen() - const fence = ledger.fenceForReset(resource) - if (transportFailure) { - work.close() - await fence.drain(signal()) - ledger.markTransportUnverifiable() - } else { - work.close(new Error('unrelated write failed')) - } - control.close() - expect(fence.assertDrained).toThrow( - transportFailure ? 'transport_unverifiable' : 'unrelated write failed' - ) - await expect(fence.drain(signal())).rejects.toThrow() - } -}) diff --git a/src/main/ssh/ssh-connection-work-ledger.ts b/src/main/ssh/ssh-connection-work-ledger.ts deleted file mode 100644 index 183bc270a39..00000000000 --- a/src/main/ssh/ssh-connection-work-ledger.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' - -/** Identity of a channel or socket; only compared by reference. */ -export type SshConnectionWorkChannel = Pick - -type WorkEntry = { - done: Promise - finish: () => void - resource?: SshConnectionWorkChannel - failure?: Error -} - -export class SshConnectionWorkAdmissionClosedError extends Error { - constructor() { - super('ssh_connection_work_admission_closed') - this.name = 'SshConnectionWorkAdmissionClosedError' - } -} - -/** Tracks local operation/channel lifetime; channel closure is not remote process-exit proof. */ -export class SshConnectionWorkLedger { - private readonly pending = new Set() - private readonly scope = new AsyncLocalStorage() - private fenced = false - private exempt: WorkEntry | undefined - private failure: Error | undefined - private readonly failureWake = Promise.withResolvers() - - constructor( - private readonly onChange: () => void = () => {}, - private readonly assertAdmission: () => void = () => {} - ) {} - - isDrained(): boolean { - return this.pending.size === 0 && !this.failure - } - - private recordFailure(error: Error): void { - this.failure ??= error - this.failureWake.resolve(this.failure) - } - - markTransportUnverifiable(): void { - if (this.fenced) { - this.recordFailure(new Error('ssh_connection_reset_transport_unverifiable')) - } - } - - private admit(): WorkEntry { - this.assertAdmission() - const parent = this.scope.getStore() - if (this.fenced && (!parent || !this.pending.has(parent) || parent === this.exempt)) { - throw new SshConnectionWorkAdmissionClosedError() - } - const completion = Promise.withResolvers() - const entry: WorkEntry = { done: completion.promise, finish: completion.resolve } - this.pending.add(entry) - return entry - } - - private settle(entry: WorkEntry, error?: unknown): void { - if (!this.pending.delete(entry)) { - return - } - if (error !== undefined) { - entry.failure = error instanceof Error ? error : new Error(String(error)) - } - if (this.fenced && entry !== this.exempt && entry.failure) { - this.recordFailure(entry.failure) - } - entry.finish() - this.onChange() - } - - async run(operation: () => Promise): Promise { - const entry = this.admit() - return this.scope.run(entry, async () => { - try { - const result = await operation() - this.settle(entry) - return result - } catch (error) { - this.settle(entry, error) - throw error - } - }) - } - - beginSession() { - const entry = this.admit() - let closed = false - let closeFailed = false - let active = 0 - const finish = () => { - if (closed && !closeFailed && active === 0) { - this.settle(entry) - } - } - return { - run: async (operation: () => Promise): Promise => { - if (closed) { - throw new Error('ssh_connection_work_session_closed') - } - active++ - try { - return await this.scope.run(entry, () => this.run(operation)) - } catch (error) { - entry.failure ??= error instanceof Error ? error : new Error(String(error)) - if (this.fenced) { - this.recordFailure(entry.failure) - } - throw error - } finally { - active-- - finish() - } - }, - close: (error?: unknown) => { - closed = true - if (error !== undefined) { - // Failed closure must remain observable even if reset has not started yet. - closeFailed = true - entry.failure ??= error instanceof Error ? error : new Error(String(error)) - if (this.fenced) { - this.recordFailure(entry.failure) - } - } - finish() - } - } - } - - beginChannelOpen() { - const entry = this.admit() - let bound = false - return { - bind: (resource: SshConnectionWorkChannel) => { - if (bound || !this.pending.has(entry)) { - throw new Error('ssh_connection_work_channel_binding_changed') - } - entry.resource = resource - bound = true - }, - markUnverifiable: (error: Error) => { - if (this.pending.has(entry)) { - entry.failure ??= error - if (this.fenced && entry !== this.exempt) { - this.recordFailure(error) - } - } - }, - close: (error?: unknown) => this.settle(entry, error) - } - } - - fenceForReset(controlChannel?: SshConnectionWorkChannel) { - if (this.fenced) { - throw new Error('ssh_connection_work_already_fenced') - } - const matches = controlChannel - ? [...this.pending].filter((entry) => entry.resource === controlChannel) - : [] - if (controlChannel && matches.length !== 1) { - throw new Error('ssh_connection_work_control_channel_unproven') - } - this.exempt = matches[0] - this.fenced = true - const assertDrained = () => { - if (this.failure) { - throw this.failure - } - if ([...this.pending].some((entry) => entry !== this.exempt)) { - throw new Error('ssh_connection_work_not_drained') - } - } - return { - drain: async (signal: AbortSignal) => { - signal.throwIfAborted() - for (;;) { - if (this.failure) { - throw this.failure - } - const pending = [...this.pending].filter((entry) => entry !== this.exempt) - for (const entry of pending) { - if (entry.failure) { - throw entry.failure - } - } - if (pending.length === 0) { - assertDrained() - return - } - await waitForPromiseWithSignal( - Promise.race([ - Promise.all(pending.map((entry) => entry.done)), - this.failureWake.promise.then((error) => { - throw error - }) - ]), - signal - ) - } - }, - assertDrained - } - } -} diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index d7150af631a..f75c4b4f172 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -1,34 +1,6 @@ /* eslint-disable max-lines -- Why: SSH connection lifecycle, credential retries, reconnect policy, and transport fallback are intentionally co-located so state transitions stay auditable in one file. */ import * as net from 'node:net' import { Client as SshClient } from 'ssh2' -import { sshProxyRouteDigest, type SshConnectionDestination } from './ssh-connection-destination' -import { - SshConnectionWorkLedger, - type SshConnectionWorkChannel -} from './ssh-connection-work-ledger' -import { SshTransportCloseLedger } from './ssh-transport-close-ledger' -import { SshTransportClosureSubscribers } from './ssh-connection-transport-closure' -import { disconnectAndAwaitSshTransportClose } from './ssh-connection-close-drain' -import { openTrackedSshUploadSession } from './ssh-upload-session-lifetime' -import { - createSshFileUploadSession, - downloadSshFile, - uploadSshDirectory, - writeSshBuffer, - writeSshFile, - type SshFileTransferHost, - type SshRemoteFileOptions -} from './ssh-connection-file-transfers' -import { - assertSshForwardClient, - assertSshStreamLocalForwardAllowed, - forwardTrackedSshChannel, - forwardTrackedSshStreamLocalChannel -} from './ssh-forward-channel-lifetime' -import { - openTrackedSshSocket, - trackSshConnectionChannelLifetime -} from './ssh-connection-channel-lifetime' import type { ChildProcess } from 'node:child_process' import type { ClientChannel, @@ -44,6 +16,11 @@ import { getOrcaControlSocketPath, spawnSystemSsh, spawnSystemSshCommand, + downloadFileViaSystemSsh, + uploadDirectoryViaSystemSsh, + uploadFileViaSystemSsh, + writeBufferViaSystemSsh, + writeFileViaSystemSsh, type SystemSshBuildArgsOptions, type SystemSshProcess } from './ssh-system-fallback' @@ -106,10 +83,18 @@ import { requiresSystemSshForSecurityKey, shouldUseSystemSshTransport } from './ssh-transport-selection' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { + resolveSftpTransferPathIfMapped, + type SftpNamespacePathMapping +} from './sftp-namespace-resolution' import type { FileUploadSession } from '../providers/types' -import { openSshSessionChannelWithRetry, waitForSshChannelOpen } from './ssh-channel-open' +import { isSshSessionLimitError } from './ssh-session-limit-error' import { withTimeout } from '../../shared/promise-timeout-fallback' -import { isEphemeralRuntimeSshOwner } from '../../shared/managed-orcad-ssh-owner' +import { + createLinkedSshFileTransferSignal, + raceSftpFileTransferWithAbort +} from './ssh-file-transfer-abort' export type { SshConnectionCallbacks } from './ssh-connection-utils' type HostKeyTrustSources = { @@ -119,6 +104,12 @@ type HostKeyTrustSources = { trustedHostKeys: Awaited> } +type SshRemoteFileOptions = { + hostPlatform?: RemoteHostPlatform + // Only uploadDirectory and writeFile honor this, and only on the non-Windows ssh2 branch. + sftpNamespace?: SftpNamespacePathMapping +} + /** Bounds the trust-source reads that run before the handshake, which nothing else times out. */ const HOST_KEY_SOURCE_READ_TIMEOUT_MS = 5_000 // Counts every INFO_REQUEST of the handshake, so it must cover each partial-success stage the auth @@ -127,6 +118,13 @@ const SSH_KEYBOARD_INTERACTIVE_MAX_ROUNDS = 8 const SSH_KEYBOARD_INTERACTIVE_READY_TIMEOUT_MS = SSH_CREDENTIAL_TIMEOUT_MS + 5_000 const SSH_KEYBOARD_INTERACTIVE_MAX_PROMPTS = 8 +// Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. +const ABORTED_CHANNEL_CLOSE_GRACE_MS = 5_000 + +// Why: MaxSessions servers can transiently refuse a channel open; a refused open never ran the command, so retry is safe. +const SESSION_LIMIT_OPEN_RETRIES = 4 +const SESSION_LIMIT_OPEN_RETRY_DELAY_MS = 150 + function cloneResolvedConfig(config: SshResolvedConfig | null): SshResolvedConfig | null { if (!config) { return null @@ -202,37 +200,6 @@ export class SshConnection { private useSystemSshTransport = false private credentialAbortController = new AbortController() private readonly pendingSsh2Clients = new Set() - // Local lifetime evidence only: a closed channel or client never proves a remote process exited. - private readonly workLedger = new SshConnectionWorkLedger( - () => this.transportClosure.notify(), - () => { - if (this.disposed) { - throw createSshOperationAbortError() - } - } - ) - private readonly transportCloseLedger = new SshTransportCloseLedger(() => - this.transportClosure.notify() - ) - private readonly transportClosure = new SshTransportClosureSubscribers( - () => - this.disposed && - this.state.status === 'disconnected' && - this.activeConnectCalls === 0 && - this.pendingSsh2Clients.size === 0 && - !this.unprovenStartupTransport && - this.transportCloseLedger.isClosed() && - this.workLedger.isDrained() - ) - private readonly automaticReconnect: boolean - // Set once drained for close; no reconnect or new transport may replace the fenced one. - private resetConnectionFenced = false - private activeConnectCalls = 0 - // Sticky: a system-ssh child's transport closure cannot be proven from here. - private unprovenStartupTransport = false - private executionDestination: - | { client: SshClient; generation: number; destination: SshConnectionDestination } - | undefined private state: SshConnectionState private callbacks: SshConnectionCallbacks private target: SshTarget @@ -247,12 +214,7 @@ export class SshConnection { private hostKeyFingerprint: string | undefined private connectGeneration = 0 - constructor( - target: SshTarget, - callbacks: SshConnectionCallbacks, - options: { automaticReconnect?: boolean } = {} - ) { - this.automaticReconnect = options.automaticReconnect !== false + constructor(target: SshTarget, callbacks: SshConnectionCallbacks) { this.target = target this.callbacks = callbacks this.state = { @@ -270,60 +232,9 @@ export class SshConnection { getConnectGeneration(): number { return this.connectGeneration } - /** Equals the connect generation; later slices name it by the transport it identifies. */ - getTransportGeneration(): number { - return this.connectGeneration - } - /** The ssh2 destination proven by the current handshake; undefined on system SSH or when stale. */ - getExecutionDestination(): SshConnectionDestination | undefined { - const captured = this.executionDestination - return !this.disposed && - !this.useSystemSshTransport && - this.state.status === 'connected' && - captured?.client === this.client && - captured?.generation === this.connectGeneration - ? captured.destination - : undefined - } - subscribeTransportClosure(onClosed: () => void): () => void { - return this.transportClosure.subscribe(onClosed) - } getClient(): SshClient | null { return this.client } - prepareForwardRoute(prepare: () => Promise): Promise { - return this.workLedger.run(prepare) - } - openForwardSocket(open: () => T): T { - return openTrackedSshSocket(this.workLedger, open, this.reportUnhandledChannelError('socket')) - } - forwardOut( - client: SshClient, - localSocket: SshConnectionWorkChannel, - ...args: Parameters - ): void { - assertSshForwardClient(this.client, client) - const report = this.reportUnhandledChannelError('forward') - forwardTrackedSshChannel(this.workLedger, client, localSocket, report, ...args) - } - forwardStreamLocal( - client: SshClient, - ...args: Parameters - ): void { - assertSshForwardClient(this.client, client) - const usable = - !this.disposed && !this.useSystemSshTransport && this.state.status === 'connected' - assertSshStreamLocalForwardAllowed(usable, args[0]) - const report = this.reportUnhandledChannelError('stream-local forward') - forwardTrackedSshStreamLocalChannel(this.workLedger, client, report, ...args) - } - /** Names the connection and channel kind when a tracked channel's error has no other handler. */ - private reportUnhandledChannelError(kind: string): (error: Error) => void { - return (error) => - console.warn( - `[ssh] Unhandled ${kind} channel error for ${this.target.label}: ${error.message}` - ) - } usesSystemSshTransport(): boolean { return this.useSystemSshTransport } @@ -358,11 +269,7 @@ export class SshConnection { return this.cachedPassphrase != null || this.cachedPassword != null } - exec(cmd: string, options?: SshExecOptions): Promise { - return this.workLedger.run(() => this.execUntracked(cmd, options)) - } - - private async execUntracked(cmd: string, options?: SshExecOptions): Promise { + async exec(cmd: string, options?: SshExecOptions): Promise { if (options?.signal?.aborted) { throw createSshOperationAbortError() } @@ -377,31 +284,21 @@ export class SshConnection { } const client = this.client const remoteCommand = options?.wrapCommand === false ? cmd : wrapRemoteCommandForPosixShell(cmd) - return openSshSessionChannelWithRetry( + return this.openSessionChannelWithRetry( () => - waitForSshChannelOpen( - this.workLedger.beginChannelOpen(), + this.waitForSshCallback( 'SSH exec channel timed out', (callback) => client.exec(remoteCommand, callback), (channel) => channel.close(), options?.signal, - true, - this.reportUnhandledChannelError('exec') + true ), options?.signal ) } /** Interactive login shell over a session channel with pty-req; ssh2 transport only. */ - shell(pty: PseudoTtyOptions, options: ShellOptions = {}): Promise { - // Plain SSH mode's terminals must drain with the rest of this connection's work. - return this.workLedger.run(() => this.shellUntracked(pty, options)) - } - - private async shellUntracked( - pty: PseudoTtyOptions, - options: ShellOptions - ): Promise { + async shell(pty: PseudoTtyOptions, options: ShellOptions = {}): Promise { if (this.useSystemSshTransport) { throw new Error('Interactive SSH shells are not available when using system SSH transport') } @@ -409,26 +306,16 @@ export class SshConnection { throw new Error('Not connected') } const client = this.client - return openSshSessionChannelWithRetry(() => - waitForSshChannelOpen( - this.workLedger.beginChannelOpen(), + return this.openSessionChannelWithRetry(() => + this.waitForSshCallback( 'SSH shell channel timed out', (callback) => client.shell(pty, options, callback), - (channel) => channel.close(), - undefined, - false, - this.reportUnhandledChannelError('shell') + (channel) => channel.close() ) ) } - sftp(options?: AbortSignal | { signal?: AbortSignal }): Promise { - return this.workLedger.run(() => this.sftpUntracked(options)) - } - - private async sftpUntracked( - options?: AbortSignal | { signal?: AbortSignal } - ): Promise { + async sftp(options?: AbortSignal | { signal?: AbortSignal }): Promise { // Why: relay transfers pass a signal directly, while filesystem factories use an options object. const signal = options && 'aborted' in options ? options : options?.signal if (signal?.aborted) { @@ -441,95 +328,373 @@ export class SshConnection { throw new Error('Not connected') } const client = this.client - return openSshSessionChannelWithRetry( + return this.openSessionChannelWithRetry( () => - waitForSshChannelOpen( - this.workLedger.beginChannelOpen(), + this.waitForSshCallback( 'SSH SFTP channel timed out', (callback) => client.sftp(callback), (sftp) => sftp.end(), - signal, - false, - this.reportUnhandledChannelError('sftp') + signal ), signal ) } - uploadDirectory( + private async openSessionChannelWithRetry( + open: () => Promise, + signal?: AbortSignal + ): Promise { + let lastError: unknown + for (let attempt = 0; attempt < SESSION_LIMIT_OPEN_RETRIES; attempt++) { + if (attempt > 0) { + // Why: an abort must release the backoff immediately, not after it. + if (!signal?.aborted) { + await new Promise((resolve) => { + const onDelayDone = (): void => { + clearTimeout(delayTimer) + signal?.removeEventListener('abort', onDelayDone) + resolve() + } + const delayTimer = setTimeout(onDelayDone, SESSION_LIMIT_OPEN_RETRY_DELAY_MS) + signal?.addEventListener('abort', onDelayDone, { once: true }) + }) + } + if (signal?.aborted) { + throw createSshOperationAbortError() + } + } + try { + return await open() + } catch (err) { + if (!isSshSessionLimitError(err)) { + throw err + } + lastError = err + } + } + throw lastError + } + + private waitForSshCallback( + timeoutMessage: string, + register: (callback: (error: Error | undefined, value: T) => void) => void, + cleanupLateValue?: (value: T) => void, + signal?: AbortSignal, + trackRemoteCommandTermination = false + ): Promise { + return new Promise((resolve, reject) => { + type ChannelOpenTerminationError = Error & { sshChannelCloseConfirmed: boolean } + let settled = false + let unconfirmedOpenError: ChannelOpenTerminationError | null = null + const markOpenUnconfirmed = (error: Error): Error => { + if (!trackRemoteCommandTermination) { + return error + } + unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false }) + return unconfirmedOpenError + } + // Why: an in-flight open holds a MaxSessions slot; reject the caller now, then settle from the open callback once the late channel closes. + let abortRequested = false + let abortDeadlineTimer: NodeJS.Timeout | undefined + const cleanup = (): void => { + clearTimeout(timer) + clearTimeout(abortDeadlineTimer) + signal?.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + abortRequested = true + // Why: a hung socket may never invoke the open callback; bound the aborted caller's wait instead of pinning it for CONNECT_TIMEOUT_MS. + abortDeadlineTimer = setTimeout(() => { + settled = true + cleanup() + reject(markOpenUnconfirmed(createSshOperationAbortError())) + }, ABORTED_CHANNEL_CLOSE_GRACE_MS) + } + const timer = setTimeout(() => { + settled = true + cleanup() + reject( + markOpenUnconfirmed( + abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage) + ) + ) + }, CONNECT_TIMEOUT_MS) + const discardLateValue = (value: T, onClose?: () => void): void => { + const emitter = value as Partial & { + resume?: () => void + stderr?: Partial & { resume?: () => void } + } + const swallowLateError = (): void => {} + emitter.on?.('error', swallowLateError) + emitter.stderr?.on?.('error', swallowLateError) + if (onClose) { + emitter.once?.('close', onClose) + } + // Why: ssh2 withholds CHANNEL_CLOSE while discarded exec streams remain unread, and teardown errors have no other owner. + emitter.resume?.() + emitter.stderr?.resume?.() + try { + cleanupLateValue?.(value) + } catch { + /* best effort */ + } + } + const rejectAfterClose = (value: T): void => { + const abortError = markOpenUnconfirmed(createSshOperationAbortError()) + const emitter = value as Partial & { + resume?: () => void + stderr?: { resume?: () => void } + } + let finished = false + const done = (): void => { + if (finished) { + return + } + finished = true + clearTimeout(closeGraceTimer) + emitter.removeListener?.('close', confirmAndDone) + reject(abortError) + } + const confirmAndDone = (): void => { + if (unconfirmedOpenError === abortError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + done() + } + // Why: bounded so a remote that never confirms the close can't hang the aborted operation forever. + const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS) + if (typeof emitter.once === 'function') { + emitter.once('close', confirmAndDone) + } + // Why: ssh2 withholds 'close' until the channel's streams are drained; nobody else will read this discarded channel. + discardLateValue(value) + if (typeof emitter.once !== 'function') { + done() + } + } + const finish = (error: Error | undefined, value?: T): void => { + if (settled) { + // Why: ssh2 can invoke the open callback after our timeout rejected; close that late channel so it isn't left open with no owner. + if (!error && value !== undefined) { + discardLateValue(value, () => { + if (unconfirmedOpenError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + }) + } + return + } + settled = true + cleanup() + if (abortRequested) { + if (!error && value !== undefined) { + rejectAfterClose(value) + } else { + reject(createSshOperationAbortError()) + } + return + } + if (error) { + reject(error) + return + } + resolve(value as T) + } + if (signal?.aborted) { + // No open is in flight yet, so failing fast leaks nothing. + cleanup() + reject(createSshOperationAbortError()) + return + } + signal?.addEventListener('abort', onAbort, { once: true }) + + try { + // Why: higher-level channel timers start only after ssh2's open callback; a stale SSH socket can otherwise keep exec/sftp stuck. + register(finish) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + async uploadDirectory( localDir: string, remoteDir: string, options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - return this.workLedger.run(() => - uploadSshDirectory(this.fileTransferHost(), localDir, remoteDir, options) + // Why: relay-deploy timeout and connection teardown are independent owners; either must stop a transfer that could outlive its lock. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + // Why: resolve on the same session that transfers — a later session is not authoritative for this one's namespace. + const transfer = (async (): Promise => { + const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options) + linkedSignal.signal.throwIfAborted() + const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') + await uploadDirectory(sftp, localDir, targetDir, localDir, { + signal: linkedSignal.signal + }) + })() + await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + return () => sftp.removeListener('close', onClose) + }) + } finally { + endSftp() + } + return + } + await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() + } } - downloadFile( + async downloadFile( remotePath: string, localPath: string, options?: SshRemoteFileOptions ): Promise { - return this.workLedger.run(() => - downloadSshFile(this.fileTransferHost(), remotePath, localPath, options) - ) + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + try { + const { fastGetViaSftp } = await import('../providers/ssh-filesystem-provider-sftp') + await fastGetViaSftp(sftp, remotePath, localPath) + } finally { + sftp.end() + } + return + } + await downloadFileViaSystemSsh(this.target, remotePath, localPath, { + signal: this.systemOperationAbortController.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) } - openFileUploadSession(options?: SshRemoteFileOptions): Promise { - return openTrackedSshUploadSession(this.workLedger, () => - createSshFileUploadSession(this.fileTransferHost(), options) - ) + async openFileUploadSession(options?: SshRemoteFileOptions): Promise { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + const { uploadFile } = await import('./sftp-upload') + return { + uploadFile: (localPath, remotePath, uploadOptions) => + uploadFile(sftp, localPath, remotePath, uploadOptions), + close: () => sftp.end() + } + } + // Why: disconnect replaces the connection controller, so an existing import session must stay bound to the signal and SSH config it opened with. + const signal = this.systemOperationAbortController.signal + const buildArgsOptions = this.getSystemSshBuildArgsOptions() + return { + uploadFile: (localPath, remotePath, uploadOptions) => + uploadFileViaSystemSsh(this.target, localPath, remotePath, { + signal, + hostPlatform: options?.hostPlatform, + exclusive: uploadOptions?.exclusive, + ...buildArgsOptions + }), + close: () => {} + } } - writeFile( + async writeFile( remotePath: string, contents: string, options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - return this.workLedger.run(() => - writeSshFile(this.fileTransferHost(), remotePath, contents, options) + // Keep package/version writes under the same dual cancellation contract as uploads. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + // Why: resolve on the same session that writes — a later session is not authoritative for this one's namespace. + const write = (async (): Promise => { + const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options) + linkedSignal.signal.throwIfAborted() + const { writeStringViaSftp } = await import('./sftp-upload') + await writeStringViaSftp(sftp, targetPath, contents) + })() + await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + return () => sftp.removeListener('close', onClose) + }) + } finally { + endSftp() + } + return + } + await writeFileViaSystemSsh(this.target, remotePath, contents, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() + } } - writeBuffer( + async writeBuffer( remotePath: string, contents: Buffer, options?: SshRemoteFileOptions & { append?: boolean; exclusive?: boolean } ): Promise { - return this.workLedger.run(() => - writeSshBuffer(this.fileTransferHost(), remotePath, contents, options) - ) - } - - private fileTransferHost(): SshFileTransferHost { - return { - target: this.target, - usesSystemSshTransport: () => this.useSystemSshTransport, - systemOperationSignal: () => this.systemOperationAbortController.signal, - systemSshBuildArgsOptions: () => this.getSystemSshBuildArgsOptions(), - sftp: (signal) => this.sftp(signal) + if (!this.useSystemSshTransport) { + const sftp = await this.sftp() + try { + const { uploadBuffer } = await import('./sftp-upload') + await uploadBuffer(sftp, contents, remotePath, options) + } finally { + sftp.end() + } + return } + await writeBufferViaSystemSsh(this.target, remotePath, contents, { + signal: this.systemOperationAbortController.signal, + hostPlatform: options?.hostPlatform, + append: options?.append, + exclusive: options?.exclusive, + ...this.getSystemSshBuildArgsOptions() + }) } async connect(): Promise { - this.activeConnectCalls++ - try { - await this.connectInitialAttempts() - } finally { - this.activeConnectCalls-- - this.transportClosure.notify() - } - } - - private assertConnectionReplacementAllowed(): void { - if (this.resetConnectionFenced) { - throw new Error('ssh_connection_reset_replacement_refused') - } - } - - private async connectInitialAttempts(): Promise { - this.assertConnectionReplacementAllowed() if (this.disposed) { throw new Error('Connection disposed') } @@ -537,7 +702,6 @@ export class SshConnection { let lastError: Error | null = null for (let attempt = 0; attempt < INITIAL_RETRY_ATTEMPTS; attempt++) { - this.assertConnectionReplacementAllowed() const connectGeneration = ++this.connectGeneration try { await this.attemptConnect(connectGeneration) @@ -655,7 +819,6 @@ export class SshConnection { } private async attemptConnect(connectGeneration = ++this.connectGeneration): Promise { - this.executionDestination = undefined this.credentialAbortController.abort() this.credentialAbortController = new AbortController() this.setState('connecting') @@ -704,10 +867,8 @@ export class SshConnection { // Why: ssh2 doesn't support ProxyCommand/ProxyJump natively; spawn the resolved proxy and pipe its stdin/stdout as config.sock. const effectiveProxy = resolveEffectiveProxy(this.target, resolved) - const proxyRouteDigest = sshProxyRouteDigest(effectiveProxy) if (effectiveProxy) { const proxy = spawnProxyCommand(effectiveProxy, config.host!, config.port!, config.username!) - this.transportCloseLedger.track(proxy.process) this.proxyProcess = proxy.process config.sock = proxy.sock } @@ -720,7 +881,7 @@ export class SshConnection { } try { - await this.doSsh2Connect(config, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(config, connectGeneration) } catch (err) { if (!(err instanceof Error)) { this.proxyProcess?.kill() @@ -796,7 +957,7 @@ export class SshConnection { if (keyConfig.privateKey || keyConfig.password) { this.respawnProxy(keyConfig, effectiveProxy) try { - await this.doSsh2Connect(keyConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(keyConfig, connectGeneration) return } catch (keyErr) { // Same reason as above: the retry re-runs the handshake, so it can be the attempt that @@ -831,7 +992,7 @@ export class SshConnection { this.cachedPassphrase = val keyConfig.passphrase = val this.respawnProxy(keyConfig, effectiveProxy) - await this.doSsh2Connect(keyConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(keyConfig, connectGeneration) return } } @@ -881,7 +1042,7 @@ export class SshConnection { this.cachedPassphrase = val credentialRetryConfig.passphrase = val this.respawnProxy(credentialRetryConfig, effectiveProxy) - await this.doSsh2Connect(credentialRetryConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(credentialRetryConfig, connectGeneration) return } } @@ -896,7 +1057,7 @@ export class SshConnection { this.cachedPassword = val credentialRetryConfig.password = val this.respawnProxy(credentialRetryConfig, effectiveProxy) - await this.doSsh2Connect(credentialRetryConfig, connectGeneration, proxyRouteDigest) + await this.doSsh2Connect(credentialRetryConfig, connectGeneration) return } } @@ -907,7 +1068,6 @@ export class SshConnection { } async reconnect(): Promise { - this.assertConnectionReplacementAllowed() if (this.disposed || this.state.status === 'connecting') { return } @@ -924,7 +1084,6 @@ export class SshConnection { } private async doSystemSshProbe(connectGeneration: number): Promise { - this.unprovenStartupTransport = true this.useSystemSshTransport = true this.client = null this.proxyProcess?.kill() @@ -1085,7 +1244,6 @@ export class SshConnection { if (!this.isCurrentConnectAttempt(connectGeneration)) { throw this.createCancelledConnectAttemptError() } - this.unprovenStartupTransport = true const proc = spawnSystemSsh(this.target, this.getSystemSshBuildArgsOptions()) this.systemSsh = proc let settled = false @@ -1176,18 +1334,10 @@ export class SshConnection { options === undefined && Object.keys(buildArgsOptions).length === 0 ? undefined : { ...options, ...buildArgsOptions } - const work = this.workLedger.beginChannelOpen() - let channel: ClientChannel - try { - channel = - commandOptions === undefined - ? spawnSystemSshCommand(this.target, command) - : spawnSystemSshCommand(this.target, command, commandOptions) - trackSshConnectionChannelLifetime(work, channel, this.reportUnhandledChannelError('command')) - } catch (error) { - work.close(error) - throw error - } + const channel = + commandOptions === undefined + ? spawnSystemSshCommand(this.target, command) + : spawnSystemSshCommand(this.target, command, commandOptions) this.systemCommandChannels.add(channel) const onAbort = (): void => { channel.close() @@ -1231,7 +1381,6 @@ export class SshConnection { } this.proxyProcess?.kill() const p = spawnProxyCommand(proxy, config.host!, config.port!, config.username!) - this.transportCloseLedger.track(p.process) this.proxyProcess = p.process config.sock = p.sock } @@ -1284,16 +1433,7 @@ export class SshConnection { return sources } - private async doSsh2Connect( - config: ConnectConfig, - connectGeneration: number, - proxyRouteDigest: string - ): Promise { - const endpoint = { - host: config.host ?? '', - port: config.port ?? 22, - username: config.username ?? '' - } + private async doSsh2Connect(config: ConnectConfig, connectGeneration: number): Promise { const hostKeyResolved = this.hostKeyResolvedConfig const { host: hostKeyLookupHost, isHostKeyAlias } = resolveKnownHostsLookupHost( hostKeyResolved, @@ -1336,7 +1476,6 @@ export class SshConnection { : undefined return new Promise((resolve, reject) => { const client = new SshClient() - this.transportCloseLedger.track(client) this.pendingSsh2Clients.add(client) let settled = false let startupTimer: ReturnType | null = null @@ -1359,7 +1498,6 @@ export class SshConnection { // the live attempt's error, and substituting a new Error drops ssh2's `code`, so a transient // ECONNRESET would stop being classified as retryable. let hostKeyRejection: HostKeyVerificationError | null = null - let acceptedFingerprint: string | undefined // Why the fingerprint is still recorded: the relay uses the negotiated server key to isolate // shared-home install locks without comparing PIDs from an unrelated SSH host. Its format is @@ -1373,7 +1511,7 @@ export class SshConnection { hostKeyStoreFile: boundSshHostKeyStoreFile() ?? undefined, strictHostKeyChecking: hostKeyResolved?.strictHostKeyChecking ?? 'ask', isHostKeyAlias, - isEphemeralRuntimeTarget: isEphemeralRuntimeSshOwner(this.target.owner), + isEphemeralRuntimeTarget: this.target.owner?.type === 'on-demand-runtime', siteConfigSuppressed, // A file that EXISTS and will not open is the absence of evidence, not evidence of a new // host — the entry that would have said "this key changed" may be in it. An ABSENT file is @@ -1411,7 +1549,6 @@ export class SshConnection { connectGeneration === this.connectGeneration ) { this.hostKeyFingerprint = decision.fingerprint - acceptedFingerprint = decision.fingerprint } if (decision.action === 'reject') { hostKeyRejection = new HostKeyVerificationError( @@ -1510,20 +1647,6 @@ export class SshConnection { } settled = true this.client = client - // Only this successful handshake can publish recovery destination authority. - this.executionDestination = acceptedFingerprint - ? { - client, - generation: connectGeneration, - destination: Object.freeze({ - version: 1, - transport: 'ssh2', - ...endpoint, - hostKeyFingerprint: acceptedFingerprint, - proxyRouteDigest - }) - } - : undefined this.proxyProcess = null this.setupDisconnectHandler(client) cleanupStartupListeners() @@ -1581,7 +1704,6 @@ export class SshConnection { if (this.disposed || this.client !== client) { return } - this.workLedger.markTransportUnverifiable() this.client = null this.scheduleReconnect() } @@ -1592,18 +1714,13 @@ export class SshConnection { return } console.warn(`[ssh] Connection error for ${this.target.label}: ${err.message}`) - this.workLedger.markTransportUnverifiable() this.client = null this.scheduleReconnect() }) } private scheduleReconnect(): void { - if (this.disposed || this.resetConnectionFenced || this.reconnectTimer) { - return - } - if (!this.automaticReconnect) { - this.setState('disconnected') + if (this.disposed || this.reconnectTimer) { return } const decision = this.reconnectLadder.next(Date.now()) @@ -1618,7 +1735,7 @@ export class SshConnection { ) this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null - if (this.disposed || this.resetConnectionFenced) { + if (this.disposed) { return } await this.runReconnectAttempt() @@ -1626,19 +1743,6 @@ export class SshConnection { } private async runReconnectAttempt(): Promise { - this.activeConnectCalls++ - try { - await this.runTrackedReconnectAttempt() - } finally { - this.activeConnectCalls-- - this.transportClosure.notify() - } - } - - private async runTrackedReconnectAttempt(): Promise { - if (this.resetConnectionFenced) { - return - } const connectGeneration = ++this.connectGeneration try { // Why: reset before connecting so the 'connected' broadcast carries reconnectAttempt=0, which ssh.ts uses to trigger relay re-establishment. @@ -1714,21 +1818,9 @@ export class SshConnection { } async connectViaSystemSsh(): Promise { - this.activeConnectCalls++ - try { - return await this.connectTrackedViaSystemSsh() - } finally { - this.activeConnectCalls-- - this.transportClosure.notify() - } - } - - private async connectTrackedViaSystemSsh(): Promise { - this.assertConnectionReplacementAllowed() if (this.disposed) { throw new Error('Connection disposed') } - this.unprovenStartupTransport = true const connectGeneration = ++this.connectGeneration this.systemSsh?.kill() this.systemSsh = null @@ -1762,7 +1854,6 @@ export class SshConnection { // Why: register the reconnect handler only after handshake succeeds (the onExit above guards with `settled`). proc.onExit(() => { if (!this.disposed && this.systemSsh === proc) { - this.workLedger.markTransportUnverifiable() this.systemSsh = null this.scheduleReconnect() } @@ -1781,60 +1872,7 @@ export class SshConnection { } } - /** Owned migration transport only; local closure never proves remote process exit. */ - async disconnectAndDrain(signal: AbortSignal): Promise { - signal.throwIfAborted() - // An unconnected non-reconnecting owner has nothing live left to fence. - const settledExclusiveStartup = - !this.automaticReconnect && - this.activeConnectCalls === 0 && - this.pendingSsh2Clients.size === 0 && - !this.unprovenStartupTransport - if ( - this.useSystemSshTransport || - this.unprovenStartupTransport || - this.activeConnectCalls > 0 || - (!this.client && !settledExclusiveStartup) - ) { - await this.disconnect() - throw new Error('ssh_connection_close_transport_unproven') - } - let fence: ReturnType - try { - if ( - this.client && - (this.disposed || this.state.status !== 'connected' || this.pendingSsh2Clients.size > 0) - ) { - throw new Error('ssh_connection_reset_transport_not_connected') - } - fence = this.workLedger.fenceForReset() - this.resetConnectionFenced = true - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - } catch (error) { - try { - await this.disconnect() - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'ssh_connection_close_cleanup_failed') - } - throw error - } - await disconnectAndAwaitSshTransportClose({ - liveResources: [ - ...(this.client ? [this.client] : []), - ...(this.proxyProcess ? [this.proxyProcess] : []) - ], - disconnect: () => this.disconnect(), - transportCloseLedger: this.transportCloseLedger, - fence, - signal - }) - } - async disconnect(): Promise { - this.executionDestination = undefined this.disposed = true this.connectGeneration += 1 if (this.reconnectTimer) { @@ -1863,7 +1901,6 @@ export class SshConnection { this.useSystemSshTransport = false this.reconnectLadder.reset() this.setState('disconnected') - this.transportClosure.notify() } private setState(status: SshConnectionStatus, error?: string): void { diff --git a/src/main/ssh/ssh-forward-channel-lifetime.test.ts b/src/main/ssh/ssh-forward-channel-lifetime.test.ts deleted file mode 100644 index ae9bc18ef76..00000000000 --- a/src/main/ssh/ssh-forward-channel-lifetime.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { EventEmitter } from 'node:events' -import type { Client, ClientChannel } from 'ssh2' -import { expect, it, vi } from 'vitest' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import { forwardTrackedSshChannel } from './ssh-forward-channel-lifetime' - -function fixture() { - const ledger = new SshConnectionWorkLedger() - const local = new EventEmitter() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ledger only needs the channel's EventEmitter surface. - const remote = new EventEmitter() as ClientChannel - let opened!: NonNullable[4]> - const client = { - forwardOut: vi.fn((_a, _b, _c, _d, callback) => { - opened = callback - }) - } - const callback = vi.fn() - const start = () => - forwardTrackedSshChannel( - ledger, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the forwardOut the helper calls. - client as unknown as Client, - local, - undefined, - '127.0.0.1', - 0, - 'remote.internal', - 443, - callback - ) - return { ledger, local, remote, client, callback, start, opened: () => opened } -} - -it('tracks a pending open and both physical ends without terminating either', async () => { - const f = fixture() - f.start() - const fence = f.ledger.fenceForReset() - expect(fence.assertDrained).toThrow('not_drained') - f.opened()(undefined, f.remote) - expect(f.callback).toHaveBeenCalledWith(undefined, f.remote) - f.remote.emit('close') - expect(fence.assertDrained).toThrow('not_drained') - f.local.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('retains late channels after the local socket closes', async () => { - const f = fixture() - f.start() - f.local.emit('close') - const fence = f.ledger.fenceForReset() - f.opened()(undefined, f.remote) - expect(fence.assertDrained).toThrow('not_drained') - f.remote.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('refuses fenced opens before forwarding or subscribing to the new socket', () => { - const f = fixture() - f.ledger.fenceForReset() - expect(f.start).toThrow('admission_closed') - expect(f.client.forwardOut).not.toHaveBeenCalled() - expect(f.local.listenerCount('close')).toBe(0) -}) - -it('retains open failures during reset rather than claiming successful drain', async () => { - const f = fixture() - f.start() - const fence = f.ledger.fenceForReset() - const failure = new Error('forward failed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ssh2 passes no channel on a refused open; the callback type omits that case. - f.opened()(failure, undefined as never) - f.local.emit('close') - await expect(fence.drain(new AbortController().signal)).rejects.toBe(failure) -}) - -it('retains synchronous open uncertainty even when reset begins later', async () => { - const f = fixture() - const failure = new Error('transport failed') - f.client.forwardOut.mockImplementation(() => { - throw failure - }) - expect(f.start).toThrow(failure) - f.local.emit('close') - await expect(f.ledger.fenceForReset().drain(new AbortController().signal)).rejects.toBe(failure) -}) - -it('aborting observation preserves the admitted sockets and allows another drain', async () => { - const f = fixture() - f.start() - const fence = f.ledger.fenceForReset() - const observer = new AbortController() - const draining = fence.drain(observer.signal).catch((error: unknown) => error) - const failure = new Error('observer stopped') - observer.abort(failure) - expect(await draining).toBe(failure) - f.opened()(undefined, f.remote) - f.local.emit('close') - f.remote.emit('close') - await fence.drain(new AbortController().signal) -}) diff --git a/src/main/ssh/ssh-forward-channel-lifetime.ts b/src/main/ssh/ssh-forward-channel-lifetime.ts deleted file mode 100644 index 902b40b66ee..00000000000 --- a/src/main/ssh/ssh-forward-channel-lifetime.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Client } from 'ssh2' -import { - trackSshConnectionChannelLifetime, - type SshChannelErrorReporter -} from './ssh-connection-channel-lifetime' -import type { - SshConnectionWorkChannel, - SshConnectionWorkLedger -} from './ssh-connection-work-ledger' - -/** A forward must ride the connection's current client, never one a reconnect replaced. */ -export function assertSshForwardClient(current: Client | null, requested: Client): void { - if (current !== requested) { - throw new Error('ssh_connection_forward_client_changed') - } -} - -/** Remote stream-local forwarding needs a live ssh2 transport and an absolute Unix socket path. */ -export function assertSshStreamLocalForwardAllowed( - transportUsable: boolean, - socketPath: string -): void { - if (!transportUsable) { - throw new Error('ssh_connection_streamlocal_transport_unavailable') - } - if (!socketPath.startsWith('/') || socketPath.includes('\0')) { - throw new Error('ssh_connection_streamlocal_endpoint_invalid') - } -} - -/** The channel remains tracked until physical close, including late open replies. */ -export function forwardTrackedSshStreamLocalChannel( - ledger: SshConnectionWorkLedger, - client: Client, - onUnhandledError: SshChannelErrorReporter | undefined, - ...args: Parameters -): void { - const opening = ledger.beginChannelOpen() - const [socketPath, callback] = args - try { - client.openssh_forwardOutStreamLocal(socketPath, (error, channel) => { - if (error) { - opening.close(error) - } else { - trackSshConnectionChannelLifetime(opening, channel, onUnhandledError) - } - callback(error, channel) - }) - } catch (error) { - opening.markUnverifiable(error instanceof Error ? error : new Error(String(error))) - throw error - } -} - -export function forwardTrackedSshChannel( - ledger: SshConnectionWorkLedger, - client: Client, - localSocket: SshConnectionWorkChannel, - onUnhandledError: SshChannelErrorReporter | undefined, - ...args: Parameters -): void { - const opening = ledger.beginChannelOpen() - trackSshConnectionChannelLifetime(ledger.beginChannelOpen(), localSocket, onUnhandledError) - const [sourceHost, sourcePort, host, port, callback] = args - try { - client.forwardOut(sourceHost, sourcePort, host, port, (error, channel) => { - if (error) { - opening.close(error) - } else { - trackSshConnectionChannelLifetime(opening, channel, onUnhandledError) - } - callback?.(error, channel) - }) - } catch (error) { - opening.markUnverifiable(error instanceof Error ? error : new Error(String(error))) - throw error - } -} diff --git a/src/main/ssh/ssh-forward-socket-admission.test.ts b/src/main/ssh/ssh-forward-socket-admission.test.ts deleted file mode 100644 index e337eab6865..00000000000 --- a/src/main/ssh/ssh-forward-socket-admission.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { openTrackedSshSocket } from './ssh-connection-channel-lifetime' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -it('acquires admission before a socket factory can connect', () => { - const ledger = new SshConnectionWorkLedger() - const open = vi.fn(() => new EventEmitter()) - ledger.fenceForReset() - expect(() => openTrackedSshSocket(ledger, open)).toThrow('admission_closed') - expect(open).not.toHaveBeenCalled() -}) - -it('waits for actual close rather than the destroyed flag', async () => { - const ledger = new SshConnectionWorkLedger() - const socket = openTrackedSshSocket(ledger, () => - Object.assign(new EventEmitter(), { destroyed: false }) - ) - const fence = ledger.fenceForReset() - socket.destroyed = true - expect(fence.assertDrained).toThrow('not_drained') - socket.emit('close') - await fence.drain(new AbortController().signal) -}) - -it('retains socket-construction uncertainty without accepting a later reset', async () => { - const ledger = new SshConnectionWorkLedger() - const failure = new Error('socket setup failed') - expect(() => - openTrackedSshSocket(ledger, () => { - throw failure - }) - ).toThrow(failure) - await expect(ledger.fenceForReset().drain(new AbortController().signal)).rejects.toBe(failure) -}) diff --git a/src/main/ssh/ssh-multi-factor-authentication.test.ts b/src/main/ssh/ssh-multi-factor-authentication.test.ts index 4b22c9e6157..2ee643dea7a 100644 --- a/src/main/ssh/ssh-multi-factor-authentication.test.ts +++ b/src/main/ssh/ssh-multi-factor-authentication.test.ts @@ -1,6 +1,5 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' -import type * as Os from 'node:os' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { Client, @@ -11,16 +10,11 @@ import { type KeyboardAuthContext, type PasswordAuthContext } from 'ssh2' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' import { buildConnectConfig } from './ssh-connection-utils' -vi.mock('node:os', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, homedir: vi.fn(() => actual.tmpdir()) } -}) - // OpenSSH's default; a host that burns it disconnects before the MFA stage is reached. const MAX_AUTH_TRIES = 6 const PASSWORD = 'stage-one-password' @@ -192,11 +186,19 @@ function connectWithOrcaConfig( describe('multi-stage SSH authentication', () => { let tempDir: string let keyPaths: string[] + let homeEnv: { HOME?: string; USERPROFILE?: string } beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'orca-mfa-')) - // Default-key discovery must never read the developer's SSH credentials. - vi.mocked(homedir).mockReturnValue(tempDir) + // Why: the cases below pass `resolved: null`, so `resolvePrivateKeys` falls through to + // `findDefaultKeyFile`, which reads `~/.ssh/id_*` through `homedir()`. On a developer + // machine that picks up a real key, and an encrypted one makes ssh2 reject with + // "Cannot parse privateKey" before authentication is exercised at all. Hosted CI has no + // key, so this only ever failed locally. Pointing home at the fixture directory keeps + // default-key discovery inside the test's control on every machine. + homeEnv = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE } + process.env.HOME = tempDir + process.env.USERPROFILE = tempDir keyPaths = ['id_a', 'id_b'].map((name) => { const path = join(tempDir, name) writeFileSync(path, utils.generateKeyPairSync('ecdsa', { bits: 256 }).private) @@ -205,7 +207,14 @@ describe('multi-stage SSH authentication', () => { }) afterEach(() => { - vi.mocked(homedir).mockReturnValue(tmpdir()) + for (const key of ['HOME', 'USERPROFILE'] as const) { + const previous = homeEnv[key] + if (previous === undefined) { + delete process.env[key] + } else { + process.env[key] = previous + } + } rmSync(tempDir, { recursive: true, force: true }) }) diff --git a/src/main/ssh/ssh-provider-continuations.ts b/src/main/ssh/ssh-provider-continuations.ts deleted file mode 100644 index 418c1d89676..00000000000 --- a/src/main/ssh/ssh-provider-continuations.ts +++ /dev/null @@ -1,24 +0,0 @@ -const pendingByTarget = new Map>() - -/** Tracks local continuation settlement, never host execution or cancellation acknowledgment. */ -export async function runSshProviderContinuation( - targetId: string, - operation: () => Promise -): Promise { - const pending = pendingByTarget.get(targetId) ?? new Set() - const token = {} - pendingByTarget.set(targetId, pending) - pending.add(token) - try { - return await operation() - } finally { - pending.delete(token) - if (pending.size === 0 && pendingByTarget.get(targetId) === pending) { - pendingByTarget.delete(targetId) - } - } -} - -export function hasSshProviderContinuations(targetId: string): boolean { - return (pendingByTarget.get(targetId)?.size ?? 0) > 0 -} diff --git a/src/main/ssh/ssh-pty-consumer-resume-session.test.ts b/src/main/ssh/ssh-pty-consumer-resume-session.test.ts deleted file mode 100644 index 60ccf6cad12..00000000000 --- a/src/main/ssh/ssh-pty-consumer-resume-session.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { openSshPtyConsumerSession, resumeSshPtyConsumerSession } from './ssh-pty-consumer-session' - -function fixture() { - const controller = new AbortController() - const grant = { - protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - serverBuildId: 'build', - role: 'session-owner', - clientGeneration: 3, - ownerGeneration: 3, - ownerLease: 'owner', - resumed: true, - capabilities: { outputFlowControl: { version: 1, windowSu: 256 } } - } - const request = vi.fn().mockResolvedValue(grant) - const isDisposed = vi.fn(() => false) - const mux: Pick = { request, isDisposed } - const options = { - clientInstanceId: 'client', - expectedServerBuildId: 'build', - resume: { ownerGeneration: 2, ownerLease: 'owner' }, - outputFlowControl: { requestedWindowSu: 512 }, - signal: controller.signal, - assertAuthority: vi.fn() - } - const run = () => resumeSshPtyConsumerSession(mux, options) - return { grant, request, isDisposed, mux, options, run, controller } -} - -it('resumes through only the dedicated method and passes cancellation to transport', async () => { - const f = fixture() - await expect(f.run()).resolves.toEqual({ - resumed: true, - state: { - mode: 'negotiated', - clientInstanceId: 'client', - clientGeneration: 3, - ownerGeneration: 3, - ownerLease: 'owner', - outputFlowControl: { version: 1, windowSu: 256 } - } - }) - expect(f.request).toHaveBeenCalledExactlyOnceWith( - 'pty.resumeClient', - { - protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - clientInstanceId: 'client', - requestedRole: 'session-owner', - resume: { ownerGeneration: 2, ownerLease: 'owner' }, - capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: 512 } } - }, - { timeoutMs: 10_000, signal: f.controller.signal } - ) - expect(f.options.assertAuthority).toHaveBeenCalledTimes(2) -}) - -it.each([-32601, -32000])( - 'never falls back on host error %s, even with legacy runtime option', - async (code) => { - const f = fixture() - const error = Object.assign(new Error('host-refusal'), { code }) - f.request.mockRejectedValue(error) - const options = { ...f.options, allowSameBuildLegacyFallback: true } - const refusal = resumeSshPtyConsumerSession(f.mux, options) - // An old relay's method-not-found becomes a typed refusal; other host errors pass through. - const expected = - code === -32601 ? { message: 'pty_consumer_resume_unsupported', cause: error } : error - await expect(refusal).rejects.toMatchObject(expected) - expect(f.request).toHaveBeenCalledOnce() - expect(f.request.mock.calls[0][0]).toBe('pty.resumeClient') - } -) - -it.each([ - { ownerGeneration: 0 }, - { ownerGeneration: -1 }, - { ownerGeneration: 1.5 }, - { ownerGeneration: Number.MAX_SAFE_INTEGER + 1 }, - { ownerLease: '' }, - { ownerLease: 'x'.repeat(513) } -])('refuses invalid resume proof before RPC %j', async (proof) => { - const f = fixture() - Object.assign(f.options.resume, proof) - await expect(f.run()).rejects.toThrow('resume_required') - expect(f.request).not.toHaveBeenCalled() -}) - -it('refuses absent resume proof before RPC', async () => { - const f = fixture() - Reflect.deleteProperty(f.options, 'resume') - await expect(f.run()).rejects.toThrow('resume_required') - expect(f.request).not.toHaveBeenCalled() -}) - -it.each(['clientInstanceId', 'expectedServerBuildId'] as const)( - 'refuses missing %s before RPC', - async (field) => { - const f = fixture() - f.options[field] = '' - await expect(f.run()).rejects.toThrow('resume_required') - expect(f.request).not.toHaveBeenCalled() - } -) - -it.each([ - { ownerLease: 'different' }, - { ownerGeneration: 2 }, - { ownerGeneration: 1 }, - { resumed: false }, - { resumed: undefined }, - { ownerGeneration: 0 }, - { clientGeneration: 0 }, - { serverBuildId: 'other' }, - { role: 'observer' }, - { capabilities: undefined } -])('refuses invalid or non-resumed grant %j', async (change) => { - const f = fixture() - Object.assign(f.grant, change) - await expect(f.run()).rejects.toThrow() - expect(f.request).toHaveBeenCalledOnce() -}) - -it.each(['before', 'after'] as const)('refuses lost native authority %s RPC', async (when) => { - const f = fixture() - const revoke = () => - f.options.assertAuthority.mockImplementation(() => { - throw new Error('native-revoked') - }) - if (when === 'before') { - revoke() - } else { - f.request.mockImplementation(async () => { - await Promise.resolve() - revoke() - return f.grant - }) - } - await expect(f.run()).rejects.toThrow('native-revoked') - expect(f.request).toHaveBeenCalledTimes(when === 'before' ? 0 : 1) -}) - -it.each(['before', 'after'] as const)('refuses abort %s RPC', async (when) => { - const f = fixture() - if (when === 'before') { - f.controller.abort(new Error('aborted')) - } else { - f.request.mockImplementation(async () => { - await Promise.resolve() - f.controller.abort(new Error('aborted')) - return f.grant - }) - } - await expect(f.run()).rejects.toThrow('aborted') - expect(f.request).toHaveBeenCalledTimes(when === 'before' ? 0 : 1) -}) - -it.each(['before', 'after'] as const)('refuses disposed mux %s RPC', async (when) => { - const f = fixture() - if (when === 'before') { - f.isDisposed.mockReturnValue(true) - } else { - f.request.mockImplementation(async () => { - await Promise.resolve() - f.isDisposed.mockReturnValue(true) - return f.grant - }) - } - await expect(f.run()).rejects.toThrow('transport_closed') - expect(f.request).toHaveBeenCalledTimes(when === 'before' ? 0 : 1) -}) - -it('pins the original proof across await rather than accepting caller mutation', async () => { - const f = fixture() - f.request.mockImplementation(async () => { - await Promise.resolve() - f.options.resume.ownerLease = 'replacement' - f.options.resume.ownerGeneration = 100 - return f.grant - }) - await expect(f.run()).resolves.toMatchObject({ - resumed: true, - state: { ownerLease: 'owner', ownerGeneration: 3 } - }) - expect(f.request.mock.calls[0][1].resume).toEqual({ ownerLease: 'owner', ownerGeneration: 2 }) -}) - -it('leaves an old relay channel usable for openClient after it refuses pty.resumeClient', async () => { - const f = fixture() - f.request.mockRejectedValueOnce(Object.assign(new Error('Method not found'), { code: -32601 })) - await expect(f.run()).rejects.toThrow('pty_consumer_resume_unsupported') - expect(f.isDisposed()).toBe(false) - const { capabilities: _flow, ...plainGrant } = f.grant - f.request.mockResolvedValueOnce({ - ...plainGrant, - clientGeneration: 1, - ownerGeneration: 1, - resumed: false - }) - - const admission = await openSshPtyConsumerSession(f.mux, { - clientInstanceId: 'client', - expectedServerBuildId: 'build' - }) - - expect(admission).toMatchObject({ resumed: false, state: { mode: 'negotiated' } }) - expect(f.request.mock.calls.map(([method]) => method)).toEqual([ - 'pty.resumeClient', - 'pty.openClient' - ]) -}) diff --git a/src/main/ssh/ssh-pty-consumer-session.ts b/src/main/ssh/ssh-pty-consumer-session.ts index 3747a788762..aa58534909b 100644 --- a/src/main/ssh/ssh-pty-consumer-session.ts +++ b/src/main/ssh/ssh-pty-consumer-session.ts @@ -1,6 +1,5 @@ import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - PTY_CONSUMER_RESUME_CLIENT_METHOD, type PtyConsumerSessionGrant } from '../../shared/pty-consumer-session' import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' @@ -99,90 +98,13 @@ function validateGrant( } export async function openSshPtyConsumerSession( - mux: Pick, + mux: SshChannelMultiplexer, options: OpenSshPtyConsumerSessionOptions -): Promise { - return requestPtyConsumerSession(mux, options, SSH_PTY_OPEN_CLIENT_METHOD) -} - -/** Dedicated RPC: old hosts refuse without minting a replacement claim. */ -export async function resumeSshPtyConsumerSession( - mux: Pick, - options: Omit & { - resume: NonNullable - signal: AbortSignal - assertAuthority: () => void - } -): Promise { - const resume = { ...options.resume } - const signal = options.signal - const assertAuthority = options.assertAuthority - const isDisposed = mux.isDisposed.bind(mux) - const assertCurrent = () => { - signal.throwIfAborted() - assertAuthority() - if (isDisposed()) { - throw new Error('pty_consumer_resume_transport_closed') - } - } - assertCurrent() - if ( - !Number.isSafeInteger(resume.ownerGeneration) || - resume.ownerGeneration <= 0 || - typeof resume.ownerLease !== 'string' || - !resume.ownerLease || - resume.ownerLease.length > 512 || - !options.clientInstanceId || - !options.expectedServerBuildId - ) { - throw new Error('pty_consumer_resume_required') - } - let admission: SshPtyConsumerAdmission - try { - admission = await requestPtyConsumerSession( - mux, - { - clientInstanceId: options.clientInstanceId, - expectedServerBuildId: options.expectedServerBuildId, - resume, - ...(options.outputFlowControl - ? { outputFlowControl: { ...options.outputFlowControl } } - : {}), - allowSameBuildLegacyFallback: false - }, - PTY_CONSUMER_RESUME_CLIENT_METHOD, - signal - ) - } catch (error) { - // Why: a relay that predates pty.resumeClient answers method-not-found; that is a refusal of - // resume on a still-open channel, not a transport failure, so callers can fall back to openClient. - if (typeof error === 'object' && error !== null && 'code' in error && error.code === -32601) { - throw new Error('pty_consumer_resume_unsupported', { cause: error }) - } - throw error - } - assertCurrent() - if ( - !admission.resumed || - admission.state.mode !== 'negotiated' || - admission.state.ownerLease !== resume.ownerLease || - admission.state.ownerGeneration <= resume.ownerGeneration - ) { - throw new Error('pty_consumer_resume_grant_mismatch') - } - return admission -} - -async function requestPtyConsumerSession( - mux: Pick, - options: OpenSshPtyConsumerSessionOptions, - method: string, - signal?: AbortSignal ): Promise { let result: unknown try { result = await mux.request( - method, + SSH_PTY_OPEN_CLIENT_METHOD, { protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, clientInstanceId: options.clientInstanceId, @@ -199,7 +121,7 @@ async function requestPtyConsumerSession( } : {}) }, - { timeoutMs: SSH_PTY_OPEN_CLIENT_TIMEOUT_MS, ...(signal ? { signal } : {}) } + { timeoutMs: SSH_PTY_OPEN_CLIENT_TIMEOUT_MS } ) } catch (error) { const code = (error as { code?: unknown })?.code diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts index 10b2b4c3763..e7d5fea353c 100644 --- a/src/main/ssh/ssh-relay-exec-command.ts +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -47,7 +47,7 @@ export function isUnconfirmedSshCommandTermination( } export async function execCommand( - conn: Pick, + conn: SshConnection, command: string, options?: ExecCommandOptions ): Promise { diff --git a/src/main/ssh/ssh-relay-exec-input.test.ts b/src/main/ssh/ssh-relay-exec-input.test.ts deleted file mode 100644 index c5b6ffdeeb1..00000000000 --- a/src/main/ssh/ssh-relay-exec-input.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough, Readable } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import { execCommand } from './ssh-relay-exec-command' - -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('node:child_process', () => ({ spawn: spawnMock })) -vi.mock('./system-ssh-binary', () => ({ findSystemSsh: () => '/usr/bin/ssh' })) -import { spawnSystemSshCommand } from './system-ssh-command' - -function fakeSshProcess() { - const proc = Object.assign(new EventEmitter(), { - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn() - }) - spawnMock.mockReturnValue(proc) - let input = '' - proc.stdin.on('data', (chunk) => { - input += chunk.toString() - }) - proc.stdin.on('finish', () => { - proc.stdout.write('answer') - proc.emit('close', 0) - }) - return { proc, input: () => input } -} - -const target = { id: 't', label: 't', host: 'example.test', port: 22, username: 'u' } - -describe('system SSH command input', () => { - it('forwards end-of-input written to the facade to the ssh child', async () => { - const { proc, input } = fakeSshProcess() - const channel = spawnSystemSshCommand(target, 'reader') - const finished = new Promise((resolve) => proc.stdin.once('finish', resolve)) - - channel.end('secret') - await finished - - expect(input()).toBe('secret') - expect(proc.stdin.writableFinished).toBe(true) - }) - - it('streams command input through the real facade and returns the response', async () => { - const { proc, input } = fakeSshProcess() - const channel = spawnSystemSshCommand(target, 'reader') - - await expect( - execCommand({ exec: async () => channel, usesSystemSshTransport: () => true }, 'reader', { - stdin: Readable.from(['secret']) - }) - ).resolves.toBe('answer') - expect(input()).toBe('secret') - expect(proc.stdin.writableFinished).toBe(true) - }) -}) diff --git a/src/main/ssh/ssh-relay-install-lock.test.ts b/src/main/ssh/ssh-relay-install-lock.test.ts index d17d5725f43..d70409def41 100644 --- a/src/main/ssh/ssh-relay-install-lock.test.ts +++ b/src/main/ssh/ssh-relay-install-lock.test.ts @@ -14,7 +14,7 @@ vi.mock('./ssh-relay-gc-claim', () => ({ })) import { execCommand } from './ssh-relay-deploy-helpers' -import { acquireInstallLock, RemoteInstallLockBusyError } from './ssh-relay-install-lock' +import { acquireInstallLock } from './ssh-relay-install-lock' import { getRemoteHostPlatform } from './ssh-remote-platform' describe('acquireInstallLock', () => { @@ -46,29 +46,4 @@ describe('acquireInstallLock', () => { ) ).rejects.toBe(termination) }) - - it.each([ - [true, 'OK'], - [false, 'BUSY'] - ])('steals a stale lock only when takeover is allowed (%s)', async (allow, expected) => { - vi.mocked(execCommand).mockImplementation(async (_conn, command) => { - if (command.includes('.steal')) { - return 'OK' - } - return command.includes('mkdir -p') ? '' : 'BUSY' - }) - const acquired = acquireInstallLock( - {} as SshConnection, - '/home/u/.orca-remote/.orcad-activation-transaction', - getRemoteHostPlatform('linux-x64'), - { relayGcClaim: false, allowStaleTakeover: allow, waitTimeoutMs: 0 } - ).then( - () => 'OK', - (error: unknown) => (error instanceof RemoteInstallLockBusyError ? 'BUSY' : String(error)) - ) - expect(await acquired).toBe(expected) - expect( - vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('.steal')) - ).toBe(allow) - }) }) diff --git a/src/main/ssh/ssh-relay-install-lock.ts b/src/main/ssh/ssh-relay-install-lock.ts index ef570ecb2fe..eaa759bcf9a 100644 --- a/src/main/ssh/ssh-relay-install-lock.ts +++ b/src/main/ssh/ssh-relay-install-lock.ts @@ -32,16 +32,6 @@ export const INSTALL_LOCK_STALE_MS = 20 * 60_000 export const INSTALL_LOCK_STALE_SECONDS = INSTALL_LOCK_STALE_MS / 1000 const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') -export class RemoteInstallLockBusyError extends Error { - constructor(lockDir: string, timeoutMs: number) { - super( - `Could not acquire relay install lock at ${lockDir} after ${timeoutMs / 1000}s; ` + - 'another install is still in progress.' - ) - this.name = 'RemoteInstallLockBusyError' - } -} - function execHostCommand( conn: SshConnection, host: RemoteHostPlatform, @@ -87,16 +77,9 @@ export async function acquireInstallLock( lockName?: string /** False for a lock whose directory is not a relay version dir, so no GC claim can name it. */ relayGcClaim?: boolean - /** False when a held lock is a fence whose age cannot prove its owner's work is finished. */ - allowStaleTakeover?: boolean - waitTimeoutMs?: number } ): Promise { const lockDir = joinRemotePath(host, remoteRelayDir, options?.lockName ?? RELAY_INSTALL_LOCK_NAME) - const waitTimeoutMs = options?.waitTimeoutMs ?? INSTALL_LOCK_TIMEOUT_MS - if (!Number.isSafeInteger(waitTimeoutMs) || waitTimeoutMs < 0) { - throw new Error('Install lock wait timeout must be a non-negative integer.') - } const relayGcClaim = options?.relayGcClaim ?? true const isClaimed = (): Promise => relayGcClaim @@ -147,10 +130,7 @@ export async function acquireInstallLock( // A failed mkdir is lock contention; keep the connection-specific error // out of the user path until the bounded wait expires. } - if ( - options?.allowStaleTakeover !== false && - Date.now() - lastStaleCheckAt >= INSTALL_LOCK_STALE_RECHECK_MS - ) { + if (Date.now() - lastStaleCheckAt >= INSTALL_LOCK_STALE_RECHECK_MS) { lastStaleCheckAt = Date.now() // Why: recover an already-stale lock immediately, then keep checking in // case a fresh holder crosses the stale threshold while we are waiting. @@ -190,8 +170,12 @@ export async function acquireInstallLock( lastWaitLogAt = Date.now() console.info(`[ssh-relay] Waiting for install lock at ${lockDir}`) } - if (Date.now() - start >= waitTimeoutMs) { - throw new RemoteInstallLockBusyError(lockDir, waitTimeoutMs) + if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) { + throw new Error( + `Could not acquire relay install lock at ${lockDir} after ${ + INSTALL_LOCK_TIMEOUT_MS / 1000 + }s; another install is still in progress.` + ) } await waitForInstallLockPoll(options?.signal) } diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index ac3ff4f0308..6e45077698a 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync, writeFileSync, mkdirSync, chmodSync, rmSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -59,9 +59,7 @@ function writeFakeRelay(dir: string): void { // below — so adding one cannot silently fail the completeness probe here. for (const filename of relayArtifactFilenames(false)) { if (filename !== 'relay.js') { - const artifactPath = join(dir, filename) - mkdirSync(dirname(artifactPath), { recursive: true }) - writeFileSync(artifactPath, '') + writeFileSync(join(dir, filename), '') } } writeFileSync( diff --git a/src/main/ssh/ssh-target-orcad-claims.test.ts b/src/main/ssh/ssh-target-orcad-claims.test.ts deleted file mode 100644 index c2849eb1685..00000000000 --- a/src/main/ssh/ssh-target-orcad-claims.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - createManagedOrcadSshOwner, - getManagedOrcadOwnerEnvironmentId -} from '../../shared/managed-orcad-ssh-owner' -import type { SshRemotePtyLease, SshTarget } from '../../shared/ssh-types' -import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' - -type SetupOptions = { - target?: Partial - repos?: { id: string; path: string; displayName: string }[] - folders?: { id: string; name: string; folderPath: string }[] - leases?: Partial[] -} - -function setup(options: SetupOptions = {}) { - let target: SshTarget = { - id: 'ssh-1', - label: 'Builder', - host: 'builder', - port: 22, - username: 'dev', - ...options.target - } - const flush = vi.fn(async () => {}) - const rows = { connectionId: 'ssh-1' } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight reads only id, name, folderPath and connectionId. - const folders = (options.folders ?? []).map((f) => ({ ...f, ...rows })) as never - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight reads only id, path, displayName, kind and connectionId. - const repos = (options.repos ?? []).map((r) => ({ ...r, ...rows })) as never - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: preflight reads only ptyId and state. - const leases = (options.leases ?? []) as never - const claims = new SshTargetOrcadClaims({ - ...emptyDependentStateStore(), - allocateSshTargetGeneration: () => 4, - flushPendingOrThrowAsync: flush, - getFolderWorkspaces: () => folders, - getRepos: () => repos, - getSshRemotePtyLeases: () => leases, - getSshTarget: (id) => (id === target.id ? target : undefined), - getSshTargets: () => [target], - updateSshTarget: (_id, updates) => (target = { ...target, ...updates }) - }) - return { claims, flush, current: () => target } -} - -describe('managed orcad SSH target claims', () => { - it('claims an empty target with a generation and a resumable provisioning intent', () => { - const { claims, current } = setup() - const claimed = claims.claim('ssh-1', 'environment-1', 'Managed') - expect(getManagedOrcadOwnerEnvironmentId(claimed.owner)).toBe('environment-1') - expect(claimed.generation).toBe(4) - expect(current().orcadProvisioning).toEqual({ requestId: 'environment-1', name: 'Managed' }) - }) - - it('is idempotent for its own environment and keeps an earlier provisioning request', () => { - const { claims } = setup({ - target: { - generation: 2, - owner: createManagedOrcadSshOwner('environment-1'), - orcadProvisioning: { requestId: 'request-1', name: 'From dialog' } - } - }) - const claimed = claims.claim('ssh-1', 'environment-1', 'Other name') - expect(claimed.generation).toBe(2) - expect(claimed.orcadProvisioning).toEqual({ requestId: 'request-1', name: 'From dialog' }) - }) - - it('claims for SSH access without recording a provisioning intent', () => { - const { claims } = setup({ target: { generation: 1 } }) - expect(claims.claim('ssh-1', 'environment-1').orcadProvisioning).toBeUndefined() - }) - - it.each<[string, SetupOptions, string]>([ - ['another owner', { target: { owner: createManagedOrcadSshOwner('other') } }, 'already owned'], - [ - 'direct SSH repositories', - { repos: [{ id: 'repo-1', path: '/srv/app', displayName: 'app' }] }, - 'repositories or folder workspaces' - ], - [ - 'folder workspaces', - { folders: [{ id: 'folder-1', name: 'scratch', folderPath: '/srv/scratch' }] }, - 'repositories or folder workspaces' - ], - [ - 'a saved terminal lease', - { leases: [{ ptyId: 'pty-1', state: 'detached' }] }, - 'terminal-lease ×1 (pty-1 (detached))' - ], - [ - 'saved port forwards', - { - target: { - portForwards: [{ localPort: 1, remoteHost: 'localhost', remotePort: 2 }] - } - }, - 'port forwards' - ] - ])('refuses a target with %s and leaves it unclaimed', (_label, options, message) => { - const { claims, current } = setup(options) - expect(claims.preflight('ssh-1').claimable).toBe(false) - expect(() => claims.claim('ssh-1', 'environment-1', 'Managed')).toThrow(message) - expect(current().orcadProvisioning).toBeUndefined() - }) - - it('counts terminated and expired leases too, since they still name this host', () => { - const { claims } = setup({ - leases: [ - { ptyId: 'a', state: 'terminated' }, - { ptyId: 'b', state: 'expired' } - ] - }) - expect(claims.preflight('ssh-1').blockers).toEqual([ - { - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies: [ - { kind: 'terminal-lease', count: 2, names: ['a (terminated)', 'b (expired)'] } - ] - } - ]) - }) - - it('reports an unknown target as a registration blocker', () => { - const { claims } = setup() - expect(claims.preflight('missing').blockers).toEqual([ - { code: 'orcad_migration_target_not_found', category: 'registration' } - ]) - }) - - it('releases only its own claim and clears the provisioning intent', () => { - const { claims, current } = setup() - claims.claim('ssh-1', 'environment-1', 'Managed') - expect(claims.release('ssh-1', 'environment-2')).toBeNull() - expect(claims.release('ssh-1', 'environment-1')).toMatchObject({ - owner: undefined, - orcadProvisioning: undefined - }) - expect(current().owner).toBeUndefined() - }) - - it('flushes without draining to a stable generation', async () => { - const { claims, flush } = setup() - await claims.flush() - expect(flush).toHaveBeenCalledWith({ signal: undefined, drainToStableGeneration: false }) - }) -}) diff --git a/src/main/ssh/ssh-target-orcad-claims.ts b/src/main/ssh/ssh-target-orcad-claims.ts deleted file mode 100644 index fc389d3c44f..00000000000 --- a/src/main/ssh/ssh-target-orcad-claims.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Exclusive managed-orcad ownership of an SSH target. A claimed target is hidden from direct SSH - * surfaces and serves only its environment's tunnel. Only empty targets are claimable, including no - * saved sessions, automations, worktree metadata or terminal leases: moving a direct SSH host's - * state into a managed server is the catalog migration, which this does not do. - */ -import type { Store } from '../persistence' -import { - createManagedOrcadSshOwner, - getManagedOrcadOwnerEnvironmentId -} from '../../shared/managed-orcad-ssh-owner' -import type { - OrcadMigrationBlocker, - OrcadMigrationPreflight -} from '../../shared/orcad-migration-preflight' -import type { SshTarget } from '../../shared/ssh-types' -import { - collectDependentStateBlockers, - dependentStateMessage, - type DependentStateStore -} from './ssh-target-orcad-dependents' - -type ClaimStore = DependentStateStore & - Pick< - Store, - | 'allocateSshTargetGeneration' - | 'flushPendingOrThrowAsync' - | 'getFolderWorkspaces' - | 'getRepos' - | 'getSshTarget' - | 'getSshTargets' - | 'updateSshTarget' - > - -export class SshTargetOrcadClaims { - constructor(private readonly store: ClaimStore) {} - - listTargets(): SshTarget[] { - return this.store.getSshTargets() - } - - /** `environmentId` lets that environment's own claim pass; omit it to see every blocker. */ - preflight(targetId: string, environmentId?: string): OrcadMigrationPreflight { - const target = this.store.getSshTarget(targetId) - if (!target) { - return { - targetId, - targetLabel: null, - claimable: false, - blockers: [{ code: 'orcad_migration_target_not_found', category: 'registration' }] - } - } - if (environmentId && getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId) { - return { targetId, targetLabel: target.label, claimable: true, blockers: [] } - } - const blockers = collectEmptyTargetBlockers(this.store, target) - return { targetId, targetLabel: target.label, claimable: blockers.length === 0, blockers } - } - - /** - * Idempotent for the same environment; the caller makes the claim durable before acting on it. - * A deploy passes its server name so an interrupted claim reads as pending provisioning. - */ - claim(targetId: string, environmentId: string, deployName?: string): SshTarget { - const blocker = this.preflight(targetId, environmentId).blockers[0] - if (blocker) { - throw new Error(orcadTargetBlockerMessage(targetId, blocker)) - } - const target = this.requireTarget(targetId) - const owned = getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId - if (owned && target.generation) { - return target - } - const claimed = this.store.updateSshTarget(targetId, { - owner: createManagedOrcadSshOwner(environmentId), - generation: target.generation ?? this.store.allocateSshTargetGeneration(), - ...(deployName && !target.orcadProvisioning - ? { orcadProvisioning: { requestId: environmentId, name: deployName } } - : {}) - }) - if (!claimed) { - throw new Error(`SSH target "${targetId}" disappeared while it was being reserved.`) - } - return claimed - } - - release(targetId: string, environmentId: string): SshTarget | null { - const target = this.store.getSshTarget(targetId) - if (!target || getManagedOrcadOwnerEnvironmentId(target.owner) !== environmentId) { - return null - } - return this.store.updateSshTarget(targetId, { owner: undefined, orcadProvisioning: undefined }) - } - - /** Ownership must be on disk before a remote host acts on it. */ - flush(signal?: AbortSignal): Promise { - return this.store.flushPendingOrThrowAsync({ signal, drainToStableGeneration: false }) - } - - private requireTarget(targetId: string): SshTarget { - const target = this.store.getSshTarget(targetId) - if (!target) { - throw new Error(`SSH target "${targetId}" not found.`) - } - return target - } -} - -function collectEmptyTargetBlockers(store: ClaimStore, target: SshTarget): OrcadMigrationBlocker[] { - const blockers: OrcadMigrationBlocker[] = [] - if (target.owner) { - blockers.push({ - code: 'orcad_migration_target_owned', - category: 'exclusive-ownership', - owner: { ...target.owner } - }) - } - const repositories = store - .getRepos() - .filter((repo) => repo.connectionId === target.id) - .map(({ id, path, displayName, kind }) => ({ id, path, displayName, kind })) - if (repositories.length > 0) { - blockers.push({ - code: 'orcad_migration_direct_ssh_repositories', - category: 'drainable-static-state', - repositories - }) - } - const folderWorkspaces = store - .getFolderWorkspaces() - .filter((workspace) => workspace.connectionId === target.id) - .map(({ id, name, folderPath }) => ({ id, name, folderPath })) - if (folderWorkspaces.length > 0) { - blockers.push({ - code: 'orcad_migration_direct_ssh_folder_workspaces', - category: 'drainable-static-state', - folderWorkspaces - }) - } - if (target.portForwards?.length) { - blockers.push({ - code: 'orcad_migration_saved_port_forwards', - category: 'client-owned-state', - portForwards: target.portForwards.map((portForward) => ({ ...portForward })) - }) - } - blockers.push(...collectDependentStateBlockers(store, target.id)) - return blockers -} - -export function orcadTargetBlockerMessage( - targetId: string, - blocker: OrcadMigrationBlocker -): string { - switch (blocker.code) { - case 'orcad_migration_target_not_found': - return `SSH target "${targetId}" not found.` - case 'orcad_migration_target_owned': - return 'This SSH target is already owned by another managed runtime.' - case 'orcad_migration_direct_ssh_repositories': - case 'orcad_migration_direct_ssh_folder_workspaces': - return 'This SSH target owns repositories or folder workspaces. A managed server can only be created on a host with no direct SSH projects yet; keep this host in direct SSH mode.' - case 'orcad_migration_direct_ssh_terminal_leases': - return 'This SSH target still owns terminal sessions. Close them before converting the host.' - case 'orcad_migration_saved_port_forwards': - return 'This SSH target has saved port forwards. Remove them before converting the host.' - case 'orcad_migration_dependent_state': - return dependentStateMessage(blocker.dependencies) - case 'orcad_migration_dependency_unverifiable': - return `Orca could not read its saved ${blocker.sources.join(', ')} state, so it cannot show this SSH target is unused; the target was left in direct SSH mode.` - } -} diff --git a/src/main/ssh/ssh-target-orcad-dependents-fixture.ts b/src/main/ssh/ssh-target-orcad-dependents-fixture.ts deleted file mode 100644 index 34239d6e54f..00000000000 --- a/src/main/ssh/ssh-target-orcad-dependents-fixture.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { DependentStateStore } from './ssh-target-orcad-dependents' - -/** A store with no client state referencing any SSH target. */ -export function emptyDependentStateStore( - overrides: Partial = {} -): DependentStateStore { - return { - getAllWorktreeMetaForHost: () => ({}), - getSshRemotePtyLeases: () => [], - getWorkspaceSession: () => getDefaultWorkspaceSession(), - getWorkspaceSessionHostIds: () => ['local'], - listAutomations: () => [], - ...overrides - } -} diff --git a/src/main/ssh/ssh-target-orcad-dependents.test.ts b/src/main/ssh/ssh-target-orcad-dependents.test.ts deleted file mode 100644 index ab663c2ed5b..00000000000 --- a/src/main/ssh/ssh-target-orcad-dependents.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { ExecutionHostId } from '../../shared/execution-host' -import { collectDependentStateBlockers } from './ssh-target-orcad-dependents' -import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture' -import { orcadTargetBlockerMessage } from './ssh-target-orcad-claims' - -const HOST: ExecutionHostId = 'ssh:ssh-1' - -function blockersFor(overrides: Parameters[0]) { - return collectDependentStateBlockers(emptyDependentStateStore(overrides), 'ssh-1') -} - -function messageFor(overrides: Parameters[0]): string { - const [blocker] = blockersFor(overrides) - if (!blocker) { - throw new Error('expected a blocker') - } - return orcadTargetBlockerMessage('ssh-1', blocker) -} - -describe('client state that still references an SSH target', () => { - it('finds nothing on an empty profile, or in an untouched session partition', () => { - expect(blockersFor({})).toEqual([]) - expect(blockersFor({ getWorkspaceSessionHostIds: () => ['local', HOST] })).toEqual([]) - }) - - it('blocks on the host session partition and names the fields holding state', () => { - const blockers = blockersFor({ - getWorkspaceSessionHostIds: () => ['local', HOST], - getWorkspaceSession: (hostId) => - hostId === HOST - ? { - ...getDefaultWorkspaceSession(), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only whether the record is empty. - tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only whether the record is empty. - sleepingAgentSessionsByPaneKey: { 'tab-1:leaf': {} } as never - } - : getDefaultWorkspaceSession() - }) - expect(blockers).toEqual([ - { - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies: [ - { - kind: 'workspace-session', - count: 2, - names: ['tabsByWorktree', 'sleepingAgentSessionsByPaneKey'] - } - ] - } - ]) - }) - - it('blocks when the local session points its active workspace at the host', () => { - expect( - messageFor({ - getWorkspaceSession: () => ({ - ...getDefaultWorkspaceSession(), - activeWorkspaceExecutionHostId: HOST - }) - }) - ).toContain('workspace-session ×1 (active workspace)') - }) - - it('blocks on an automation that runs on the host, by name', () => { - const automation = { - name: 'Nightly build', - executionTargetType: 'ssh', - executionTargetId: 'ssh-1' - } - const elsewhere = { name: 'Other', executionTargetType: 'ssh', executionTargetId: 'ssh-2' } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only name and execution target. - const automations = [automation, elsewhere] as never - expect(messageFor({ listAutomations: () => automations })).toContain( - 'automation ×1 (Nightly build)' - ) - }) - - it('blocks on worktree metadata recorded for the host', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only the keys. - const meta = { 'repo-1::/srv/app': {} } as never - expect( - messageFor({ - getAllWorktreeMetaForHost: (hostId) => (hostId === HOST ? meta : {}) - }) - ).toContain('worktree-metadata ×1 (repo-1::/srv/app)') - }) - - it('blocks on a saved terminal lease of any status', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only ptyId and state. - const leases = [{ ptyId: 'pty-9', state: 'terminated' }] as never - expect(messageFor({ getSshRemotePtyLeases: () => leases })).toContain( - 'terminal-lease ×1 (pty-9 (terminated))' - ) - }) - - it('caps the names it reports but keeps the full count', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only ptyId and state. - const leases = Array.from({ length: 7 }, (_, i) => ({ - ptyId: `p${i}`, - state: 'expired' - })) as never - const [blocker] = blockersFor({ getSshRemotePtyLeases: () => leases }) - expect(blocker).toMatchObject({ - dependencies: [{ kind: 'terminal-lease', count: 7, names: expect.any(Array) }] - }) - expect( - blocker?.code === 'orcad_migration_dependent_state' && blocker.dependencies[0]?.names - ).toHaveLength(5) - }) - - it('refuses as unverifiable when a store cannot be read, naming the store', () => { - const blockers = blockersFor({ - listAutomations: () => { - throw new Error('automations unreadable') - }, - getAllWorktreeMetaForHost: () => { - throw new Error('meta unreadable') - } - }) - expect(blockers).toEqual([ - { - code: 'orcad_migration_dependency_unverifiable', - category: 'live-or-unverifiable', - sources: ['automation', 'worktree-metadata'] - } - ]) - const [blocker] = blockers - expect(blocker && orcadTargetBlockerMessage('ssh-1', blocker)).toContain( - 'could not read its saved automation, worktree-metadata state' - ) - }) -}) diff --git a/src/main/ssh/ssh-target-orcad-dependents.ts b/src/main/ssh/ssh-target-orcad-dependents.ts deleted file mode 100644 index bfac28d25ce..00000000000 --- a/src/main/ssh/ssh-target-orcad-dependents.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Client state that still references an SSH target. Until the full migration census exists, - * any of it keeps the host from being claimed: hiding the target would strand that state. - */ -import type { Store } from '../persistence' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import { toSshExecutionHostId } from '../../shared/execution-host' -import type { - OrcadMigrationBlocker, - OrcadMigrationDependency, - OrcadMigrationDependencyKind -} from '../../shared/orcad-migration-preflight' - -export type DependentStateStore = Pick< - Store, - | 'getAllWorktreeMetaForHost' - | 'getSshRemotePtyLeases' - | 'getWorkspaceSession' - | 'getWorkspaceSessionHostIds' - | 'listAutomations' -> - -const MAX_NAMES = 5 - -export function collectDependentStateBlockers( - store: DependentStateStore, - targetId: string -): OrcadMigrationBlocker[] { - const hostId = toSshExecutionHostId(targetId) - const readers: [OrcadMigrationDependencyKind, () => string[]][] = [ - ['workspace-session', () => workspaceSessionReferences(store, hostId)], - [ - 'automation', - () => - store - .listAutomations() - .filter((a) => a.executionTargetType === 'ssh' && a.executionTargetId === targetId) - .map((a) => a.name) - ], - ['worktree-metadata', () => Object.keys(store.getAllWorktreeMetaForHost(hostId))], - // Why every status: a terminated lease is still a saved record pointing at this host. - [ - 'terminal-lease', - () => store.getSshRemotePtyLeases(targetId).map((lease) => `${lease.ptyId} (${lease.state})`) - ] - ] - const dependencies: OrcadMigrationDependency[] = [] - const unreadable: OrcadMigrationDependencyKind[] = [] - for (const [kind, read] of readers) { - let names: string[] - try { - names = read() - } catch { - unreadable.push(kind) - continue - } - if (names.length > 0) { - dependencies.push({ kind, count: names.length, names: names.slice(0, MAX_NAMES) }) - } - } - const blockers: OrcadMigrationBlocker[] = [] - if (dependencies.length > 0) { - blockers.push({ - code: 'orcad_migration_dependent_state', - category: 'client-owned-state', - dependencies - }) - } - if (unreadable.length > 0) { - blockers.push({ - code: 'orcad_migration_dependency_unverifiable', - category: 'live-or-unverifiable', - sources: unreadable - }) - } - return blockers -} - -/** Non-default fields of the host's session partition, plus a local session pointed at the host. */ -function workspaceSessionReferences(store: DependentStateStore, hostId: string): string[] { - const references: string[] = [] - if (store.getWorkspaceSession().activeWorkspaceExecutionHostId === hostId) { - references.push('active workspace') - } - if (!store.getWorkspaceSessionHostIds().some((id) => id === hostId)) { - return references - } - const defaults: Record = { ...getDefaultWorkspaceSession() } - for (const [field, value] of Object.entries(store.getWorkspaceSession(hostId))) { - if (!isEmptyValue(value) && JSON.stringify(value) !== JSON.stringify(defaults[field])) { - references.push(field) - } - } - return references -} - -function isEmptyValue(value: unknown): boolean { - if (value === null || value === undefined) { - return true - } - if (Array.isArray(value)) { - return value.length === 0 - } - return typeof value === 'object' && Object.keys(value).length === 0 -} - -export function dependentStateMessage(dependencies: OrcadMigrationDependency[]): string { - const parts = dependencies.map( - ({ kind, count, names }) => `${kind} ×${count}${names?.length ? ` (${names.join(', ')})` : ''}` - ) - return ( - 'This SSH target is still referenced by saved Orca state that a managed server cannot take ' + - `over yet: ${parts.join('; ')}. Remove it, or keep this host in direct SSH mode.` - ) -} diff --git a/src/main/ssh/ssh-target-registry.ts b/src/main/ssh/ssh-target-registry.ts index aef003025cc..9959e8072a6 100644 --- a/src/main/ssh/ssh-target-registry.ts +++ b/src/main/ssh/ssh-target-registry.ts @@ -90,7 +90,6 @@ export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexe } let registeredGetSshConnectionManager: (() => SshConnectionManager | null) | null = null -let registeredHasDirectSshAuthority: ((targetId: string) => boolean) | null = null export function setSshConnectionManagerResolver( resolve: (() => SshConnectionManager | null) | null @@ -109,14 +108,3 @@ export function setSshConnectionManagerResolver( export function getSshConnectionManager(): SshConnectionManager | null { return registeredGetSshConnectionManager?.() ?? null } - -export function setDirectSshAuthorityResolver( - resolve: ((targetId: string) => boolean) | null -): void { - registeredHasDirectSshAuthority = resolve -} - -/** Whether this process currently holds the target's relay session; false when unregistered. */ -export function hasRegisteredDirectSshAuthority(targetId: string): boolean { - return registeredHasDirectSshAuthority?.(targetId) ?? false -} diff --git a/src/main/ssh/ssh-transport-close-ledger.test.ts b/src/main/ssh/ssh-transport-close-ledger.test.ts deleted file mode 100644 index b126c9cb0b1..00000000000 --- a/src/main/ssh/ssh-transport-close-ledger.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { SshTransportCloseLedger } from './ssh-transport-close-ledger' - -function fixture() { - const ledger = new SshTransportCloseLedger() - const allocate = () => { - const client = new EventEmitter() - ledger.track(client) - return client - } - return { ledger, allocate } -} - -it('waits for all allocated clients, including failed authentication attempts', async () => { - const { ledger, allocate } = fixture() - const failed = allocate() - const successful = allocate() - const done = vi.fn() - const draining = ledger.drain(new AbortController().signal).then(done) - failed.emit('end') - successful.emit('close') - await Promise.resolve() - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - failed.emit('close') - await draining - expect(done).toHaveBeenCalledOnce() - expect(failed.listenerCount('close')).toBe(0) - expect(successful.listenerCount('close')).toBe(0) -}) - -it('remembers closure before drain and does not accumulate listeners', async () => { - const { ledger, allocate } = fixture() - for (let i = 0; i < 100; i++) { - const client = allocate() - ledger.track(client) - expect(client.listenerCount('close')).toBe(1) - client.emit('close') - expect(client.listenerCount('close')).toBe(0) - } - await ledger.drain(new AbortController().signal) - await ledger.drain(new AbortController().signal) -}) - -it('retains physical closure evidence across canceled waits', async () => { - const { ledger, allocate } = fixture() - const client = allocate() - const controller = new AbortController() - const draining = ledger.drain(controller.signal) - controller.abort() - await expect(draining).rejects.toThrow() - expect(client.listenerCount('close')).toBe(1) - const retry = ledger.drain(new AbortController().signal) - client.emit('close') - await retry - expect(client.listenerCount('close')).toBe(0) -}) - -it('refuses new outstanding allocations made after the drain snapshot', async () => { - const { ledger, allocate } = fixture() - const original = allocate() - const draining = ledger.drain(new AbortController().signal) - const late = allocate() - original.emit('close') - await expect(draining).rejects.toThrow('ssh_client_close_allocations_changed') - late.emit('close') - await ledger.drain(new AbortController().signal) -}) diff --git a/src/main/ssh/ssh-transport-close-ledger.ts b/src/main/ssh/ssh-transport-close-ledger.ts deleted file mode 100644 index b9721ea71fc..00000000000 --- a/src/main/ssh/ssh-transport-close-ledger.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { EventEmitter } from 'node:events' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' - -/** Authentication settlement does not establish physical socket closure. */ -export class SshTransportCloseLedger { - private readonly pending = new Map>() - - constructor(private readonly onChange: () => void = () => {}) {} - - isClosed(): boolean { - return this.pending.size === 0 - } - - track(client: EventEmitter): void { - if (this.pending.has(client)) { - return - } - let resolveClose!: () => void - const closed = new Promise((resolve) => { - resolveClose = resolve - }) - const onClose = (): void => { - client.off('close', onClose) - this.pending.delete(client) - resolveClose() - this.onChange() - } - this.pending.set(client, closed) - client.on('close', onClose) - } - - /** Caller must fence new allocations before taking this snapshot. */ - async drain(signal: AbortSignal): Promise { - await waitForPromiseWithSignal(Promise.all(this.pending.values()), signal) - if (this.pending.size !== 0) { - throw new Error('ssh_client_close_allocations_changed') - } - } -} diff --git a/src/main/ssh/ssh-transport-close-observation.test.ts b/src/main/ssh/ssh-transport-close-observation.test.ts deleted file mode 100644 index c12950110b1..00000000000 --- a/src/main/ssh/ssh-transport-close-observation.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import { observeSshTransportClose } from './ssh-transport-close-observation' - -it('resolves only after every observed resource emits close', async () => { - const client = new EventEmitter() - client.on('error', () => {}) - const proxy = new EventEmitter() - const observation = observeSshTransportClose([client, proxy]) - const done = vi.fn() - const waiting = observation.wait(new AbortController().signal).then(done) - client.emit('end') - client.emit('error', new Error('reset by peer')) - client.emit('close') - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - proxy.emit('close') - await waiting - expect(done).toHaveBeenCalledOnce() -}) - -it('cancels only the wait and removes its listeners on dispose', async () => { - const client = new EventEmitter() - client.on('error', () => {}) - const observation = observeSshTransportClose([client]) - const controller = new AbortController() - const waiting = observation.wait(controller.signal) - controller.abort(new Error('caller gave up')) - await expect(waiting).rejects.toThrow('caller gave up') - expect(client.listenerCount('close')).toBe(1) - observation.dispose() - expect(client.listenerCount('close')).toBe(0) -}) diff --git a/src/main/ssh/ssh-transport-close-observation.ts b/src/main/ssh/ssh-transport-close-observation.ts deleted file mode 100644 index 5f604303d42..00000000000 --- a/src/main/ssh/ssh-transport-close-observation.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { EventEmitter } from 'node:events' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' - -/** Register before initiating close; 'end', 'error' and destroy requests are not closure. */ -export function observeSshTransportClose(resources: readonly EventEmitter[]) { - const listeners: { resource: EventEmitter; close: () => void }[] = [] - const closed = Promise.all( - resources.map( - (resource) => - new Promise((resolve) => { - const close = () => resolve() - listeners.push({ resource, close }) - resource.once('close', close) - }) - ) - ) - return { - wait: (signal: AbortSignal) => waitForPromiseWithSignal(closed, signal), - dispose: () => { - for (const { resource, close } of listeners) { - resource.removeListener('close', close) - } - } - } -} diff --git a/src/main/ssh/ssh-upload-session-lifetime.test.ts b/src/main/ssh/ssh-upload-session-lifetime.test.ts deleted file mode 100644 index adaa13642ff..00000000000 --- a/src/main/ssh/ssh-upload-session-lifetime.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' -import type { FileUploadSession } from '../providers/filesystem-provider-contract' -import { SshConnectionWorkLedger } from './ssh-connection-work-ledger' -import { openTrackedSshUploadSession } from './ssh-upload-session-lifetime' - -const signal = () => new AbortController().signal -const makeSession = (): FileUploadSession => ({ - uploadFile: vi.fn(async () => undefined), - close: vi.fn() -}) - -it('holds an idle session until explicit close and closes the underlying session once', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const fence = ledger.fenceForReset() - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - session.close() - session.close() - await fence.drain(signal()) - expect(underlying.close).toHaveBeenCalledTimes(1) - await expect(session.uploadFile('source', 'destination')).rejects.toThrow( - 'ssh_connection_work_session_closed' - ) - expect(underlying.uploadFile).not.toHaveBeenCalled() -}) - -it('waits for every concurrent upload after close', async () => { - const ledger = new SshConnectionWorkLedger() - const first = Promise.withResolvers() - const second = Promise.withResolvers() - const underlying = makeSession() - underlying.uploadFile = vi - .fn() - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise) - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const uploadingFirst = session.uploadFile('a', 'b') - const uploadingSecond = session.uploadFile('c', 'd') - const fence = ledger.fenceForReset() - session.close() - first.resolve() - await uploadingFirst - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - second.resolve() - await uploadingSecond - await fence.drain(signal()) - fence.assertDrained() -}) - -it('allows admitted multi-file sessions to continue after fencing but refuses new sessions', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - underlying.uploadFile = vi.fn(async () => { - await ledger.run(async () => undefined) - }) - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - await session.uploadFile('a', 'b') - const fence = ledger.fenceForReset() - const unopened = vi.fn(async () => makeSession()) - await expect(openTrackedSshUploadSession(ledger, unopened)).rejects.toThrow( - 'ssh_connection_work_admission_closed' - ) - expect(unopened).not.toHaveBeenCalled() - await session.uploadFile('c', 'd', { exclusive: true }) - expect(underlying.uploadFile).toHaveBeenLastCalledWith('c', 'd', { exclusive: true }) - session.close() - await fence.drain(signal()) -}) - -it('retains pending factory admission across the fence and independently tracks physical channels', async () => { - const ledger = new SshConnectionWorkLedger() - const release = Promise.withResolvers() - let channel!: ReturnType - const opening = openTrackedSshUploadSession(ledger, async () => { - await release.promise - await ledger.run(async () => { - channel = ledger.beginChannelOpen() - channel.bind(new EventEmitter()) - }) - return makeSession() - }) - const fence = ledger.fenceForReset() - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - release.resolve() - const session = await opening - session.close() - expect(fence.assertDrained).toThrow('ssh_connection_work_not_drained') - channel.close() - await fence.drain(signal()) -}) - -it('retains failed factories that settle after fencing', async () => { - const ledger = new SshConnectionWorkLedger() - const factory = Promise.withResolvers() - const failure = new Error('factory failed') - const opening = openTrackedSshUploadSession(ledger, () => factory.promise).catch( - (error: unknown) => error - ) - const fence = ledger.fenceForReset() - const draining = fence.drain(signal()).catch((error: unknown) => error) - factory.reject(failure) - expect(await opening).toBe(failure) - expect(await draining).toBe(failure) - expect(fence.assertDrained).toThrow(failure) -}) - -it('wakes reset on upload failure even while the held session remains open', async () => { - const ledger = new SshConnectionWorkLedger() - const upload = Promise.withResolvers() - const underlying = makeSession() - underlying.uploadFile = () => upload.promise - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const uploading = session.uploadFile('a', 'b').catch((error: unknown) => error) - const fence = ledger.fenceForReset() - const draining = fence.drain(signal()).catch((error: unknown) => error) - const failure = new Error('upload failed') - upload.reject(failure) - expect(await uploading).toBe(failure) - expect(await draining).toBe(failure) - session.close() - expect(fence.assertDrained).toThrow(failure) - await expect(fence.drain(signal())).rejects.toBe(failure) -}) - -it('retains close failure and refuses subsequent uploads or repeated physical close', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - const failure = new Error('close failed') - underlying.close = vi.fn(() => { - throw failure - }) - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - const fence = ledger.fenceForReset() - expect(session.close).toThrow(failure) - session.close() - expect(underlying.close).toHaveBeenCalledTimes(1) - await expect(session.uploadFile('a', 'b')).rejects.toThrow('ssh_connection_work_session_closed') - await expect(fence.drain(signal())).rejects.toBe(failure) - expect(fence.assertDrained).toThrow(failure) -}) - -it('preserves close uncertainty raised before reset starts', async () => { - const ledger = new SshConnectionWorkLedger() - const underlying = makeSession() - const failure = new Error('close failed before reset') - underlying.close = () => { - throw failure - } - const session = await openTrackedSshUploadSession(ledger, async () => underlying) - expect(session.close).toThrow(failure) - const fence = ledger.fenceForReset() - await expect(fence.drain(signal())).rejects.toBe(failure) - expect(fence.assertDrained).toThrow() -}) - -it('isolates sessions, admission, and failures between connection ledgers', async () => { - const first = new SshConnectionWorkLedger() - const second = new SshConnectionWorkLedger() - const failed = makeSession() - const failure = new Error('first connection failed') - failed.close = () => { - throw failure - } - const firstSession = await openTrackedSshUploadSession(first, async () => failed) - const firstFence = first.fenceForReset() - expect(firstSession.close).toThrow(failure) - const secondSession = await openTrackedSshUploadSession(second, async () => makeSession()) - const secondFence = second.fenceForReset() - await secondSession.uploadFile('a', 'b') - secondSession.close() - await secondFence.drain(signal()) - secondFence.assertDrained() - await expect(firstFence.drain(signal())).rejects.toBe(failure) -}) - -it('does not let stale factory callbacks reuse the held-session admission scope', async () => { - const ledger = new SshConnectionWorkLedger() - const release = Promise.withResolvers() - let stale!: Promise - const session = await openTrackedSshUploadSession(ledger, async () => { - stale = release.promise - .then(() => ledger.run(async () => undefined)) - .catch((error: unknown) => error) - return makeSession() - }) - const fence = ledger.fenceForReset() - release.resolve() - expect(await stale).toMatchObject({ message: 'ssh_connection_work_admission_closed' }) - session.close() - await fence.drain(signal()) -}) diff --git a/src/main/ssh/ssh-upload-session-lifetime.ts b/src/main/ssh/ssh-upload-session-lifetime.ts deleted file mode 100644 index bbbd6f3119c..00000000000 --- a/src/main/ssh/ssh-upload-session-lifetime.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { FileUploadSession } from '../providers/filesystem-provider-contract' -import type { SshConnectionWorkLedger } from './ssh-connection-work-ledger' - -export async function openTrackedSshUploadSession( - ledger: SshConnectionWorkLedger, - open: () => Promise -): Promise { - const lifetime = ledger.beginSession() - let session: FileUploadSession - try { - session = await lifetime.run(open) - } catch (error) { - lifetime.close(error) - throw error - } - let closed = false - return { - uploadFile: (...args) => lifetime.run(() => session.uploadFile(...args)), - close: () => { - if (closed) { - return - } - closed = true - try { - session.close() - } catch (error) { - lifetime.close(error) - throw error - } - lifetime.close() - } - } -} diff --git a/src/main/ssh/system-ssh-command.ts b/src/main/ssh/system-ssh-command.ts index 15f099a5f88..eab96245ba5 100644 --- a/src/main/ssh/system-ssh-command.ts +++ b/src/main/ssh/system-ssh-command.ts @@ -96,10 +96,6 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { }, write(chunk, encoding, cb) { proc.stdin!.write(chunk, encoding, cb) - }, - final(cb) { - // EOF must reach the remote reader, not just this local facade. - proc.stdin!.end(cb) } }) const channel = duplex as unknown as SystemSshCommandChannel diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index eaefd4d367d..c761ccf0409 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -55,9 +55,8 @@ describe('headless PTY registry hydration ordering', () => { it('starts the orcad hook owner after Store hydration and before daemon PTY recovery', () => { const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') - // Each runtime resource registers its own cleanup; the hook owner's must precede its start. - const cleanup = source.indexOf('registerCleanup(') - const hookStop = source.indexOf('registerCleanup(() => agentHookServer.stop())', cleanup) + const cleanup = source.indexOf('registerCleanup(async () => {') + const hookStop = source.indexOf('agentHookServer.stop()', cleanup) const store = source.indexOf('createOrcadProfileStateStartup(runtimeUserDataPath)') const hookStart = source.indexOf('await agentHookServer.start(', store) const daemon = source.indexOf('await startOrcadDaemon()', hookStart) @@ -65,7 +64,7 @@ describe('headless PTY registry hydration ordering', () => { const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', hookEnv) expect(cleanup).toBeGreaterThanOrEqual(0) - expect(hookStop).toBeGreaterThanOrEqual(cleanup) + expect(hookStop).toBeGreaterThan(cleanup) expect(store).toBeGreaterThan(hookStop) expect(hookStart).toBeGreaterThan(store) expect(daemon).toBeGreaterThan(hookStart) diff --git a/src/main/startup/linux-dev-shm-policy.test.ts b/src/main/startup/linux-dev-shm-policy.test.ts new file mode 100644 index 00000000000..e3c6a6de6a7 --- /dev/null +++ b/src/main/startup/linux-dev-shm-policy.test.ts @@ -0,0 +1,159 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { appMock, statfsSyncMock, breadcrumbMock } = vi.hoisted(() => ({ + appMock: { + commandLine: { + appendSwitch: vi.fn(), + hasSwitch: vi.fn(() => false) + } + }, + statfsSyncMock: vi.fn(), + breadcrumbMock: vi.fn() +})) + +vi.mock('electron', () => ({ app: appMock })) +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal>()), + statfsSync: statfsSyncMock +})) +vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ + recordCrashBreadcrumb: breadcrumbMock +})) + +const MIB = 1024 * 1024 +const originalPlatform = process.platform +const originalOverride = process.env.ORCA_DEV_SHM + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +function mockDevShm(totalMib: number, freeMib: number): void { + // statfs reports sizes in blocks; use 4 KiB blocks like tmpfs. + const bsize = 4096 + statfsSyncMock.mockReturnValue({ + bsize, + blocks: (totalMib * MIB) / bsize, + bavail: (freeMib * MIB) / bsize + }) +} + +beforeEach(() => { + vi.resetModules() + appMock.commandLine.appendSwitch.mockReset() + appMock.commandLine.hasSwitch.mockReset() + appMock.commandLine.hasSwitch.mockReturnValue(false) + statfsSyncMock.mockReset() + breadcrumbMock.mockReset() + delete process.env.ORCA_DEV_SHM +}) + +afterEach(() => { + setPlatform(originalPlatform) + if (originalOverride === undefined) { + delete process.env.ORCA_DEV_SHM + } else { + process.env.ORCA_DEV_SHM = originalOverride + } +}) + +describe('configureLinuxDevShmUsage', () => { + it('moves Chromium shared memory off a Docker-default 64 MB /dev/shm', async () => { + setPlatform('linux') + mockDevShm(64, 60) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(statfsSyncMock).toHaveBeenCalledWith('/dev/shm') + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') + expect(breadcrumbMock).toHaveBeenCalledWith('dev_shm_policy', { + devShmTotalMB: 64, + devShmFreeMB: 60, + devShmUsageDisabled: true, + reason: 'small' + }) + }) + + it('leaves a normally sized /dev/shm on the fast shared-memory path', async () => { + setPlatform('linux') + mockDevShm(8192, 8000) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + expect(breadcrumbMock).toHaveBeenCalledWith('dev_shm_policy', { + devShmTotalMB: 8192, + devShmFreeMB: 8000, + devShmUsageDisabled: false, + reason: 'ok' + }) + }) + + it('disables /dev/shm usage when the mount is missing', async () => { + setPlatform('linux') + statfsSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') + expect(breadcrumbMock).toHaveBeenCalledWith('dev_shm_policy', { + devShmUsageDisabled: true, + reason: 'unreadable' + }) + }) + + it('honors ORCA_DEV_SHM=off on a small mount and =force on a large one', async () => { + setPlatform('linux') + mockDevShm(64, 60) + process.env.ORCA_DEV_SHM = 'off' + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + configureLinuxDevShmUsage() + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + + mockDevShm(8192, 8000) + process.env.ORCA_DEV_SHM = 'force' + configureLinuxDevShmUsage() + expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage') + }) + + it('does not append the switch twice when it is already on the command line', async () => { + setPlatform('linux') + mockDevShm(64, 60) + appMock.commandLine.hasSwitch.mockReturnValue(true) + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + }) + + it('is a no-op off Linux', async () => { + setPlatform('darwin') + const { configureLinuxDevShmUsage } = await import('./linux-dev-shm-policy') + + configureLinuxDevShmUsage() + + expect(statfsSyncMock).not.toHaveBeenCalled() + expect(appMock.commandLine.appendSwitch).not.toHaveBeenCalled() + expect(breadcrumbMock).not.toHaveBeenCalled() + }) +}) + +describe('desktop startup wiring', () => { + it('runs the /dev/shm policy for every launch before app ready, GPU fallback included', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ).replace(/\r\n/g, '\n') + const call = source.indexOf('\n configureLinuxDevShmUsage()\n') + expect(call).toBeGreaterThan(-1) + expect(call).toBeLessThan(source.indexOf('\n maybeApplyGpuFallbackForThisLaunch()\n')) + }) +}) diff --git a/src/main/startup/linux-dev-shm-policy.ts b/src/main/startup/linux-dev-shm-policy.ts new file mode 100644 index 00000000000..8f707e3e64c --- /dev/null +++ b/src/main/startup/linux-dev-shm-policy.ts @@ -0,0 +1,54 @@ +import { statfsSync } from 'node:fs' +import { app } from 'electron' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' + +const DEV_SHM_PATH = '/dev/shm' +const DEV_SHM_OVERRIDE_ENV_VAR = 'ORCA_DEV_SHM' +// Why 512: Docker/Codespaces default to 64 MB, where Chromium's ring-buffer and +// texture allocations fail and IMMEDIATE_CRASH the renderer on every reload. +const MIN_DEV_SHM_TOTAL_MIB = 512 +const MIB = 1024 * 1024 + +type DevShmReading = { totalMib: number; freeMib: number } | null + +function readDevShm(): DevShmReading { + try { + const stats = statfsSync(DEV_SHM_PATH) + return { + totalMib: Math.floor((stats.blocks * stats.bsize) / MIB), + freeMib: Math.floor((stats.bavail * stats.bsize) / MIB) + } + } catch { + return null + } +} + +/** + * On Linux hosts with a tiny or missing /dev/shm (containers, Codespaces), + * back Chromium shared memory with /tmp files instead of letting renderers abort. + */ +export function configureLinuxDevShmUsage(): void { + if (process.platform !== 'linux') { + return + } + const override = (process.env[DEV_SHM_OVERRIDE_ENV_VAR] ?? '').trim().toLowerCase() + const reading = readDevShm() + const reason = + override === 'off' || override === 'force' + ? override + : reading === null + ? 'unreadable' + : reading.totalMib < MIN_DEV_SHM_TOTAL_MIB + ? 'small' + : 'ok' + const disable = reason === 'force' || reason === 'small' || reason === 'unreadable' + // Why hasSwitch: user argv may already carry it (headless serve appends its own later). + if (disable && !app.commandLine.hasSwitch('disable-dev-shm-usage')) { + app.commandLine.appendSwitch('disable-dev-shm-usage') + } + recordCrashBreadcrumb('dev_shm_policy', { + ...(reading ? { devShmTotalMB: reading.totalMib, devShmFreeMB: reading.freeMib } : {}), + devShmUsageDisabled: disable, + reason + }) +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index be0254a9d43..67be830901f 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -33,6 +33,7 @@ import { } from '../updater' import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instance-identity' import { enableRendererHeapHeadroom } from './renderer-heap-headroom' +import { configureLinuxDevShmUsage } from './linux-dev-shm-policy' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics' import { startEventLoopStallProbe } from './event-loop-stall-probe' import { @@ -364,6 +365,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b optOutOfHiddenPageWakeUpThrottling() configureElectronNetworkCompatibility() enableRendererHeapHeadroom() + configureLinuxDevShmUsage() maybeApplyGpuFallbackForThisLaunch() if (!state.gpuFallbackActiveThisLaunch) { enableMainProcessGpuFeatures() diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts index 0acc8d1a074..c4303b79986 100644 --- a/src/main/startup/main-window-actions.ts +++ b/src/main/startup/main-window-actions.ts @@ -20,6 +20,7 @@ import { presentRendererRecoveryPrompt, type RendererRecoveryPromptFailure } from '../window/renderer-recovery-prompt' +import { probeRendererLaunchCapacity } from '../window/renderer-launch-failure-probe' // The window module injects this callback to avoid a cycle between actions and lifecycle code. let openWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow @@ -149,17 +150,20 @@ export function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { webContents?.send('ui:openCrashReport') } -// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. +// Why: once auto-recovery gives up the window stays blank, so a main-process dialog is the only retry/quit surface. export async function showRendererRecoveryPrompt( recentRecoveryCount: number, failure?: RendererRecoveryPromptFailure, - retry?: () => void + retry?: () => void, + availableCommitMB?: number ): Promise { await presentRendererRecoveryPrompt({ recentRecoveryCount, ...(failure ? { failure } : {}), + ...(availableCommitMB === undefined ? {} : { availableCommitMB }), isQuitting: () => state.isQuitting, diagnose: describeInstallDirAclPoison, + probeLaunchCapacity: () => probeRendererLaunchCapacity(), showMessageBox: (options) => { const window = state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index e2741b5d005..941b3654f65 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -44,6 +44,7 @@ import { stopSyntheticTitleSpinnerTimer } from './synthetic-title-runtime' import { requireMainWindowServices } from './main-window-service-readiness' +import { recordRendererLaunchFailureProbe } from '../window/renderer-launch-failure-probe' const TRAY_CREATE_FALLBACK_MS = 12_000 const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 @@ -100,7 +101,7 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} state.isQuitting = false clearExpectedRendererReload() }, - onRendererProcessGone: (details, webContentsId) => + onRendererProcessGone: (details, webContentsId) => { recordProcessGoneCrash( 'renderer', 'renderer', @@ -108,25 +109,40 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} details.exitCode ?? null, { processType: 'renderer' }, webContentsId - ), + ) + // Why: launch-failed only says a spawn failed; the probe's errno (EAGAIN = process limit) names the cause. + if (details.reason === 'launch-failed' && !state.isQuitting) { + void recordRendererLaunchFailureProbe(details) + } + }, shouldRecoverRenderer: (details, webContentsId) => shouldRecoverRendererAfterProcessGone({ reason: details.reason, expectedTeardown: getExpectedTeardownScope(webContentsId, false) }), - onRendererRecoveryExhausted: ({ details, recentRecoveryCount, cause, retry }) => { - // Why two names: a stalled reload never opened the breaker, and a bundle that says it did misreads the failure. + onRendererRecoveryExhausted: ({ details, recentRecoveryCount, cause, lowCommit, retry }) => { + // Why distinct names: a stalled reload or a low-commit hold never opened the breaker, and a bundle that says it did misreads the failure. recordDurableCrashBreadcrumb( cause === 'reload-stalled' ? 'renderer_recovery_reload_exhausted' - : 'renderer_recovery_circuit_breaker_open', + : cause === 'launch-failed' + ? 'renderer_recovery_launch_backoff_exhausted' + : cause === 'low-commit' + ? 'renderer_recovery_low_commit_prompt' + : 'renderer_recovery_circuit_breaker_open', { reason: details.reason, exitCode: details.exitCode ?? null, - recentRecoveryCount + recentRecoveryCount, + ...lowCommit } ) - void showRendererRecoveryPrompt(recentRecoveryCount, cause, retry) + void showRendererRecoveryPrompt( + recentRecoveryCount, + cause, + retry, + lowCommit?.availableCommitMB + ) }, deferLoad: true, ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), diff --git a/src/main/usage/agent-token-usage-reporter.test.ts b/src/main/usage/agent-token-usage-reporter.test.ts index 8ecf487838c..b1572ce6cf5 100644 --- a/src/main/usage/agent-token-usage-reporter.test.ts +++ b/src/main/usage/agent-token-usage-reporter.test.ts @@ -138,6 +138,7 @@ describe('agent token usage', () => { }) it('retries rate-limited snapshots without incrementing their revision', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000) const instance = reporter() for (let count = 1; count <= 31; count++) { await instance.report([{ ...row, input_tokens: count }]) @@ -145,6 +146,7 @@ describe('agent token usage', () => { expect(captures()).toHaveLength(30) resetBurstCapsForSession() await instance.report([{ ...row, input_tokens: 31 }]) + expect(captures()).toHaveLength(31) expect(captures().at(-1)).toMatchObject({ revision: 31, input_tokens: 31 }) }) diff --git a/src/main/window/createMainWindow-low-commit-oom-recovery.test.ts b/src/main/window/createMainWindow-low-commit-oom-recovery.test.ts new file mode 100644 index 00000000000..218f48c5606 --- /dev/null +++ b/src/main/window/createMainWindow-low-commit-oom-recovery.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', async () => + (await import('./createMainWindow-test-harness')).electronModuleMock() +) +vi.mock('@electron-toolkit/utils', async () => + (await import('./createMainWindow-test-harness')).electronToolkitUtilsMock() +) +vi.mock('./macos-tahoe-release', async () => + (await import('./createMainWindow-test-harness')).macosTahoeReleaseMock() +) +vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harness')).appIconMock()) +vi.mock('../browser/browser-manager', async () => + (await import('./createMainWindow-test-harness')).browserManagerMock() +) +vi.mock('../browser/browser-client-page-renderer-runtime', async () => { + const harness = await import('./createMainWindow-test-harness') + return { + attachBrowserClientPageRenderer: harness.attachClientPageRendererMock, + retireBrowserClientPageRenderer: harness.retireClientPageRendererMock + } +}) + +import { createMainWindow } from './createMainWindow' +import { resetExpectedTeardownStateForTest } from '../crash-reporting/expected-teardown-state' +import { + resetPreGoneSystemMemorySamplingForTest, + samplePreGoneSystemMemory +} from '../crash-reporting/pre-gone-host-memory' +import { setSystemMemoryInfoReaderForTest } from '../crash-reporting/system-memory-details' +import { setSwapVolumeFreeSpaceReaderForTest } from '../crash-reporting/swap-volume-free-space' +import { + createRendererRecoveryWindowHarness, + resetMainWindowMocks, + withPlatform +} from './createMainWindow-test-harness' + +// Launch 13084 (Scan-31 1790683596/1790684449/1790684587): the second reload OOMed 3.458 s after the first. +const OOM_AT = [ + '2026-09-29T12:06:19.869Z', + '2026-09-29T12:20:37.207Z', + '2026-09-29T12:20:40.665Z', + '2026-09-29T12:22:59.975Z' +].map((iso) => Date.parse(iso)) +// Commit at the two OOMs of launch 22912 (Scan-30 1790622432/1790622459): 744 MB, then 60 MB of a 130 GB limit. +const HEALTHY_FIRST_OOM_COMMIT_MB = 744 +const EXHAUSTED_COMMIT_MB = 60 +const RECOVERED_COMMIT_MB = 2_029 + +const OOM: Electron.RenderProcessGoneDetails = { reason: 'oom', exitCode: -536870904 } + +function hostWithAvailableCommit(swapFreeMB: number): void { + setSystemMemoryInfoReaderForTest(() => ({ + total: 32_000 * 1024, + free: 976 * 1024, + swapTotal: 130_000 * 1024, + swapFree: swapFreeMB * 1024 + })) + void samplePreGoneSystemMemory(Date.now()) +} + +async function runOomSequence( + platform: NodeJS.Platform, + commitAtEachOomMB: readonly (number | null)[], + goneTimeCommitMB?: number +): Promise<{ reloads: number; onRendererRecoveryExhausted: ReturnType }> { + const onRendererRecoveryExhausted = vi.fn() + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + createMainWindow(null, { onRendererRecoveryExhausted }) + for (const [index, goneAt] of OOM_AT.entries()) { + const commitMB = commitAtEachOomMB[index] + // null: no sampler tick since the previous OOM. + if (commitMB !== null) { + vi.setSystemTime(goneAt - 2_000) + withPlatform(platform, () => hostWithAvailableCommit(commitMB)) + } + vi.setSystemTime(goneAt) + if (goneTimeCommitMB !== undefined) { + // Only the host changes; no sampler tick commits it. + setSystemMemoryInfoReaderForTest(() => ({ swapFree: goneTimeCommitMB * 1024 })) + } + withPlatform(platform, () => windowHandlers['render-process-gone']?.({}, OOM)) + await vi.advanceTimersByTimeAsync(250) + } + // Minus the initial load. + return { + reloads: browserWindowInstance.loadFile.mock.calls.length - 1, + onRendererRecoveryExhausted + } +} + +describe('Windows renderer OOM recovery under exhausted commit', () => { + beforeEach(() => { + resetMainWindowMocks() + resetExpectedTeardownStateForTest() + resetPreGoneSystemMemorySamplingForTest() + setSwapVolumeFreeSpaceReaderForTest(async () => undefined) + vi.useFakeTimers() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + setSystemMemoryInfoReaderForTest(null) + setSwapVolumeFreeSpaceReaderForTest(null) + resetPreGoneSystemMemorySamplingForTest() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('stops reloading into a repeat OOM and tells the user how much commit is left', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence('win32', [ + HEALTHY_FIRST_OOM_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB + ]) + // The first OOM and the one 14 min later each still get their automatic reload. + expect(reloads).toBe(2) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ + details: OOM, + cause: 'low-commit', + lowCommit: { + availableCommitMB: EXHAUSTED_COMMIT_MB, + sincePreviousOomMs: 3_458, + commitReading: 'pre-gone' + } + }) + ) + }) + + // The common field case: the 10 s sampler has no tick in the 3.458 s between the two OOMs. + it('reads commit at gone time when no sampler tick landed since the previous OOM', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence('win32', [ + HEALTHY_FIRST_OOM_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + null, + EXHAUSTED_COMMIT_MB + ]) + expect(reloads).toBe(2) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ + cause: 'low-commit', + lowCommit: { + availableCommitMB: EXHAUSTED_COMMIT_MB, + sincePreviousOomMs: 3_458, + commitReading: 'gone-time' + } + }) + ) + }) + + it('keeps reloading when the gone-time read shows commit recovered and no tick landed', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence( + 'win32', + [HEALTHY_FIRST_OOM_COMMIT_MB, EXHAUSTED_COMMIT_MB, null, null], + RECOVERED_COMMIT_MB + ) + expect(reloads).toBe(4) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('keeps auto-reloading repeat OOMs once commit has recovered', async () => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence('win32', [ + HEALTHY_FIRST_OOM_COMMIT_MB, + RECOVERED_COMMIT_MB, + RECOVERED_COMMIT_MB, + RECOVERED_COMMIT_MB + ]) + expect(reloads).toBe(4) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it.each(['darwin', 'linux'] as const)('is a no-op on %s', async (platform) => { + const { reloads, onRendererRecoveryExhausted } = await runOomSequence(platform, [ + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB, + EXHAUSTED_COMMIT_MB + ]) + expect(reloads).toBe(4) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts index b3fc210be2c..0f22c3cc644 100644 --- a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts +++ b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts @@ -31,6 +31,7 @@ import { import { attachClientPageRendererMock, browserWindowMock, + createRendererRecoveryWindowHarness, resetMainWindowMocks, retireClientPageRendererMock, withPlatform @@ -349,43 +350,6 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) - const createRendererRecoveryWindowHarness = () => { - const windowHandlers: Record void> = {} - const webContents = { - id: 143, - getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), - isDestroyed: vi.fn(() => false), - on: vi.fn((event, handler) => { - windowHandlers[event] = handler - }), - setZoomLevel: vi.fn(), - setBackgroundThrottling: vi.fn(), - invalidate: vi.fn(), - setWindowOpenHandler: vi.fn(), - send: vi.fn() - } - const browserWindowInstance = { - webContents, - on: vi.fn((event, handler) => { - windowHandlers[event] = handler - }), - isDestroyed: vi.fn(() => false), - isMaximized: vi.fn(() => true), - isFullScreen: vi.fn(() => false), - getSize: vi.fn(() => [1200, 800]), - setSize: vi.fn(), - maximize: vi.fn(), - show: vi.fn(), - loadFile: vi.fn(() => Promise.resolve()), - loadURL: vi.fn(() => Promise.resolve()) - } - browserWindowMock.mockImplementation(function () { - return browserWindowInstance - }) - - return { browserWindowInstance, windowHandlers } - } - it('reloads the app shell after an unexpected renderer process loss', () => { vi.useFakeTimers() @@ -634,46 +598,4 @@ describe('createMainWindow', () => { consoleError.mockRestore() }) - - it('bounds renderer launch-failed recovery with the crash-loop breaker', () => { - vi.useFakeTimers() - - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - const onRendererRecoveryExhausted = vi.fn() - const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() - - try { - createMainWindow(null, { - onRendererRecoveryExhausted, - shouldRecoverRenderer: (details) => - shouldRecoverRendererAfterProcessGone({ - reason: details.reason, - expectedTeardown: 'none' - }) - }) - - const details = { - reason: 'launch-failed', - exitCode: 18 - } as Electron.RenderProcessGoneDetails - const driveLaunchFailure = (): void => { - windowHandlers['render-process-gone']?.({} as never, details) - vi.advanceTimersByTime(250) - } - - driveLaunchFailure() - driveLaunchFailure() - driveLaunchFailure() - expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(4) - - driveLaunchFailure() - expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(4) - expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() - expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( - expect.objectContaining({ details, recentRecoveryCount: 3 }) - ) - } finally { - consoleError.mockRestore() - } - }) }) diff --git a/src/main/window/createMainWindow-renderer-launch-failure.test.ts b/src/main/window/createMainWindow-renderer-launch-failure.test.ts new file mode 100644 index 00000000000..7befc2fe588 --- /dev/null +++ b/src/main/window/createMainWindow-renderer-launch-failure.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', async () => + (await import('./createMainWindow-test-harness')).electronModuleMock() +) +vi.mock('@electron-toolkit/utils', async () => + (await import('./createMainWindow-test-harness')).electronToolkitUtilsMock() +) +vi.mock('./macos-tahoe-release', async () => + (await import('./createMainWindow-test-harness')).macosTahoeReleaseMock() +) +vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harness')).appIconMock()) +vi.mock('../browser/browser-manager', async () => + (await import('./createMainWindow-test-harness')).browserManagerMock() +) +vi.mock('../browser/browser-client-page-renderer-runtime', async () => { + const harness = await import('./createMainWindow-test-harness') + return { + attachBrowserClientPageRenderer: harness.attachClientPageRendererMock, + retireBrowserClientPageRenderer: harness.retireClientPageRendererMock + } +}) + +import { createMainWindow } from './createMainWindow' +import type { CreateMainWindowOptions } from './main-window-contracts' +import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { + browserWindowMock, + resetMainWindowMocks, + withPlatform +} from './createMainWindow-test-harness' +import { RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS } from './renderer-launch-failure-backoff' + +// macOS LAUNCH_RESULT_FAILURE: posix_spawn of the Renderer helper failed (field: EAGAIN, per-user process limit). +const LAUNCH_FAILED: Electron.RenderProcessGoneDetails = { reason: 'launch-failed', exitCode: 1003 } +const CRASHED: Electron.RenderProcessGoneDetails = { reason: 'crashed', exitCode: 5 } + +/** + * Field shape (v1.4.218, bundle F0C6NHQF4C8): while the OS refuses spawns, every load emits launch-failed and then + * rejects ERR_FAILED within ~10ms, before any did-finish-load. Once headroom returns the same webContents loads. + */ +function createSpawnRefusingWindow(platform: NodeJS.Platform = 'darwin') { + const handlers: Record void> = {} + const spawn = { refused: true } + const webContents = { + id: 143, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isDestroyed: vi.fn(() => false), + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + handlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn() + } + const load = vi.fn((): Promise => { + if (!spawn.refused) { + queueMicrotask(() => handlers['did-finish-load']?.()) + return Promise.resolve() + } + queueMicrotask(() => + withPlatform(platform, () => handlers['render-process-gone']?.({}, LAUNCH_FAILED)) + ) + return Promise.reject( + new Error("ERR_FAILED (-2) loading 'file:///opt/orca/renderer/index.html'") + ) + }) + browserWindowMock.mockImplementation(function () { + return { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + handlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: load, + loadURL: load + } + }) + return { handlers, load, spawn } +} + +function open(overrides: CreateMainWindowOptions = {}) { + const onRendererRecoveryExhausted = vi.fn() + const onRecoveryReloadOutcome = vi.fn() + createMainWindow(null, { + onRendererRecoveryExhausted, + onRecoveryReloadOutcome, + shouldRecoverRenderer: (details) => + shouldRecoverRendererAfterProcessGone({ reason: details.reason, expectedTeardown: 'none' }), + ...overrides + }) + return { onRendererRecoveryExhausted, onRecoveryReloadOutcome } +} + +const BACKOFF_TOTAL_MS = RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + +describe('renderer launch-failed recovery', () => { + beforeEach(() => { + resetMainWindowMocks() + vi.useFakeTimers() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('backs off instead of opening the crash-loop breaker after 3 quick failures', async () => { + const { load } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(0) + expect(load).toHaveBeenCalledTimes(1) + + // Field: 3 reloads in ~750ms, then the breaker opened. Now retries spread out: 250ms, 1s, 2s, 4s, ... + await vi.advanceTimersByTimeAsync(250) + expect(load).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(999) + expect(load).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(load).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(2_000) + expect(load).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(4_000) + expect(load).toHaveBeenCalledTimes(5) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('recovers in place once spawn headroom returns, without any prompt', async () => { + const { handlers, load, spawn } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted, onRecoveryReloadOutcome } = open() + // Field: the process table stayed full for minutes. + await vi.advanceTimersByTimeAsync(250 + 1_000 + 2_000 + 4_000 + 8_000 + 15_000) + expect(load).toHaveBeenCalledTimes(7) + spawn.refused = false + + await vi.advanceTimersByTimeAsync(30_000) + expect(load).toHaveBeenCalledTimes(8) + expect(onRecoveryReloadOutcome).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'loaded' }) + ) + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(load).toHaveBeenCalledTimes(8) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + + // A loaded document starts a fresh schedule: the next launch failure retries after 250ms again. + spawn.refused = true + handlers['render-process-gone']?.({}, LAUNCH_FAILED) + await vi.advanceTimersByTimeAsync(250) + expect(load).toHaveBeenCalledTimes(9) + }) + + it('prompts only after the ~2 minute schedule is spent, then a failed manual retry re-prompts', async () => { + const { load } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(BACKOFF_TOTAL_MS - 1) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + + // The last retry fails too; its prompt follows on the usual 250ms recovery tick. + await vi.advanceTimersByTimeAsync(251) + expect(BACKOFF_TOTAL_MS).toBeLessThanOrEqual(125_000) + expect(load).toHaveBeenCalledTimes(1 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ + details: LAUNCH_FAILED, + cause: 'launch-failed', + recentRecoveryCount: RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length + }) + ) + + onRendererRecoveryExhausted.mock.calls[0]?.[0].retry() + expect(load).toHaveBeenCalledTimes(2 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length) + await vi.advanceTimersByTimeAsync(250) + expect(onRendererRecoveryExhausted).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(load).toHaveBeenCalledTimes(2 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length) + }) + + it('stops recovery when quitting during the long backoff', async () => { + const { load } = createSpawnRefusingWindow() + let quitting = false + const { onRendererRecoveryExhausted } = open({ getIsQuitting: () => quitting }) + await vi.advanceTimersByTimeAsync(250 + 1_000 + 2_000 + 4_000) + const loadsBeforeQuit = load.mock.calls.length + quitting = true + await vi.advanceTimersByTimeAsync(BACKOFF_TOTAL_MS * 2) + expect(load).toHaveBeenCalledTimes(loadsBeforeQuit) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('does not spend retry attempts on duplicate failure events', async () => { + const { handlers, load } = createSpawnRefusingWindow() + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(0) + handlers['render-process-gone']?.({}, LAUNCH_FAILED) + handlers['render-process-gone']?.({}, LAUNCH_FAILED) + await vi.advanceTimersByTimeAsync(250) + expect(load).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(999) + expect(load).toHaveBeenCalledTimes(2) + expect(onRendererRecoveryExhausted).not.toHaveBeenCalled() + }) + + it('keeps Windows launch failures on the short recovery schedule', async () => { + const { load } = createSpawnRefusingWindow('win32') + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(1_000) + expect(load).toHaveBeenCalledTimes(4) + expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce() + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'launch-failed', recentRecoveryCount: 3 }) + ) + }) + + it('keeps the crash-loop breaker for renderers that actually crash', async () => { + const { handlers, load, spawn } = createSpawnRefusingWindow() + spawn.refused = false + const { onRendererRecoveryExhausted } = open() + await vi.advanceTimersByTimeAsync(0) + for (let i = 0; i < 4; i += 1) { + handlers['render-process-gone']?.({}, CRASHED) + await vi.advanceTimersByTimeAsync(250) + } + expect(load).toHaveBeenCalledTimes(4) + expect(onRendererRecoveryExhausted).toHaveBeenCalledWith( + expect.objectContaining({ details: CRASHED, cause: 'crash-loop', recentRecoveryCount: 3 }) + ) + }) +}) diff --git a/src/main/window/createMainWindow-test-harness.ts b/src/main/window/createMainWindow-test-harness.ts index 6f7ae25da67..abf2dd8c829 100644 --- a/src/main/window/createMainWindow-test-harness.ts +++ b/src/main/window/createMainWindow-test-harness.ts @@ -146,3 +146,46 @@ export function withPlatform(platform: NodeJS.Platform, run: () => T): T { Object.defineProperty(process, 'platform', { configurable: true, value: original }) } } + +export type RendererRecoveryWindowHarness = { + browserWindowInstance: { loadFile: Mock<() => Promise>; loadURL: Mock<() => Promise> } + windowHandlers: Record void> +} + +/** A main window whose webContents events land in `windowHandlers` for suites that drive renderer recovery. */ +export function createRendererRecoveryWindowHarness(): RendererRecoveryWindowHarness { + const windowHandlers: RendererRecoveryWindowHarness['windowHandlers'] = {} + const webContents = { + id: 143, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isDestroyed: vi.fn(() => false), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(() => Promise.resolve()), + loadURL: vi.fn(() => Promise.resolve()) + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + return { browserWindowInstance, windowHandlers } +} diff --git a/src/main/window/main-window-contracts.ts b/src/main/window/main-window-contracts.ts index 5135be0fbe6..2b2e8282304 100644 --- a/src/main/window/main-window-contracts.ts +++ b/src/main/window/main-window-contracts.ts @@ -1,4 +1,5 @@ import type { KeybindingOverrides } from '../../shared/keybindings' +import type { LowCommitOomVerdict } from '../crash-reporting/low-commit-oom-recovery-gate' import type { RecoveryExhaustionCause, RecoveryReloadMilestone, @@ -31,6 +32,8 @@ export type CreateMainWindowOptions = { webContentsId: number recentRecoveryCount: number cause?: RecoveryExhaustionCause + /** Set with cause 'low-commit': the Windows commit reading that stopped the auto-reload. */ + lowCommit?: LowCommitOomVerdict /** Watched manual retry for the recovery prompt; an unwatched one cannot re-raise the prompt when it stalls too. */ retry?: () => void }) => void diff --git a/src/main/window/main-window-focus-lifecycle.ts b/src/main/window/main-window-focus-lifecycle.ts index d494992e692..6b0f1fb1002 100644 --- a/src/main/window/main-window-focus-lifecycle.ts +++ b/src/main/window/main-window-focus-lifecycle.ts @@ -9,6 +9,7 @@ import { DEFAULT_RENDERER_RECOVERY_WINDOW_MS, RendererRecoveryCircuitBreaker } from '../crash-reporting/renderer-recovery-circuit-breaker' +import { createLowCommitOomRecoveryGate } from '../crash-reporting/low-commit-oom-recovery-gate' import { buildEditableContextMenuTemplate, matchingRichMarkdownContextMenuTableTarget, @@ -22,6 +23,7 @@ import { } from '../browser/browser-client-page-renderer-runtime' import { registerRendererDocumentNavigation } from './renderer-document-navigation' import { createRendererRecoveryReloadWatchdog } from './renderer-recovery-reload-watchdog' +import { createRendererLaunchFailureBackoff } from './renderer-launch-failure-backoff' export type MainWindowFocusLifecycle = { dispose: () => void @@ -159,6 +161,8 @@ export function installMainWindowFocusLifecycle(args: { windowMs: DEFAULT_RENDERER_RECOVERY_WINDOW_MS, maxRecoveries: DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES }) + const launchFailureBackoff = createRendererLaunchFailureBackoff() + const lowCommitOomGate = createLowCommitOomRecoveryGate() const clearRendererRecoveryTimer = (): void => { if (rendererRecoveryTimer) { clearTimeout(rendererRecoveryTimer) @@ -187,6 +191,12 @@ export function installMainWindowFocusLifecycle(args: { ) { return } + const launchFailed = details.reason === 'launch-failed' + const useLaunchBackoff = launchFailed && process.platform !== 'win32' + const launchRetryDelayMs = useLaunchBackoff ? launchFailureBackoff.nextDelayMs() : null + const goneAt = Date.now() + // Why read at gone time: the sampler's next tick would see commit the corpse just released. + const lowCommit = lowCommitOomGate.assess(details, goneAt) rendererRecoveryTimer = setTimeout(() => { rendererRecoveryTimer = null if ( @@ -197,6 +207,29 @@ export function installMainWindowFocusLifecycle(args: { ) { return } + lowCommitOomGate.recordRecoveredDeath(details, goneAt) + if (lowCommit) { + // Why: a reload would OOM again on the starved host; only the user can free commit. + recoveryReloadWatchdog.escalate( + { + details, + recentRecoveryCount: rendererRecoveryCircuitBreaker.recentRecoveryCount(Date.now()) + }, + 'low-commit', + lowCommit + ) + return + } + // Why outside the breaker: a refused spawn is not a crash loop; its own bounded backoff owns the budget. + if (useLaunchBackoff) { + const subject = { details, recentRecoveryCount: launchFailureBackoff.attempts() } + if (launchRetryDelayMs === null) { + recoveryReloadWatchdog.escalate(subject, 'launch-failed') + } else { + recoveryReloadWatchdog.issue(details, subject.recentRecoveryCount) + } + return + } const recovery = rendererRecoveryCircuitBreaker.registerRecoveryAttempt(Date.now()) if (!recovery.allowed) { // Why: too many reloads means it will just crash again; stop and let the host surface a recovery prompt. @@ -204,13 +237,13 @@ export function installMainWindowFocusLifecycle(args: { // recovery reload too — unwatched, one that stalls leaves a blank window and no further prompt. recoveryReloadWatchdog.escalate( { details, recentRecoveryCount: recovery.recentRecoveryCount }, - 'crash-loop' + launchFailed ? 'launch-failed' : 'crash-loop' ) return } // Why: a transient renderer/Network Service loss can blank Chromium; reload the app document once to recover. recoveryReloadWatchdog.issue(details, recovery.recentRecoveryCount) - }, 250) + }, launchRetryDelayMs ?? 250) } mainWindow.webContents.on('render-process-gone', (_event, details) => { rendererProcessGone = true @@ -241,6 +274,7 @@ export function installMainWindowFocusLifecycle(args: { rendererProcessGone = false attachBrowserClientPageRenderer(rendererWebContents) clearRendererRecoveryTimer() + launchFailureBackoff.reset() recoveryReloadWatchdog.notifyDocumentLoaded() }) diff --git a/src/main/window/renderer-launch-failure-backoff.test.ts b/src/main/window/renderer-launch-failure-backoff.test.ts new file mode 100644 index 00000000000..193e6bd4d20 --- /dev/null +++ b/src/main/window/renderer-launch-failure-backoff.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + createRendererLaunchFailureBackoff, + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS +} from './renderer-launch-failure-backoff' + +describe('createRendererLaunchFailureBackoff', () => { + it('keeps the 250ms first retry and spreads the rest over about two minutes', () => { + expect(RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS[0]).toBe(250) + const total = RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + expect(total).toBeGreaterThanOrEqual(90_000) + expect(total).toBeLessThanOrEqual(125_000) + }) + + it('walks the schedule, then reports it spent until reset', () => { + const backoff = createRendererLaunchFailureBackoff([10, 20]) + expect(backoff.nextDelayMs()).toBe(10) + expect(backoff.nextDelayMs()).toBe(20) + expect(backoff.nextDelayMs()).toBeNull() + expect(backoff.attempts()).toBe(2) + backoff.reset() + expect(backoff.attempts()).toBe(0) + expect(backoff.nextDelayMs()).toBe(10) + }) +}) diff --git a/src/main/window/renderer-launch-failure-backoff.ts b/src/main/window/renderer-launch-failure-backoff.ts new file mode 100644 index 00000000000..f815a0d9597 --- /dev/null +++ b/src/main/window/renderer-launch-failure-backoff.ts @@ -0,0 +1,31 @@ +// Why: a refused renderer spawn (e.g. macOS 1003 at the process limit) lasts minutes, so retry over ~2 min, not the breaker's 750ms. +export const RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS: readonly number[] = [ + 250, 1_000, 2_000, 4_000, 8_000, 15_000, 30_000, 60_000 +] + +export type RendererLaunchFailureBackoff = { + /** Delay before the next retry, or null once the schedule is spent. */ + nextDelayMs: () => number | null + attempts: () => number + reset: () => void +} + +export function createRendererLaunchFailureBackoff( + delaysMs: readonly number[] = RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS +): RendererLaunchFailureBackoff { + let attempts = 0 + return { + nextDelayMs: () => { + const delay = delaysMs[attempts] + if (delay === undefined) { + return null + } + attempts += 1 + return delay + }, + attempts: () => attempts, + reset: () => { + attempts = 0 + } + } +} diff --git a/src/main/window/renderer-launch-failure-probe.spawn.test.ts b/src/main/window/renderer-launch-failure-probe.spawn.test.ts new file mode 100644 index 00000000000..aa6c13e609f --- /dev/null +++ b/src/main/window/renderer-launch-failure-probe.spawn.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { probeRendererLaunchCapacity } from './renderer-launch-failure-probe' + +describe.skipIf(process.platform === 'win32')('probeRendererLaunchCapacity (real spawn)', () => { + it('reports ok when the host can start a process', async () => { + await expect(probeRendererLaunchCapacity()).resolves.toBe('ok') + }) +}) diff --git a/src/main/window/renderer-launch-failure-probe.test.ts b/src/main/window/renderer-launch-failure-probe.test.ts new file mode 100644 index 00000000000..29714f84dad --- /dev/null +++ b/src/main/window/renderer-launch-failure-probe.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { runProcessMock, recordDurableCrashBreadcrumbMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), + recordDurableCrashBreadcrumbMock: vi.fn() +})) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) +vi.mock('../crash-reporting/durable-crash-breadcrumb', () => ({ + recordDurableCrashBreadcrumb: recordDurableCrashBreadcrumbMock +})) + +import { withPlatform } from './createMainWindow-test-harness' + +import { + classifyLaunchProbeError, + probeRendererLaunchCapacity, + recordRendererLaunchFailureProbe +} from './renderer-launch-failure-probe' + +function spawnError(code: string): Error { + return Object.assign(new Error(`spawn /bin/sh ${code}`), { code, errno: -35, syscall: 'spawn' }) +} + +function recordPosixFailure(now: number) { + return withPlatform('darwin', () => recordRendererLaunchFailureProbe({ exitCode: 1003 }, now)) +} + +describe('classifyLaunchProbeError', () => { + it.each(['EAGAIN', 'EACCES', 'ENOENT', 'EMFILE'])('reports the spawn errno %s', (code) => { + expect(classifyLaunchProbeError(spawnError(code))).toBe(code) + }) + + it.each([ + ['a plain error', new Error('boom')], + ['a non-errno code', Object.assign(new Error('x'), { code: 'ERR_INVALID_ARG_TYPE' })], + ['a numeric code', Object.assign(new Error('x'), { code: 11 })], + ['a non-error throw', 'EAGAIN'] + ])('reports unknown for %s', (_label, error) => { + expect(classifyLaunchProbeError(error)).toBe('unknown') + }) +}) + +describe('probeRendererLaunchCapacity', () => { + afterEach(() => { + runProcessMock.mockReset() + recordDurableCrashBreadcrumbMock.mockReset() + }) + + it('reports ok for any child that started, whatever its exit', async () => { + runProcessMock.mockResolvedValue({ + code: 1, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) + await expect(probeRendererLaunchCapacity('darwin')).resolves.toBe('ok') + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ program: '/bin/sh', args: ['-c', 'exit 0'] }) + ) + }) + + // Windows EDR scores a short-lived child per operation; the breadcrumb still records the launch failure. + it('skips the spawn on Windows', async () => { + await expect(probeRendererLaunchCapacity('win32')).resolves.toBe('skipped') + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('records a skipped Windows probe without spawning a child', async () => { + await expect( + withPlatform('win32', () => recordRendererLaunchFailureProbe({ exitCode: 18 }, 100_000)) + ).resolves.toBe('skipped') + expect(runProcessMock).not.toHaveBeenCalled() + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_launch_failed_probe', { + spawnError: 'skipped', + exitCode: 18 + }) + }) + + it('never rejects when the breadcrumb write throws', async () => { + runProcessMock.mockRejectedValue(spawnError('EAGAIN')) + recordDurableCrashBreadcrumbMock.mockImplementation(() => { + throw new Error('disk full') + }) + await expect(recordPosixFailure(90_000)).resolves.toBe('EAGAIN') + }) + + it('records the refused spawn as a durable breadcrumb', async () => { + runProcessMock.mockRejectedValue(spawnError('EAGAIN')) + await expect(recordPosixFailure(10_000)).resolves.toBe('EAGAIN') + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledWith('renderer_launch_failed_probe', { + spawnError: 'EAGAIN', + exitCode: 1003 + }) + }) + + it('probes once for the duplicate render-process-gone of one failed launch', async () => { + runProcessMock.mockRejectedValue(spawnError('EAGAIN')) + await recordPosixFailure(50_000) + await recordPosixFailure(50_010) + expect(runProcessMock).toHaveBeenCalledOnce() + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledOnce() + + // The next backoff retry fails again: a fresh probe and breadcrumb. + await recordPosixFailure(50_250) + expect(runProcessMock).toHaveBeenCalledTimes(2) + expect(recordDurableCrashBreadcrumbMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/window/renderer-launch-failure-probe.ts b/src/main/window/renderer-launch-failure-probe.ts new file mode 100644 index 00000000000..fd81b419c6f --- /dev/null +++ b/src/main/window/renderer-launch-failure-probe.ts @@ -0,0 +1,69 @@ +import { runProcess } from '../../shared/child-process/run-process' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' + +/** 'ok' when a throwaway child could start, the spawn errno (EAGAIN = per-user process limit), or 'skipped' on Windows. */ +export type RendererLaunchProbeResult = string + +const PROBE_TIMEOUT_MS = 5_000 +const ERRNO_CODE = /^E[A-Z0-9]{1,15}$/ + +export function classifyLaunchProbeError(error: unknown): RendererLaunchProbeResult { + const code = error instanceof Error && 'code' in error ? error.code : undefined + return typeof code === 'string' && ERRNO_CODE.test(code) ? code : 'unknown' +} + +/** + * Chromium reports any failed helper spawn as launch-failed (macOS exit 1003 = LAUNCH_RESULT_FAILURE), + * so spawn an unrelated child to learn whether the OS is refusing every new process. + */ +export async function probeRendererLaunchCapacity( + platform: NodeJS.Platform = process.platform +): Promise { + // Why skip Windows: a short-lived child per failed launch is the per-operation spawn burst EDR scores (windows-edr-posture.md). + if (platform === 'win32') { + return 'skipped' + } + try { + // Exit status is irrelevant: a child that started proves spawn headroom. /bin/sh is at a fixed path on every POSIX host. + await runProcess({ + program: '/bin/sh', + args: ['-c', 'exit 0'], + timeoutMs: PROBE_TIMEOUT_MS, + maxOutputBytes: 4096 + }) + return 'ok' + } catch (error) { + return classifyLaunchProbeError(error) + } +} + +// Electron emits render-process-gone twice (~10ms apart) for one failed launch; probe and record it once. +const PROBE_COALESCE_MS = 100 +let lastProbe: { startedAt: number; result: Promise } | null = null + +export function recordRendererLaunchFailureProbe( + details: Pick, + now: number = Date.now() +): Promise { + if ( + lastProbe && + now - lastProbe.startedAt >= 0 && + now - lastProbe.startedAt < PROBE_COALESCE_MS + ) { + return lastProbe.result + } + // Never rejects: callers fire-and-forget it from the render-process-gone handler. + const result = probeRendererLaunchCapacity().then((spawnError) => { + try { + recordDurableCrashBreadcrumb('renderer_launch_failed_probe', { + spawnError, + exitCode: details.exitCode ?? null + }) + } catch { + // Diagnostics only. + } + return spawnError + }) + lastProbe = { startedAt: now, result } + return result +} diff --git a/src/main/window/renderer-recovery-prompt.test.ts b/src/main/window/renderer-recovery-prompt.test.ts index a26700720eb..2415fadac58 100644 --- a/src/main/window/renderer-recovery-prompt.test.ts +++ b/src/main/window/renderer-recovery-prompt.test.ts @@ -100,6 +100,34 @@ describe('presentRendererRecoveryPrompt', () => { expect(shown[0].detail).not.toContain('times in a row') }) + it('says Windows is out of memory with the commit left, instead of blaming drivers', async () => { + const { run, shown, reload } = harness({ + failure: 'low-commit', + availableCommitMB: 60, + responses: [0] + }) + await run() + expect(shown[0].message).toBe('Windows is out of memory.') + expect(shown[0].detail).toContain('only 60 MB of memory left') + expect(shown[0].detail).toContain('increase the Windows page file size') + expect(shown[0].detail).not.toContain('graphics') + expect(shown[0].buttons).toEqual(['Reload', 'Quit']) + expect(reload).toHaveBeenCalledOnce() + }) + + it('omits Copy Commands on low commit, since its detail would not be shown', async () => { + const { run, shown, quit, copied } = harness({ + failure: 'low-commit', + availableCommitMB: 60, + diagnose: () => POISON, + responses: [1] + }) + await run() + expect(shown[0].buttons).toEqual(['Reload', 'Quit']) + expect(copied).toEqual([]) + expect(quit).toHaveBeenCalledOnce() + }) + it('quits on the last button', async () => { const { run, reload, quit } = harness({ responses: [1] }) await run() @@ -155,4 +183,91 @@ describe('presentRendererRecoveryPrompt', () => { expect(shown[0].detail).toContain('repairing now') expect(shown[1].detail).toContain('repaired') }) + + describe('after a renderer launch failure', () => { + it('names the process limit when the probe is refused with EAGAIN', async () => { + const probeLaunchCapacity = vi.fn(async () => 'EAGAIN') + const { run, shown, reload, quit } = harness({ + failure: 'launch-failed', + recentRecoveryCount: 8, + probeLaunchCapacity + }) + await run() + expect(probeLaunchCapacity).toHaveBeenCalledOnce() + // No Restart: app.relaunch needs a free process slot too and silently fails without one. + expect(shown[0].buttons).toEqual(['Try Again', 'Quit']) + expect(shown[0].defaultId).toBe(0) + expect(shown[0].cancelId).toBe(0) + expect(shown[0].message).toContain("couldn't start the process") + expect(shown[0].detail).toContain('retried 8 times') + expect(shown[0].detail).toContain('process limit') + expect(shown[0].detail).not.toMatch(/graphics|driver/i) + expect(reload).toHaveBeenCalledOnce() + expect(quit).not.toHaveBeenCalled() + }) + + it('drops the graphics-driver blame when the probe could spawn', async () => { + const { run, shown, quit } = harness({ + failure: 'launch-failed', + probeLaunchCapacity: async () => 'ok', + responses: [1] + }) + await run() + expect(shown[0].detail).not.toContain('process limit') + expect(shown[0].detail).not.toMatch(/graphics|driver/i) + expect(shown[0].detail).toContain('Try Again') + expect(quit).toHaveBeenCalledOnce() + }) + + it('probes once even when Copy Commands re-shows the box', async () => { + const probeLaunchCapacity = vi.fn(async () => 'ok') + const { run, shown, reload } = harness({ + failure: 'launch-failed', + diagnose: () => POISON, + probeLaunchCapacity, + responses: [1, 1, 0] + }) + await run() + expect(shown).toHaveLength(3) + expect(probeLaunchCapacity).toHaveBeenCalledOnce() + expect(reload).toHaveBeenCalledOnce() + }) + + it('shows nothing when the app starts quitting during the probe', async () => { + let quitting = false + const { run, shown, reload } = harness({ + failure: 'launch-failed', + isQuitting: () => quitting, + probeLaunchCapacity: async () => { + quitting = true + return 'EAGAIN' + } + }) + await run() + expect(shown).toEqual([]) + expect(reload).not.toHaveBeenCalled() + }) + + it('does not probe once the app is already quitting', async () => { + const probeLaunchCapacity = vi.fn(async () => 'ok') + const { run } = harness({ + failure: 'launch-failed', + isQuitting: () => true, + probeLaunchCapacity + }) + await run() + expect(probeLaunchCapacity).not.toHaveBeenCalled() + }) + + it('keeps the install-permission diagnosis and its copy button', async () => { + const { run, shown } = harness({ + failure: 'launch-failed', + diagnose: () => POISON, + probeLaunchCapacity: async () => 'ok' + }) + await run() + expect(shown[0].buttons).toEqual(['Try Again', 'Copy Commands', 'Quit']) + expect(shown[0].detail).toContain(POISON.detail) + }) + }) }) diff --git a/src/main/window/renderer-recovery-prompt.ts b/src/main/window/renderer-recovery-prompt.ts index 18ab02a8eca..73992b666e6 100644 --- a/src/main/window/renderer-recovery-prompt.ts +++ b/src/main/window/renderer-recovery-prompt.ts @@ -2,14 +2,19 @@ import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron' import { translateMain } from '../i18n/main-i18n' import type { InstallDirAclPoisonDiagnosis } from '../startup/windows-install-dir-acl-recovery' import type { RecoveryExhaustionCause } from './renderer-recovery-reload-watchdog' +import type { RendererLaunchProbeResult } from './renderer-launch-failure-probe' export type RendererRecoveryPromptFailure = RecoveryExhaustionCause export type RendererRecoveryPromptDeps = { recentRecoveryCount: number failure?: RendererRecoveryPromptFailure + /** Shown with failure 'low-commit'. */ + availableCommitMB?: number isQuitting: () => boolean diagnose: () => InstallDirAclPoisonDiagnosis | null + /** Re-checks spawn headroom so a launch-failed prompt can name the process limit. */ + probeLaunchCapacity?: () => Promise showMessageBox: (options: MessageBoxOptions) => Promise copyToClipboard: (text: string) => void reload: () => void @@ -20,33 +25,31 @@ export async function presentRendererRecoveryPrompt( deps: RendererRecoveryPromptDeps ): Promise { const stalled = deps.failure === 'reload-stalled' + const launchFailed = deps.failure === 'launch-failed' + const lowCommit = deps.failure === 'low-commit' + if (deps.isQuitting()) { + return + } + // Probed once: Copy Commands re-shows the box and must not spawn again. The loop re-checks quitting after it. + const launchProbe = launchFailed ? await deps.probeLaunchCapacity?.() : undefined // Copying must preserve the only available recovery surface. while (!deps.isQuitting()) { - const diagnosis = deps.diagnose() - const buttons = [translateMain('rendererRecovery.reload', 'Reload')] + const diagnosis = lowCommit ? null : deps.diagnose() + // Why no Restart button: relaunching needs a free process slot too, and app.relaunch fails silently without one. + const buttons = [ + launchFailed + ? translateMain('rendererRecovery.tryAgain', 'Try Again') + : translateMain('rendererRecovery.reload', 'Reload') + ] if (diagnosis) { buttons.push(translateMain('rendererRecovery.copyCommands', 'Copy Commands')) } buttons.push(translateMain('rendererRecovery.quit', 'Quit')) - const recoveryDetail = stalled - ? translateMain( - 'rendererRecovery.stalledDetail', - 'Orca reloaded the window after a crash, but it never finished loading.' - ) - : translateMain( - 'rendererRecovery.crashLoopDetail', - 'Orca tried to recover {{recoveryCount}} times in a row without success.', - { recoveryCount: deps.recentRecoveryCount } - ) - const causeDetail = diagnosis - ? `${diagnosis.detail}\n\n${translateMain( - 'rendererRecovery.driverFallback', - 'If that does not help, the cause is usually a graphics driver.' - )}` - : translateMain( - 'rendererRecovery.genericDetail', - 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' - ) + const content = launchFailed + ? describeLaunchFailure(deps.recentRecoveryCount, launchProbe, diagnosis) + : lowCommit + ? describeLowCommit(deps.availableCommitMB ?? 0) + : describeRendererCrash(stalled, deps.recentRecoveryCount, diagnosis) const { response } = await deps.showMessageBox({ type: 'error', buttons, @@ -54,16 +57,7 @@ export async function presentRendererRecoveryPrompt( // Escape retries instead of destroying the session. cancelId: 0, title: translateMain('rendererRecovery.title', 'Orca keeps failing to load'), - message: stalled - ? translateMain( - 'rendererRecovery.stalledMessage', - 'The app window stopped responding while reloading after a crash.' - ) - : translateMain( - 'rendererRecovery.crashLoopMessage', - 'The app window crashed repeatedly and stopped reloading automatically.' - ), - detail: `${recoveryDetail}\n\n${causeDetail}` + ...content }) if (response === 1 && diagnosis) { deps.copyToClipboard(diagnosis.commands.join('\r\n')) @@ -77,3 +71,87 @@ export async function presentRendererRecoveryPrompt( return } } + +type PromptContent = { message: string; detail: string } + +function describeLowCommit(availableMB: number): PromptContent { + const recoveryDetail = translateMain( + 'rendererRecovery.lowCommitDetail', + 'Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.', + { availableMB } + ) + const advice = translateMain( + 'rendererRecovery.lowCommitAdvice', + 'Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload.' + ) + return { + message: translateMain('rendererRecovery.lowCommitMessage', 'Windows is out of memory.'), + detail: `${recoveryDetail}\n\n${advice}` + } +} + +function describeLaunchFailure( + attempts: number, + probe: RendererLaunchProbeResult | undefined, + diagnosis: InstallDirAclPoisonDiagnosis | null +): PromptContent { + const message = translateMain( + 'rendererRecovery.launchFailedMessage', + "Orca couldn't start the process that draws its window." + ) + const retried = translateMain( + 'rendererRecovery.launchFailedDetail', + 'Orca retried {{recoveryCount}} times without success.', + { recoveryCount: attempts } + ) + const cause = + probe === 'EAGAIN' + ? translateMain( + 'rendererRecovery.processLimitDetail', + 'The system refused to create a new process because your user account reached its process limit. This is often caused by runaway terminals, dev servers, or agents. Close some of them, then click Try Again.' + ) + : (diagnosis?.detail ?? + translateMain( + 'rendererRecovery.launchFailedGenericDetail', + 'The system refused to start it. Free up memory or close other apps, then click Try Again. If this keeps happening, reinstall Orca.' + )) + return { message, detail: `${retried}\n\n${cause}` } +} + +function describeRendererCrash( + stalled: boolean, + recoveryCount: number, + diagnosis: InstallDirAclPoisonDiagnosis | null +): PromptContent { + const recoveryDetail = stalled + ? translateMain( + 'rendererRecovery.stalledDetail', + 'Orca reloaded the window after a crash, but it never finished loading.' + ) + : translateMain( + 'rendererRecovery.crashLoopDetail', + 'Orca tried to recover {{recoveryCount}} times in a row without success.', + { recoveryCount } + ) + const causeDetail = diagnosis + ? `${diagnosis.detail}\n\n${translateMain( + 'rendererRecovery.driverFallback', + 'If that does not help, the cause is usually a graphics driver.' + )}` + : translateMain( + 'rendererRecovery.genericDetail', + 'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.' + ) + return { + message: stalled + ? translateMain( + 'rendererRecovery.stalledMessage', + 'The app window stopped responding while reloading after a crash.' + ) + : translateMain( + 'rendererRecovery.crashLoopMessage', + 'The app window crashed repeatedly and stopped reloading automatically.' + ), + detail: `${recoveryDetail}\n\n${causeDetail}` + } +} diff --git a/src/main/window/renderer-recovery-reload-watchdog.ts b/src/main/window/renderer-recovery-reload-watchdog.ts index 1295ca13ac4..4d21da2a2cc 100644 --- a/src/main/window/renderer-recovery-reload-watchdog.ts +++ b/src/main/window/renderer-recovery-reload-watchdog.ts @@ -1,6 +1,7 @@ import { is } from '@electron-toolkit/utils' import type { BrowserWindow } from 'electron' import { isSystemSessionEnding } from '../crash-reporting/expected-teardown-state' +import type { LowCommitOomVerdict } from '../crash-reporting/low-commit-oom-recovery-gate' import type { CreateMainWindowOptions, MainWindowLoadObserver } from './main-window-contracts' import { mainWindowLoadErrorCode } from './main-window-load-error-code' @@ -24,7 +25,11 @@ const MILESTONE_RANK: Record = { 'dom-ready': 2 } -export type RecoveryExhaustionCause = 'crash-loop' | 'reload-stalled' +export type RecoveryExhaustionCause = + | 'crash-loop' + | 'reload-stalled' + | 'launch-failed' + | 'low-commit' export type RendererRecoveryReloadWatchdog = { /** Issues a recovery reload and arms the stall watchdog. */ @@ -34,7 +39,11 @@ export type RendererRecoveryReloadWatchdog = { trigger?: RecoveryReloadTrigger ) => void /** Raises the recovery prompt at most once: a native message box cannot be dismissed, so a second one stacks. */ - escalate: (subject: RecoveryPromptSubject, cause: RecoveryExhaustionCause) => void + escalate: ( + subject: RecoveryPromptSubject, + cause: RecoveryExhaustionCause, + lowCommit?: LowCommitOomVerdict + ) => void /** * A main-frame document finished loading. Only an attempt whose load was superseded takes this as its outcome; * every other attempt settles through its own load promise, which an error page or a later navigation cannot fool. @@ -197,7 +206,11 @@ export function createRendererRecoveryReloadWatchdog(args: { ) } - const escalate = (subject: RecoveryPromptSubject, cause: RecoveryExhaustionCause): void => { + const escalate = ( + subject: RecoveryPromptSubject, + cause: RecoveryExhaustionCause, + lowCommit?: LowCommitOomVerdict + ): void => { // A new crash invalidates any document that landed while the prompt was open. documentLanded = false if (prompt) { @@ -209,6 +222,7 @@ export function createRendererRecoveryReloadWatchdog(args: { webContentsId: rendererWebContentsId, recentRecoveryCount: subject.recentRecoveryCount, cause, + ...(lowCommit ? { lowCommit } : {}), // Watch manual retries too, so another stall can offer recovery again. retry: () => retryFrom(subject) }) diff --git a/src/main/worktree-create-base-prefetch.test.ts b/src/main/worktree-create-base-prefetch.test.ts index af80afe4b41..abc338cdad7 100644 --- a/src/main/worktree-create-base-prefetch.test.ts +++ b/src/main/worktree-create-base-prefetch.test.ts @@ -186,7 +186,7 @@ describe('prefetchWorktreeCreateBase local git routing', () => { describe('checkout and refresh overlap', () => { it.each([{}, WSL])( - 'starts one checkout before a blocked refresh finishes on %j', + 'starts one checkout with a shared refresh barrier before fetch settles on %j', async (gitOptions) => { const base = { remote: 'origin', @@ -199,11 +199,17 @@ describe('checkout and refresh overlap', () => { let release!: () => void mocks.getOrStartRemoteTrackingBaseRefresh.mockImplementation( () => - new Promise((resolve) => { - release = resolve + new Promise<{ ok: boolean }>((resolve) => { + release = () => resolve({ ok: true }) }) ) - const prepareCheckout = vi.fn().mockResolvedValue(undefined) + let materialized = false + const prepareCheckout = vi.fn( + async (_base: string, beforeMaterialization?: Promise) => { + await beforeMaterialization + materialized = true + } + ) let settled = false const result = prefetchWorktreeCreateBase({ repo, @@ -214,11 +220,16 @@ describe('checkout and refresh overlap', () => { }).finally(() => { settled = true }) - await vi.waitFor(() => expect(prepareCheckout).toHaveBeenCalledWith('origin/main')) + await vi.waitFor(() => + expect(prepareCheckout).toHaveBeenCalledWith('origin/main', expect.any(Promise)) + ) expect(settled).toBe(false) + expect(materialized).toBe(false) + expect(mocks.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledOnce() release() await expect(result).resolves.toBe('origin/main') - expect(prepareCheckout).toHaveBeenCalledTimes(1) + expect(materialized).toBe(true) + expect(prepareCheckout).toHaveBeenCalledOnce() } ) @@ -232,8 +243,8 @@ describe('checkout and refresh overlap', () => { let release!: () => void mocks.getOrStartRemoteTrackingBaseRefresh.mockImplementation( () => - new Promise((resolve) => { - release = resolve + new Promise<{ ok: boolean }>((resolve) => { + release = () => resolve({ ok: true }) }) ) const prepareCheckout = vi.fn().mockResolvedValue(undefined) @@ -307,6 +318,35 @@ describe('checkout and refresh overlap', () => { release() await assertion }) + + it('settles the materialization barrier on fetch failure without hiding the fetch error', async () => { + mocks.resolveRemoteTrackingBase.mockResolvedValue({ + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + mocks.hasRemoteTrackingRef.mockResolvedValue(true) + const error = new Error('offline') + mocks.getOrStartRemoteTrackingBaseRefresh.mockRejectedValue(error) + let materialized = false + const prepareCheckout = vi.fn(async (_base: string, beforeMaterialization?: Promise) => { + await beforeMaterialization + materialized = true + }) + await expect( + prefetchWorktreeCreateBase({ + repo, + baseBranch: 'origin/main', + runtime: runtime(), + gitOptions: {}, + prepareCheckout + }) + ).rejects.toBe(error) + expect(materialized).toBe(true) + expect(prepareCheckout).toHaveBeenCalledOnce() + expect(mocks.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledOnce() + }) }) it('does not prepare folder repositories', async () => { diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index 8db177a3e9c..c322ce077c8 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -33,7 +33,7 @@ type WorktreeCreateBasePrefetchRuntime = { repoPath: string, base: RemoteTrackingBaseForPrefetch, options?: WorktreeCreateBaseGitOptions - ) => Promise + ) => Promise<{ ok: boolean }> fetchRemoteWithCache: ( repoPath: string, remote: string, @@ -46,7 +46,7 @@ async function prefetchLocalWorktreeCreateBase( baseBranch: string | undefined, runtime: WorktreeCreateBasePrefetchRuntime, options: WorktreeCreateBaseGitOptions, - prepareLocalCheckout: (base: string) => void + prepareLocalCheckout: (base: string, beforeMaterialization?: Promise) => void ): Promise { // Keep host-routed calls at their original arity so they stay on the runtime's default options. const optionArgs: [] | [WorktreeCreateBaseGitOptions] = options.wslDistro ? [options] : [] @@ -89,19 +89,24 @@ async function prefetchLocalWorktreeCreateBase( remoteTrackingBase, ...optionArgs ) - if (hasTrackingRef) { - // Finalization revalidates the refreshed commit before exposing the checkout. - prepareLocalCheckout(resolvedBaseBranch) - } if ( hasTrackingRef || !(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch, options)) ) { - await runtime.getOrStartRemoteTrackingBaseRefresh( + const refresh = runtime.getOrStartRemoteTrackingBaseRefresh( repo.path, remoteTrackingBase, ...optionArgs ) + if (hasTrackingRef) { + // Register during fetch, then materialize the settled base once; offline keeps its local tip. + const beforeMaterialization = refresh.then( + () => {}, + () => {} + ) + prepareLocalCheckout(resolvedBaseBranch, beforeMaterialization) + } + await refresh return resolvedBaseBranch } } @@ -124,7 +129,7 @@ export async function prefetchWorktreeCreateBase(args: { /** Routing for the project's Git host; required so a caller cannot silently * warm up the wrong ref store — pass `{}` for host Git. */ gitOptions: WorktreeCreateBaseGitOptions - prepareCheckout?: (base: string) => Promise + prepareCheckout?: (base: string, beforeMaterialization?: Promise) => Promise }): Promise { if (isFolderRepo(args.repo)) { return undefined @@ -139,12 +144,11 @@ export async function prefetchWorktreeCreateBase(args: { } const prepareCheckout = args.prepareCheckout let preparation: Promise | undefined - const prepare = (base: string): void => { + const prepare = (base: string, beforeMaterialization?: Promise): void => { if (!preparation && prepareCheckout) { - preparation = Promise.resolve() - .then(() => prepareCheckout(base)) - .catch(() => {}) + preparation = Promise.resolve().then(() => prepareCheckout(base, beforeMaterialization)) } + void preparation?.catch(() => {}) } try { const base = await prefetchLocalWorktreeCreateBase( @@ -160,6 +164,6 @@ export async function prefetchWorktreeCreateBase(args: { return base } finally { // Settle speculative work even if refresh fails; Create owns error reporting. - await preparation + await preparation?.catch(() => {}) } } diff --git a/src/main/worktree-create-preparation-cancellation.test.ts b/src/main/worktree-create-preparation-cancellation.test.ts index 7974dcb7e3d..fc171f498db 100644 --- a/src/main/worktree-create-preparation-cancellation.test.ts +++ b/src/main/worktree-create-preparation-cancellation.test.ts @@ -118,7 +118,7 @@ describe('worktree create preparation cancellation', () => { expect(signal?.aborted).toBe(true) expect((await settled)[0].status).toBe('rejected') await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, obsoletePath, {}) + expect(mocks.discard).toHaveBeenCalledWith(repo.path, obsoletePath, {}, expect.any(String)) }) it('does not retry a discard whose registration the aborted checkout already removed', async () => { diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts index a72972c9d20..280be5af895 100644 --- a/src/main/worktree-create-preparation-pool.ts +++ b/src/main/worktree-create-preparation-pool.ts @@ -9,6 +9,8 @@ import { } from '../shared/worktree/create-preparation' import type { AddWorktreeOptions } from './git/worktree' import { prepareWorktreeCreateCheckout } from './git/worktree-create-preparation' +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' +import { queuePreparedWorktreeTipRefresh } from './worktree-preparation-refresh-queue' import { toHostFilesystemPath } from './host-tree-removal' import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim' import { @@ -17,7 +19,8 @@ import { resetStalePreparationCleanupForTests } from './worktree-create-preparation-stale-cleanup' import { - discardPreparationWithRetry, + discardPreparationEntry, + preparationHostKey, resetPendingPreparationDiscardsForTests, trackPreparationDiscard } from './worktree-preparation-discard-retry' @@ -35,6 +38,7 @@ export type PreparationEntry = { baseBranch: string canonicalBase: string preparedPath: string + lockReason: string options: AddWorktreeOptions createdAt: number ready: Promise @@ -49,6 +53,7 @@ export type StartPreparationArgs = { baseBranch: string canonicalBase: string options: AddWorktreeOptions + beforeMaterialization?: Promise } export type DeferredPreparation = { @@ -64,38 +69,14 @@ export type PreparationClaim = { } const claims = new Set() -/** One repo on one Git host: the scope a stranded discard is retried under. */ -function preparationHostKey(repoPathKey: string, wslDistro: string): string { - return `${repoPathKey}\0${wslDistro}` -} - /** A prepared checkout is a create that is either in flight or imminent. */ export function hasPendingPreparations(): boolean { return preparations.size > 0 || claims.size > 0 || hasPendingStalePreparationCleanup() } -function pathOps(path: string): Pick { - return isWindowsAbsolutePathLike(path) ? win32 : posix -} - -async function discardEntry(entry: PreparationEntry): Promise { - // A failed checkout self-discards, but that self-discard is best-effort too, so it can strand the - // registration for the same reason the discard here can. Enrol either way. - await entry.ready.catch(() => {}) - if (!entry.checkoutStarted) { - return - } - await discardPreparationWithRetry({ - hostKey: preparationHostKey(entry.repoPathKey, entry.wslDistro), - repoPath: entry.repoPath, - preparedPath: entry.preparedPath, - options: entry.options - }) -} - function discardEntryInBackground(entry: PreparationEntry): void { // Tracked, not bare `void`: the test reset must be able to settle it before dropping the registry. - trackPreparationDiscard(worktreePreparationGit.run(() => discardEntry(entry))) + trackPreparationDiscard(worktreePreparationGit.run(() => discardPreparationEntry(entry))) } function expireEntry(entry: PreparationEntry): void { @@ -107,16 +88,7 @@ function expireEntry(entry: PreparationEntry): void { discardEntryInBackground(entry) } -/** - * Frees a slot for an incoming preparation, preferring one the same workspace already owns. - * - * The cap is a disk bound — a prepared checkout is a full tree, ~200 MB of tracked content in the - * repo this was measured against — so it stays small. But flipping through the composer's base - * picker arms several preparations for one repo, and a plain oldest-first eviction let that churn - * throw away another project's warm checkout, which is a structural miss for anyone working across - * several repos. Evict the incoming workspace's own oldest entry first; only reach across - * workspaces when this one holds none. - */ +/** Bound full-checkout disk use, evicting the requesting workspace's oldest preparation first. */ function enforcePreparationLimit( repoPathKey: string, workspaceRootKey: string, @@ -230,7 +202,9 @@ export function startPreparation( args.options.wslDistro ?? '' ) if (existing) { - return existing.ready + return args.beforeMaterialization + ? refreshPreparationTip(existing, args.beforeMaterialization) + : existing.ready } if (deferPreparationForClaim(args, kind)) { return Promise.resolve() @@ -238,12 +212,34 @@ export function startPreparation( return worktreePreparationGit.run(() => startBackgroundPreparation(args)) } +function refreshPreparationTip( + entry: PreparationEntry, + beforeMaterialization: Promise +): Promise { + return queuePreparedWorktreeTipRefresh( + entry, + () => { + const available = preparations.get(entry.key) === entry + if (!available && ![...claims].some((claim) => claim.entry === entry)) { + return + } + if (available) { + preparations.delete(entry.key) + clearTimeout(entry.expiration) + } + discardEntryInBackground(entry) + }, + beforeMaterialization + ) +} + function startBackgroundPreparation({ repoPath, workspaceRoot, baseBranch, canonicalBase, - options + options, + beforeMaterialization }: StartPreparationArgs): Promise { const repoPathKey = preparationPathKey(repoPath) const workspaceRootKey = preparationPathKey(workspaceRoot) @@ -252,19 +248,16 @@ function startBackgroundPreparation({ enforcePreparationLimit(repoPathKey, workspaceRootKey, wslDistro) const preparationId = `${process.pid}-${randomUUID()}` const lockReason = createWorktreePreparationLockReason(preparationId) - const preparationRoot = pathOps(workspaceRoot).join( - workspaceRoot, - WORKTREE_CREATE_PREPARATION_DIRECTORY - ) - const preparedPath = pathOps(workspaceRoot).join(preparationRoot, preparationId) + const paths = isWindowsAbsolutePathLike(workspaceRoot) ? win32 : posix + const preparationRoot = paths.join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY) + const preparedPath = paths.join(preparationRoot, preparationId) const controller = new AbortController() const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal - const entry = {} as PreparationEntry const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) expiration.unref() - Object.assign(entry, { + const entry: PreparationEntry = { key, repoPath, repoPathKey, @@ -274,28 +267,41 @@ function startBackgroundPreparation({ baseBranch, canonicalBase, preparedPath, + lockReason, options, createdAt: Date.now(), expiration, controller, checkoutStarted: false, - ready: (async () => { - await startStalePreparationCleanup( - preparationHostKey(repoPathKey, wslDistro), + ready: Promise.resolve() + } + entry.ready = (async () => { + await startStalePreparationCleanup( + preparationHostKey(repoPathKey, wslDistro), + repoPath, + options + ) + signal.throwIfAborted() + await mkdir(toHostFilesystemPath(preparationRoot), { recursive: true }) + signal.throwIfAborted() + // Already canonical, so the add re-resolves nothing. + entry.checkoutStarted = true + try { + await prepareWorktreeCreateCheckout( repoPath, - options + preparedPath, + canonicalBase, + lockReason, + { ...options, signal }, + beforeMaterialization ) - signal.throwIfAborted() - await mkdir(toHostFilesystemPath(preparationRoot), { recursive: true }) - signal.throwIfAborted() - // Already canonical, so the add re-resolves nothing. - entry.checkoutStarted = true - await prepareWorktreeCreateCheckout(repoPath, preparedPath, canonicalBase, lockReason, { - ...options, - signal - }) - })() - } satisfies PreparationEntry) + } catch (error) { + if (error instanceof WorktreePreparationLockOwnershipError) { + entry.checkoutStarted = false + } + throw error + } + })() preparations.set(key, entry) void entry.ready.catch(() => { if (preparations.get(key) === entry) { @@ -314,7 +320,7 @@ export async function _resetPreparationPoolForTests(): Promise { await Promise.all( entries.map(async (entry) => { clearTimeout(entry.expiration) - await discardEntry(entry) + await discardPreparationEntry(entry) }) ) await resetPendingPreparationDiscardsForTests() diff --git a/src/main/worktree-create-preparation-stale-cleanup.ts b/src/main/worktree-create-preparation-stale-cleanup.ts index d0f146eda98..3c8c39750c3 100644 --- a/src/main/worktree-create-preparation-stale-cleanup.ts +++ b/src/main/worktree-create-preparation-stale-cleanup.ts @@ -51,15 +51,25 @@ export async function startStalePreparationCleanup( nextIndex += 1 const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) - if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { + if (!worktree.lockReason || !lockOwnerPid || isProcessAlive(lockOwnerPid)) { continue } // Preserve a branch-attached final path after a crash; only detached or // still-hidden preparations are safe to discard automatically. if (worktree.branch && pathOwnerPid === null) { - await unlockPreparedWorktree(repoPath, worktree.path, reclaimOptions).catch(() => {}) + await unlockPreparedWorktree( + repoPath, + worktree.path, + reclaimOptions, + worktree.lockReason + ).catch(() => {}) } else if (pathOwnerPid === lockOwnerPid) { - await discardPreparedWorktree(repoPath, worktree.path, reclaimOptions).catch(() => {}) + await discardPreparedWorktree( + repoPath, + worktree.path, + reclaimOptions, + worktree.lockReason + ).catch(() => {}) } } } diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index e8c2bb97bce..052181da228 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -1,53 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as WorktreeLogic from './ipc/worktree-logic' -import type { Store } from './persistence' -import { hasPendingStalePreparationCleanup } from './worktree-create-preparation-stale-cleanup' +import { mocks, repo, store, flushBackgroundWork } from './__mocks__/worktree-create-preparation' +import { describe, expect, it } from 'vitest' import type { Repo } from '../shared/repo-types' +import { hasPendingStalePreparationCleanup } from './worktree-create-preparation-stale-cleanup' import { WORKTREE_CREATE_PREPARATION_DIRECTORY } from '../shared/worktree/create-preparation' -import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' - -const mocks = vi.hoisted(() => ({ - mkdir: vi.fn(), - listWorktreeGraph: vi.fn(), - prepareCheckout: vi.fn(), - finalize: vi.fn(), - discard: vi.fn(), - unlock: vi.fn(), - getWorktreeOptions: vi.fn(), - computeWorkspaceRoot: vi.fn(), - computeWorkspaceRootAsync: vi.fn(), - resolveBaseRef: vi.fn(), - measureDivergence: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) -vi.mock('./git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) -vi.mock('./git/worktree-create-preparation', () => ({ - prepareWorktreeCreateCheckout: mocks.prepareCheckout, - finalizePreparedWorktree: mocks.finalize, - discardPreparedWorktree: mocks.discard, - unlockPreparedWorktree: mocks.unlock -})) -vi.mock('./git/worktree-base-ref-probe', () => ({ - resolveLocalWorktreeBaseRef: mocks.resolveBaseRef -})) -vi.mock('./git/worktree-base-divergence', () => ({ - measureRetargetDivergence: mocks.measureDivergence -})) -vi.mock('./project-runtime-git-options', () => ({ - getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, - getWorktreeMirrorDistro: () => undefined -})) -vi.mock('./ipc/worktree-logic', async (importOriginal) => ({ - isOrphanedWorktreeError: (await importOriginal()).isOrphanedWorktreeError, - computeWorkspaceRoot: mocks.computeWorkspaceRoot, - computeWorkspaceRootAsync: mocks.computeWorkspaceRootAsync, - getWorktreePathSettings: () => ({ - workspaceDir: process.platform === 'win32' ? 'C:\\workspace' : '/workspace', - nestWorkspaces: false - }) -})) - +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { _resetWorktreeCreatePreparationsForTests, consumePreparedWorktreeCreate, @@ -55,49 +11,6 @@ import { prepareWorktreeCreateForRepo } from './worktree-create-preparation' -// Evictions and retries are fire-and-forget, so let them settle before asserting. -function flushBackgroundWork(ms = 0): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -const EXISTING_REFS = new Set([ - 'refs/heads/main', - 'refs/remotes/origin/main', - 'refs/remotes/origin/release' -]) -const repo = { id: 'repo-1', path: '/repo' } as Repo -const store = { getSettings: () => ({}) } as unknown as Store - -beforeEach(() => { - mocks.mkdir.mockReset().mockResolvedValue(undefined) - mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) - mocks.prepareCheckout.mockReset().mockResolvedValue(undefined) - mocks.finalize.mockReset().mockResolvedValue({}) - mocks.discard.mockReset().mockResolvedValue(undefined) - mocks.unlock.mockReset().mockResolvedValue(undefined) - mocks.getWorktreeOptions.mockReset().mockReturnValue({}) - mocks.measureDivergence.mockReset().mockResolvedValue('within') - mocks.resolveBaseRef - .mockReset() - .mockImplementation((_repoPath: string, baseRef: string) => - resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate)) - ) - mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => { - throw new Error('synchronous workspace-root lookup must not run on the main thread') - }) - mocks.computeWorkspaceRootAsync - .mockReset() - .mockImplementation(async (repoPath: string) => - process.platform === 'win32' && /^[A-Za-z]:[\\/]/.test(repoPath) - ? 'C:\\workspace' - : '/workspace' - ) -}) - -afterEach(async () => { - await _resetWorktreeCreatePreparationsForTests() -}) - describe('worktree create preparation registry', () => { it.each([undefined, 'Ubuntu'])( 'preserves create priority through claim probes on %s', @@ -229,7 +142,8 @@ describe('worktree create preparation registry', () => { 'feature/test', 'main', undefined, - {} + {}, + mocks.prepareCheckout.mock.calls[0]?.[3] ) }) @@ -407,7 +321,8 @@ describe('worktree create preparation registry', () => { expect.any(String), 'refs/remotes/origin/main', expect.any(String), - { ...options, signal: expect.any(AbortSignal) } + { ...options, signal: expect.any(AbortSignal) }, + undefined ) expect(mocks.finalize).toHaveBeenCalledWith( repo.path, @@ -416,7 +331,8 @@ describe('worktree create preparation registry', () => { 'feature/test', 'origin/main', undefined, - options + options, + mocks.prepareCheckout.mock.calls[0]?.[3] ) }) @@ -454,9 +370,14 @@ describe('worktree create preparation registry', () => { }) try { await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, stalePath, { - admissionTier: 'background' - }) + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + stalePath, + { + admissionTier: 'background' + }, + 'orca-create-preparation:v1:999999999:stale' + ) expect(ready).toBe(true) await prepareWorktreeCreateForRepo(store, repo, 'origin/release') expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2) @@ -498,9 +419,12 @@ describe('worktree create preparation registry', () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - expect(mocks.unlock).toHaveBeenCalledWith(repo.path, '/workspace/final', { - admissionTier: 'background' - }) + expect(mocks.unlock).toHaveBeenCalledWith( + repo.path, + '/workspace/final', + { admissionTier: 'background' }, + 'orca-create-preparation:v1:999999999:stale' + ) expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, '/workspace/final', expect.anything()) }) @@ -560,6 +484,21 @@ describe('worktree create preparation registry', () => { expect(mocks.discard).toHaveBeenCalledTimes(1) }) + it('preserves another owner’s checkout when finalization loses its lock', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.finalize.mockRejectedValueOnce(new WorktreePreparationLockOwnershipError()) + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toMatchObject({ status: 'miss', reason: 'finalize_failed' }) + expect(mocks.discard).not.toHaveBeenCalled() + }) + /** Mirrors a real create: consume, then run the deferred re-arm once the create has returned. */ async function consumeOnce(name: string): Promise { const attempt = await consumePreparedWorktreeCreate({ @@ -676,218 +615,6 @@ describe('worktree create preparation registry', () => { expect(mocks.prepareCheckout).not.toHaveBeenCalled() }) - it('retries a discard that failed while this process is still alive', async () => { - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string - mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) - - // Fill the registry so the first preparation is evicted while its owner pid is still alive. - for (const base of ['origin/one', 'origin/two', 'origin/three']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/five') - await flushBackgroundWork() - expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) - }) - - it('retries only the leaked paths belonging to the host being prepared', async () => { - const otherRepo = { ...repo, id: 'repo-2', path: '/other-repo' } as Repo - const unremovable = new Set() - mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { - if (unremovable.has(path)) { - throw new Error('EBUSY') - } - }) - - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedHere = mocks.prepareCheckout.mock.calls[0][1] as string - await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main') - const leakedElsewhere = mocks.prepareCheckout.mock.calls[1][1] as string - unremovable.add(leakedHere) - unremovable.add(leakedElsewhere) - - // Evict through each host's own arming: eviction prefers the incoming workspace's oldest - // entry, so preparing for `repo` no longer reaches across and takes `otherRepo`'s. - for (const base of ['origin/one', 'origin/two']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - for (const base of ['origin/one', 'origin/two']) { - await prepareWorktreeCreateForRepo(store, otherRepo, base) - } - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}) - expect(mocks.discard).toHaveBeenCalledWith(otherRepo.path, leakedElsewhere, {}) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}) - expect(mocks.discard.mock.calls.some((call) => call[1] === leakedElsewhere)).toBe(false) - }) - - it('stops retrying a preparation that never becomes removable', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string - mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { - if (path === leakedPath) { - throw new Error('EBUSY') - } - }) - const leakedDiscards = (): number => - mocks.discard.mock.calls.filter((call) => call[1] === leakedPath).length - - for (const base of ['origin/one', 'origin/two', 'origin/three']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - await flushBackgroundWork() - expect(leakedDiscards()).toBe(1) - - for (const base of ['origin/four', 'origin/five', 'origin/six']) { - await prepareWorktreeCreateForRepo(store, repo, base) - await flushBackgroundWork() - } - expect(leakedDiscards()).toBe(3) - expect(warn).toHaveBeenCalledWith( - expect.stringContaining(`could not be discarded in 3 attempts; ${leakedPath}`), - expect.any(Error) - ) - } finally { - warn.mockRestore() - } - }) - - it('retries a failed checkout whose own self-discard also left the path registered', async () => { - let failCheckout!: (error: Error) => void - mocks.prepareCheckout.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - failCheckout = reject - }) - ) - const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') - await flushBackgroundWork() - const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string - - // Evict it while its checkout is still in flight, so discardEntry runs on a failed preparation. - for (const base of ['origin/one', 'origin/two', 'origin/three']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) - failCheckout(new Error('worktree add failed')) - await failing.catch(() => {}) - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) - }) - - it('scopes retries to the WSL distro whose preparation leaked', async () => { - const unremovable = new Set() - mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { - if (unremovable.has(path)) { - throw new Error('EBUSY') - } - }) - - mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedOnUbuntu = mocks.prepareCheckout.mock.calls[0][1] as string - mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedOnDebian = mocks.prepareCheckout.mock.calls[1][1] as string - unremovable.add(leakedOnUbuntu) - unremovable.add(leakedOnDebian) - - // Evict through each distro's own arming: the eviction scope includes the distro, so arming - // under Ubuntu no longer reaches across and takes the Debian entry. - mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) - for (const base of ['origin/one', 'origin/two']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) - await prepareWorktreeCreateForRepo(store, repo, 'origin/one') - mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' }) - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnDebian, { wslDistro: 'Debian' }) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' }) - expect(mocks.discard.mock.calls.some((call) => call[1] === leakedOnDebian)).toBe(false) - }) - - it('drops recorded discards when the registry is reset for tests', async () => { - await prepareWorktreeCreateForRepo(store, repo, 'origin/main') - const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string - // Reject on a real timer so the fire-and-forget discard is still in flight at reset. - mocks.discard.mockImplementationOnce(async () => { - await new Promise((resolve) => setTimeout(resolve, 5)) - throw new Error('EBUSY') - }) - - for (const base of ['origin/one', 'origin/two', 'origin/three']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - await _resetWorktreeCreatePreparationsForTests() - // Past the rejection timer: the reset must have absorbed the failure, not raced ahead of it. - await flushBackgroundWork(20) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) - }) - - it("settles an evicted preparation's discard before the reset drops the registry", async () => { - let failCheckout!: (error: Error) => void - mocks.prepareCheckout.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - failCheckout = reject - }) - ) - const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') - await flushBackgroundWork() - const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string - mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { - if (path === leakedPath) { - throw new Error('EBUSY') - } - }) - - for (const base of ['origin/one', 'origin/two', 'origin/three']) { - await prepareWorktreeCreateForRepo(store, repo, base) - } - // The eviction's discard is still parked on the checkout, so the reset has to wait for it. - const reset = _resetWorktreeCreatePreparationsForTests() - await flushBackgroundWork(5) - failCheckout(new Error('worktree add failed')) - await failing.catch(() => {}) - await reset - await flushBackgroundWork(5) - - mocks.discard.mockClear() - await prepareWorktreeCreateForRepo(store, repo, 'origin/four') - await flushBackgroundWork() - expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) - }) - it('reports a pending create while a stale-cleanup scan is running', async () => { let releaseListing!: () => void mocks.listWorktreeGraph.mockReturnValueOnce( diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index 3ac022910eb..0bca1160d8e 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -9,6 +9,7 @@ import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree' import { measureRetargetDivergence } from './git/worktree-base-divergence' import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' import { _resetPreparationPoolForTests, @@ -86,17 +87,19 @@ function canonicalBaseRef( export function prepareWorktreeCreateForRepo( store: Store, repo: Repo, - baseBranch: string + baseBranch: string, + beforeMaterialization?: Promise ): Promise { return worktreePreparationGit.run(() => - prepareWorktreeCreateInBackground(store, repo, baseBranch) + prepareWorktreeCreateInBackground(store, repo, baseBranch, beforeMaterialization) ) } async function prepareWorktreeCreateInBackground( store: Store, repo: Repo, - baseBranch: string + baseBranch: string, + beforeMaterialization?: Promise ): Promise { if (repo.connectionId || isFolderRepo(repo)) { return @@ -116,7 +119,8 @@ async function prepareWorktreeCreateInBackground( workspaceRoot, baseBranch, canonicalBase, - options + options, + beforeMaterialization }) } @@ -285,7 +289,8 @@ export async function consumePreparedWorktreeCreate( args.branch, args.baseBranch, args.refreshLocalBaseRef, - options + options, + entry.lockReason ) const result = args.timing ? await args.timing.time('prepared_checkout_finalize', finalize) @@ -295,7 +300,14 @@ export async function consumePreparedWorktreeCreate( const rearm = deferRearmPreparation(entry, reservation, args.baseBranch, claim.canonicalBase) return { status: 'hit', retargeted: claim.retargeted, result, rearm } } catch (error) { - await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {}) + if (!(error instanceof WorktreePreparationLockOwnershipError)) { + await discardPreparedWorktree( + args.repoPath, + entry.preparedPath, + options, + entry.lockReason + ).catch(() => {}) + } console.warn( '[worktree-create] prepared checkout could not be finalized; using normal add', error diff --git a/src/main/worktree-preparation-discard-retry.test.ts b/src/main/worktree-preparation-discard-retry.test.ts new file mode 100644 index 00000000000..5a4b2d8b52d --- /dev/null +++ b/src/main/worktree-preparation-discard-retry.test.ts @@ -0,0 +1,241 @@ +import { mocks, repo, store, flushBackgroundWork } from './__mocks__/worktree-create-preparation' +import { describe, expect, it, vi } from 'vitest' +import type { Repo } from '../shared/repo-types' +import { + _resetWorktreeCreatePreparationsForTests, + prepareWorktreeCreateForRepo +} from './worktree-create-preparation' + +describe('worktree preparation discard retries', () => { + it('retries a discard that failed while this process is still alive', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) + + // Fill the registry so the first preparation is evicted while its owner pid is still alive. + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/five') + await flushBackgroundWork() + expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + }) + + it('retries only the leaked paths belonging to the host being prepared', async () => { + const otherRepo = { ...repo, id: 'repo-2', path: '/other-repo' } as Repo + const unremovable = new Set() + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (unremovable.has(path)) { + throw new Error('EBUSY') + } + }) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedHere = mocks.prepareCheckout.mock.calls[0][1] as string + await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main') + const leakedElsewhere = mocks.prepareCheckout.mock.calls[1][1] as string + unremovable.add(leakedHere) + unremovable.add(leakedElsewhere) + + // Evict through each host's own arming: eviction prefers the incoming workspace's oldest + // entry, so preparing for `repo` no longer reaches across and takes `otherRepo`'s. + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, otherRepo, base) + } + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}, expect.any(String)) + expect(mocks.discard).toHaveBeenCalledWith( + otherRepo.path, + leakedElsewhere, + {}, + expect.any(String) + ) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}, expect.any(String)) + expect(mocks.discard.mock.calls.some((call) => call[1] === leakedElsewhere)).toBe(false) + }) + + it('stops retrying a preparation that never becomes removable', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (path === leakedPath) { + throw new Error('EBUSY') + } + }) + const leakedDiscards = (): number => + mocks.discard.mock.calls.filter((call) => call[1] === leakedPath).length + + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + await flushBackgroundWork() + expect(leakedDiscards()).toBe(1) + + for (const base of ['origin/four', 'origin/five', 'origin/six']) { + await prepareWorktreeCreateForRepo(store, repo, base) + await flushBackgroundWork() + } + expect(leakedDiscards()).toBe(3) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`could not be discarded in 3 attempts; ${leakedPath}`), + expect.any(Error) + ) + } finally { + warn.mockRestore() + } + }) + + it('retries a failed checkout whose own self-discard also left the path registered', async () => { + let failCheckout!: (error: Error) => void + mocks.prepareCheckout.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failCheckout = reject + }) + ) + const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await flushBackgroundWork() + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + + // Evict it while its checkout is still in flight, so discardEntry runs on a failed preparation. + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) + failCheckout(new Error('worktree add failed')) + await failing.catch(() => {}) + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + }) + + it('scopes retries to the WSL distro whose preparation leaked', async () => { + const unremovable = new Set() + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (unremovable.has(path)) { + throw new Error('EBUSY') + } + }) + + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedOnUbuntu = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedOnDebian = mocks.prepareCheckout.mock.calls[1][1] as string + unremovable.add(leakedOnUbuntu) + unremovable.add(leakedOnDebian) + + // Evict through each distro's own arming: the eviction scope includes the distro, so arming + // under Ubuntu no longer reaches across and takes the Debian entry. + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/one') + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + leakedOnUbuntu, + { wslDistro: 'Ubuntu' }, + expect.any(String) + ) + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + leakedOnDebian, + { wslDistro: 'Debian' }, + expect.any(String) + ) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + leakedOnUbuntu, + { wslDistro: 'Ubuntu' }, + expect.any(String) + ) + expect(mocks.discard.mock.calls.some((call) => call[1] === leakedOnDebian)).toBe(false) + }) + + it('drops recorded discards when the registry is reset for tests', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + // Reject on a real timer so the fire-and-forget discard is still in flight at reset. + mocks.discard.mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + throw new Error('EBUSY') + }) + + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + await _resetWorktreeCreatePreparationsForTests() + // Past the rejection timer: the reset must have absorbed the failure, not raced ahead of it. + await flushBackgroundWork(20) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + }) + + it("settles an evicted preparation's discard before the reset drops the registry", async () => { + let failCheckout!: (error: Error) => void + mocks.prepareCheckout.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failCheckout = reject + }) + ) + const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await flushBackgroundWork() + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (path === leakedPath) { + throw new Error('EBUSY') + } + }) + + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + // The eviction's discard is still parked on the checkout, so the reset has to wait for it. + const reset = _resetWorktreeCreatePreparationsForTests() + await flushBackgroundWork(5) + failCheckout(new Error('worktree add failed')) + await failing.catch(() => {}) + await reset + await flushBackgroundWork(5) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}, expect.any(String)) + }) +}) diff --git a/src/main/worktree-preparation-discard-retry.ts b/src/main/worktree-preparation-discard-retry.ts index 8602bebb39a..69ea2c4edd3 100644 --- a/src/main/worktree-preparation-discard-retry.ts +++ b/src/main/worktree-preparation-discard-retry.ts @@ -1,6 +1,8 @@ import type { AddWorktreeOptions } from './git/worktree' import { discardPreparedWorktree } from './git/worktree-create-preparation' +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { isOrphanedWorktreeError } from './ipc/worktree-logic' +import type { PreparationEntry } from './worktree-create-preparation-pool' // Stale cleanup only reclaims preparations whose owner pid is dead, so a discard that fails inside // the live process would strand its scratch checkout until the app restarts. Remember the failure @@ -11,12 +13,34 @@ export type PreparationDiscardTarget = { hostKey: string repoPath: string preparedPath: string + lockReason: string options: AddWorktreeOptions } const pendingDiscards = new Map() const inFlightDiscards = new Set>() +/** One repository on one Git host owns preparation cleanup and its retries. */ +export function preparationHostKey(repoPathKey: string, wslDistro: string): string { + return `${repoPathKey}\0${wslDistro}` +} + +export async function discardPreparationEntry(entry: PreparationEntry): Promise { + await entry.ready.catch(() => {}) + if (!entry.checkoutStarted) { + return + } + // Claim cleanup before yielding so queued refresh failures cannot discard the same checkout. + entry.checkoutStarted = false + await discardPreparationWithRetry({ + hostKey: preparationHostKey(entry.repoPathKey, entry.wslDistro), + repoPath: entry.repoPath, + preparedPath: entry.preparedPath, + lockReason: entry.lockReason, + options: entry.options + }) +} + /** Keeps a fire-and-forget discard settleable by the test reset, which would otherwise race it. */ export function trackPreparationDiscard(work: Promise): void { inFlightDiscards.add(work) @@ -30,11 +54,16 @@ function pendingKey(target: PreparationDiscardTarget): string { async function runDiscard(target: PreparationDiscardTarget, attempts: number): Promise { try { - await discardPreparedWorktree(target.repoPath, target.preparedPath, target.options) + await discardPreparedWorktree( + target.repoPath, + target.preparedPath, + target.options, + target.lockReason + ) } catch (error) { // An aborted or failed checkout self-discards first, so the registration is usually already // gone by the time the pool discards; retrying that would only spawn Git to fail again. - if (isOrphanedWorktreeError(error)) { + if (error instanceof WorktreePreparationLockOwnershipError || isOrphanedWorktreeError(error)) { return } // Bounded: a path that never becomes removable must not tax every later preparation. diff --git a/src/main/worktree-preparation-fetch-barrier.test.ts b/src/main/worktree-preparation-fetch-barrier.test.ts new file mode 100644 index 00000000000..9d2c8d397a7 --- /dev/null +++ b/src/main/worktree-preparation-fetch-barrier.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { waitForPromiseWithSignal } from '../shared/abort-signal-reason' +import { flushBackgroundWork, mocks, repo, store } from './__mocks__/worktree-create-preparation' +import { + consumePreparedWorktreeCreate, + prepareWorktreeCreateForRepo +} from './worktree-create-preparation' +import { + listPreparations, + WORKTREE_CREATE_PREPARATION_TTL_MS +} from './worktree-create-preparation-pool' + +function deferredFetch() { + let settle!: () => void + const beforeMaterialization = new Promise((resolve) => { + settle = resolve + }) + return { beforeMaterialization, settle } +} + +function consume() { + return consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/new', + branch: 'new', + baseBranch: 'origin/main' + }) +} + +describe('prepared checkout shared fetch barrier', () => { + it('passes initial materialization settlement separately from owning WSL Git options', async () => { + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + const fetch = deferredFetch() + await prepareWorktreeCreateForRepo(store, repo, 'origin/main', fetch.beforeMaterialization) + const entry = listPreparations()[0]! + expect(mocks.prepareCheckout).toHaveBeenCalledWith( + repo.path, + entry.preparedPath, + 'refs/remotes/origin/main', + entry.lockReason, + { wslDistro: 'Ubuntu', signal: expect.any(AbortSignal) }, + fetch.beforeMaterialization + ) + expect(mocks.refreshTip).not.toHaveBeenCalled() + fetch.settle() + }) + + it('lets a cold racing consume join the first materialization without a second checkout or refresh', async () => { + const fetch = deferredFetch() + let materializations = 0 + mocks.prepareCheckout.mockImplementationOnce( + async (_repo, _path, _base, _lock, options, barrier) => { + await waitForPromiseWithSignal(barrier, options.signal) + materializations++ + } + ) + const preparation = prepareWorktreeCreateForRepo( + store, + repo, + 'origin/main', + fetch.beforeMaterialization + ) + await vi.waitFor(() => expect(mocks.prepareCheckout).toHaveBeenCalledOnce()) + const create = consume() + await flushBackgroundWork() + expect(materializations).toBe(0) + expect(mocks.finalize).not.toHaveBeenCalled() + fetch.settle() + await preparation + expect(await create).toMatchObject({ status: 'hit' }) + expect(materializations).toBe(1) + expect(mocks.prepareCheckout).toHaveBeenCalledOnce() + expect(mocks.refreshTip).not.toHaveBeenCalled() + expect(mocks.finalize).toHaveBeenCalledOnce() + }) + + it('publishes an existing checkout refresh before a racing consume can finalize', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0]! + const oldReady = entry.ready + const fetch = deferredFetch() + const preparation = prepareWorktreeCreateForRepo( + store, + repo, + 'origin/main', + fetch.beforeMaterialization + ) + await vi.waitFor(() => expect(entry.ready).not.toBe(oldReady)) + const create = consume() + await flushBackgroundWork() + expect(mocks.refreshTip).not.toHaveBeenCalled() + expect(mocks.finalize).not.toHaveBeenCalled() + fetch.settle() + await preparation + expect(await create).toMatchObject({ status: 'hit' }) + expect(mocks.prepareCheckout).toHaveBeenCalledOnce() + expect(mocks.refreshTip).toHaveBeenCalledOnce() + expect(mocks.finalize).toHaveBeenCalledOnce() + }) + + it('expires a ready checkout while its shared fetch is still pending without waiting for the network', async () => { + vi.useFakeTimers() + const fetch = deferredFetch() + try { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0]! + const settled = Promise.allSettled([ + prepareWorktreeCreateForRepo(store, repo, 'origin/main', fetch.beforeMaterialization) + ]) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_PREPARATION_TTL_MS) + expect((await settled)[0]?.status).toBe('rejected') + expect(mocks.refreshTip).not.toHaveBeenCalled() + expect(mocks.discard).toHaveBeenCalledOnce() + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + entry.preparedPath, + {}, + entry.lockReason + ) + expect(listPreparations()).toEqual([]) + } finally { + fetch.settle() + vi.useRealTimers() + } + }) +}) diff --git a/src/main/worktree-preparation-refresh-queue.ts b/src/main/worktree-preparation-refresh-queue.ts new file mode 100644 index 00000000000..5e9b69c9725 --- /dev/null +++ b/src/main/worktree-preparation-refresh-queue.ts @@ -0,0 +1,42 @@ +import { worktreePreparationGit } from './git/worktree-create-git-executor' +import { refreshPreparedWorktreeTip } from './git/worktree-preparation-tip-refresh' +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' +import type { PreparationEntry } from './worktree-create-preparation-pool' +import { waitForPromiseWithSignal } from '../shared/abort-signal-reason' + +/** Publish the refresh before yielding so a racing create waits for the same checkout. */ +export function queuePreparedWorktreeTipRefresh( + entry: PreparationEntry, + retire: () => void, + beforeMaterialization?: Promise +): Promise { + const signal = entry.options.signal + ? AbortSignal.any([entry.options.signal, entry.controller.signal]) + : entry.controller.signal + const ready = entry.ready.then(async () => { + signal.throwIfAborted() + if (beforeMaterialization) { + await waitForPromiseWithSignal(beforeMaterialization, signal) + } + return worktreePreparationGit.run(() => + refreshPreparedWorktreeTip( + entry.repoPath, + entry.preparedPath, + entry.canonicalBase, + entry.lockReason, + { + ...entry.options, + signal + } + ) + ) + }) + entry.ready = ready + void ready.catch((error: unknown) => { + if (error instanceof WorktreePreparationLockOwnershipError) { + entry.checkoutStarted = false + } + retire() + }) + return ready +} diff --git a/src/main/worktree-preparation-tip-refresh.test.ts b/src/main/worktree-preparation-tip-refresh.test.ts new file mode 100644 index 00000000000..cc22314a8b8 --- /dev/null +++ b/src/main/worktree-preparation-tip-refresh.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from 'vitest' +import { flushBackgroundWork, mocks, repo, store } from './__mocks__/worktree-create-preparation' +import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' +import { + consumePreparedWorktreeCreate, + prepareWorktreeCreateForRepo +} from './worktree-create-preparation' +import { + listPreparations, + WORKTREE_CREATE_PREPARATION_TTL_MS +} from './worktree-create-preparation-pool' + +const beforeMaterialization = Promise.resolve() + +function consume() { + return consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/new', + branch: 'new', + baseBranch: 'origin/main' + }) +} + +describe('prepared checkout tip refresh', () => { + it('refreshes the existing checkout on its Git host without preparing another tree', async () => { + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0]! + await prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + expect(mocks.prepareCheckout).toHaveBeenCalledOnce() + expect(mocks.refreshTip).toHaveBeenCalledWith( + repo.path, + entry.preparedPath, + 'refs/remotes/origin/main', + entry.lockReason, + { wslDistro: 'Ubuntu', signal: expect.any(AbortSignal) } + ) + }) + + it('lets consume join an in-flight refresh before finalizing', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + let release!: () => void + mocks.refreshTip.mockImplementationOnce( + () => new Promise((resolve) => (release = resolve)) + ) + const refresh = prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + await vi.waitFor(() => expect(mocks.refreshTip).toHaveBeenCalledOnce()) + const create = consume() + await flushBackgroundWork() + expect(mocks.finalize).not.toHaveBeenCalled() + release() + await refresh + expect(await create).toMatchObject({ status: 'hit' }) + expect(mocks.finalize).toHaveBeenCalledOnce() + }) + + it('defers another preparation while the previous checkout is still claimed', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + expect(await consume()).toMatchObject({ status: 'hit' }) + mocks.computeWorkspaceRootAsync.mockClear() + mocks.resolveBaseRef.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + expect(mocks.computeWorkspaceRootAsync).toHaveBeenCalledOnce() + expect(mocks.resolveBaseRef).toHaveBeenCalledOnce() + expect(mocks.refreshTip).not.toHaveBeenCalled() + expect(mocks.prepareCheckout).toHaveBeenCalledOnce() + }) + + it('cancels an expired tip refresh before cleanup', async () => { + vi.useFakeTimers() + let signal: AbortSignal | undefined + try { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0]! + mocks.refreshTip.mockImplementationOnce((_repo, _path, _base, _lock, options) => { + signal = options.signal + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { + once: true + }) + }) + }) + const refresh = prepareWorktreeCreateForRepo( + store, + repo, + 'origin/main', + beforeMaterialization + ) + const settled = Promise.allSettled([refresh]) + await vi.advanceTimersByTimeAsync(0) + expect(signal?.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_PREPARATION_TTL_MS) + expect(signal?.aborted).toBe(true) + expect((await settled)[0]?.status).toBe('rejected') + expect(mocks.discard).toHaveBeenCalledOnce() + expect(mocks.discard).toHaveBeenCalledWith( + repo.path, + entry.preparedPath, + {}, + entry.lockReason + ) + expect(listPreparations()).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('cancels an evicted tip refresh and preserves its original cleanup scope', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const entry = listPreparations()[0]! + let signal: AbortSignal | undefined + mocks.refreshTip.mockImplementationOnce((_repo, _path, _base, _lock, options) => { + signal = options.signal + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { + once: true + }) + }) + }) + const settled = Promise.allSettled([ + prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + ]) + await vi.waitFor(() => expect(mocks.refreshTip).toHaveBeenCalledOnce()) + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + expect(signal?.aborted).toBe(true) + expect((await settled)[0]?.status).toBe('rejected') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledOnce() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, entry.preparedPath, {}, entry.lockReason) + }) + + it('preserves a checkout whose lock was taken over', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.refreshTip.mockRejectedValueOnce(new WorktreePreparationLockOwnershipError()) + await expect( + prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + ).rejects.toThrow('lock owner changed') + await flushBackgroundWork() + expect(listPreparations()).toEqual([]) + expect(mocks.discard).not.toHaveBeenCalled() + expect(await consume()).toMatchObject({ status: 'miss', reason: 'none_armed' }) + }) + + it('serializes successive fetched tips and lets consume wait for the newest queued refresh', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const order: string[] = [] + let release!: () => void + mocks.refreshTip + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => { + order.push('first-tip') + resolve() + } + }) + ) + .mockImplementationOnce(async () => { + order.push('second-tip') + }) + mocks.finalize.mockImplementationOnce(async () => { + order.push('finalize') + return {} + }) + const first = prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + await vi.waitFor(() => expect(mocks.refreshTip).toHaveBeenCalledOnce()) + const second = prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + await flushBackgroundWork() + const create = consume() + await flushBackgroundWork() + expect(mocks.refreshTip).toHaveBeenCalledOnce() + expect(mocks.finalize).not.toHaveBeenCalled() + release() + const results = await Promise.all([first, second, create]) + expect(results[2]).toMatchObject({ status: 'hit' }) + expect(order).toEqual(['first-tip', 'second-tip', 'finalize']) + expect(mocks.prepareCheckout).toHaveBeenCalledOnce() + }) + + it('discards a claimed checkout once when successive queued refreshes fail', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + let rejectRefresh = (_error: Error) => {} + mocks.refreshTip.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRefresh = reject + }) + ) + const first = prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + await vi.waitFor(() => expect(mocks.refreshTip).toHaveBeenCalledOnce()) + const second = prepareWorktreeCreateForRepo(store, repo, 'origin/main', beforeMaterialization) + await flushBackgroundWork() + const create = consume() + const settled = Promise.allSettled([first, second]) + await flushBackgroundWork() + const finishes: (() => void)[] = [] + mocks.discard.mockImplementation( + () => + new Promise((resolve) => { + finishes.push(resolve) + }) + ) + rejectRefresh(new Error('first tip reset failed')) + await settled + const attempt = await create + expect(attempt).toMatchObject({ status: 'miss', reason: 'prepare_failed' }) + await vi.waitFor(() => expect(mocks.discard).toHaveBeenCalled()) + await flushBackgroundWork() + try { + expect(mocks.discard).toHaveBeenCalledOnce() + expect(mocks.finalize).not.toHaveBeenCalled() + } finally { + for (const finish of finishes) { + finish() + } + if (attempt.status === 'miss') { + attempt.rearm?.() + } + await flushBackgroundWork() + } + }) +}) diff --git a/src/relay/agent-exec-disposal.test.ts b/src/relay/agent-exec-disposal.test.ts deleted file mode 100644 index 6e770ff5b9e..00000000000 --- a/src/relay/agent-exec-disposal.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { execFile } from 'node:child_process' -import type * as ChildProcess from 'node:child_process' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { AgentExecHandler } from './agent-exec-handler' -import { RELAY_AGENT_CLOSE_DEADLINE_MS } from './relay-agent-process-lifetime' -import { createFakeChild, requestContext } from './agent-exec-handler-test-harness' -import type { MethodHandler, RelayDispatcher } from './dispatcher' - -// Why an untyped mock: the fake child stubs only what AgentExecHandler reads from a ChildProcess. -const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawn: (...args: unknown[]) => spawnMock(...args), - execFile: vi.fn() -})) - -function fixture() { - const methods = new Map() - const handler = new AgentExecHandler( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler only registers request methods. - { - onRequest: (name: string, method: MethodHandler) => methods.set(name, method) - } as unknown as RelayDispatcher - ) - const exec = (params: Record = {}) => - methods.get('agent.execNonInteractive')!( - { binary: 'agent', timeoutMs: 1000, ...params }, - requestContext() - ) - return { handler, exec } -} - -beforeEach(() => { - vi.useFakeTimers() - spawnMock.mockReset() - vi.mocked(execFile).mockReset() -}) -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() -}) - -it('waits for every child close, including commands without a cwd and replaced lanes', async () => { - const f = fixture() - const children = [createFakeChild(), createFakeChild(), createFakeChild()] - for (const child of children) { - spawnMock.mockReturnValueOnce(child) - } - const requests = [f.exec(), f.exec({ cwd: '/repo' }), f.exec({ cwd: '/repo' })] - let finished = false - const disposal = f.handler.dispose().then(() => { - finished = true - }) - await Promise.resolve() - expect(finished).toBe(false) - if (process.platform === 'win32') { - expect(execFile).toHaveBeenCalledWith( - 'taskkill', - ['/pid', '12345', '/T', '/F'], - expect.any(Function) - ) - } else { - for (const child of children) { - expect(child.kill).toHaveBeenCalledWith('SIGKILL') - } - } - children[0].emit('close', null) - children[2].emit('close', null) - await Promise.resolve() - expect(finished).toBe(false) - children[1].emit('close', null) - await disposal - expect(finished).toBe(true) - await Promise.all(requests) -}) - -it('refuses execution once disposal begins and after it completes', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec() - const disposal = f.handler.dispose() - await expect(f.exec()).rejects.toThrow() - expect(spawnMock).toHaveBeenCalledTimes(1) - child.emit('close', null) - await Promise.all([request, disposal]) - await expect(f.exec()).rejects.toThrow() - expect(spawnMock).toHaveBeenCalledTimes(1) -}) - -it('retains timed-out children until close rather than treating RPC settlement as exit', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec({ cwd: '/repo' }) - await vi.advanceTimersByTimeAsync(1000) - await expect(request).resolves.toMatchObject({ timedOut: true }) - let finished = false - const disposal = f.handler.dispose().then(() => { - finished = true - }) - await Promise.resolve() - expect(finished).toBe(false) - child.emit('error', new Error('late child error')) - await Promise.resolve() - expect(finished).toBe(false) - child.emit('close', null) - await disposal - expect(finished).toBe(true) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) -}) - -it('resolves disposal with no children or only already-closed children', async () => { - await fixture().handler.dispose() - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec() - child.emit('close', 0) - await request - await f.handler.dispose() - expect(child.kill).not.toHaveBeenCalled() -}) - -it('rejects within the close deadline and re-checks without re-killing on retry', async () => { - const f = fixture() - const child = createFakeChild() - spawnMock.mockReturnValue(child) - const request = f.exec({ timeoutMs: 60_000 }) - const signals = () => - process.platform === 'win32' - ? vi.mocked(execFile).mock.calls.length - : child.kill.mock.calls.length - const first = f.handler.dispose() - const firstOutcome = first.then( - () => 'resolved', - (error: Error) => error.message - ) - expect(signals()).toBe(1) - await vi.advanceTimersByTimeAsync(RELAY_AGENT_CLOSE_DEADLINE_MS) - await expect(firstOutcome).resolves.toBe('relay_agent_execution_shutdown_incomplete') - const retry = f.handler.dispose() - expect(signals()).toBe(1) - child.emit('close', null) - await expect(retry).resolves.toBeUndefined() - await request -}) diff --git a/src/relay/agent-exec-handler.test.ts b/src/relay/agent-exec-handler.test.ts index 1cb2820b1ec..87a2ef0403b 100644 --- a/src/relay/agent-exec-handler.test.ts +++ b/src/relay/agent-exec-handler.test.ts @@ -523,10 +523,6 @@ describe('AgentExecHandler', () => { } expect(child.stdout.listenerCount('data')).toBe(0) expect(child.stderr.listenerCount('data')).toBe(0) - // Shutdown keeps tracking the child until it physically closes. - expect(child.listenerCount('error')).toBe(1) - expect(child.listenerCount('close')).toBe(1) - child.emit('close', null) expect(child.listenerCount('error')).toBe(0) expect(child.listenerCount('close')).toBe(0) } finally { diff --git a/src/relay/agent-exec-handler.ts b/src/relay/agent-exec-handler.ts index 4314dbbc6a7..152e8bd72fb 100644 --- a/src/relay/agent-exec-handler.ts +++ b/src/relay/agent-exec-handler.ts @@ -6,7 +6,6 @@ import type { RelayDispatcher, RequestContext } from './dispatcher' import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard' import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env' import { terminateRelaySubprocessTree } from './subprocess-tree-termination' -import { RelayAgentProcessLifetime } from './relay-agent-process-lifetime' const DEFAULT_TIMEOUT_MS = 60_000 const MAX_TIMEOUT_MS = 5 * 60 * 1000 @@ -110,7 +109,6 @@ type ExecResult = { * and a clean exit code instead of an interactive session. */ export class AgentExecHandler { - private readonly processLifetime = new RelayAgentProcessLifetime() // Why: commit-message and PR-field generation can run together for one cwd; // operation lanes let cancel target only the user-visible job that stopped. private inFlightByLane = new Map() @@ -126,10 +124,6 @@ export class AgentExecHandler { dispatcher.onRequest('agent.cancelExec', (p) => this.cancel(p as CancelParams)) } - dispose(): Promise { - return this.processLifetime.dispose() - } - private async cancel(params: CancelParams): Promise<{ canceled: boolean }> { const cwd = typeof params.cwd === 'string' ? params.cwd : '' const entry = this.inFlightByLane.get(this.laneKey(cwd, params.operation)) @@ -141,7 +135,6 @@ export class AgentExecHandler { } private async exec(params: ExecParams, context?: RequestContext): Promise { - this.processLifetime.assertAdmission() const binary = typeof params.binary === 'string' ? params.binary : '' if (!binary) { throw new Error('agent.execNonInteractive: binary is required') @@ -195,7 +188,6 @@ export class AgentExecHandler { return } - this.processLifetime.track(child) let stdout = '' let stderr = '' let stdoutBytes = 0 diff --git a/src/relay/dispatcher-client-state.ts b/src/relay/dispatcher-client-state.ts index 80d1990085e..710a704e4d8 100644 --- a/src/relay/dispatcher-client-state.ts +++ b/src/relay/dispatcher-client-state.ts @@ -1,6 +1,5 @@ import type { DecodedFrame, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse } from './protocol' import { ClientRequestAborts } from './client-request-aborts' -import { RelayWorkAdmission } from './relay-work-admission' import type { PtyConsumerCloseCause } from '../shared/pty-consumer-session-contract' import { LegacyRelayPublicationLedger, @@ -20,12 +19,10 @@ import type { PtyDataPublicationAdmission, RelayClient, RelayClientSessionIdentity, - RelayClientSourceOptions, - RequestContext + RelayClientSourceOptions } from './dispatcher-contract' export abstract class RelayDispatcherClientState { - protected readonly workAdmission = new RelayWorkAdmission() protected readonly primaryClient: RelayClient protected readonly clients = new Map() protected requestHandlers = new Map() @@ -60,17 +57,7 @@ export abstract class RelayDispatcherClientState { } onRequest(method: string, handler: MethodHandler): void { - this.requestHandlers.set(method, (params, context) => - this.workAdmission.run(method, context, () => handler(params, context)) - ) - } - - beginWorkDrain(exclude?: RequestContext): Promise { - return this.workAdmission.beginDrain(exclude) - } - - assertActiveWorkContext(context: RequestContext): void { - this.workAdmission.assertActiveContext(context) + this.requestHandlers.set(method, handler) } // Why it throws: this is a single slot, so a second registration silently shadows the @@ -81,9 +68,7 @@ export abstract class RelayDispatcherClientState { if (this.notificationHandlers.has(method)) { throw new Error(`Notification handler for ${method} is already registered`) } - this.notificationHandlers.set(method, (params, context) => - this.workAdmission.runNotification(method, context, () => handler(params, context)) - ) + this.notificationHandlers.set(method, handler) } onClientDetached(listener: (clientId: number, cause: PtyConsumerCloseCause) => void): () => void { diff --git a/src/relay/dispatcher-client-writer.ts b/src/relay/dispatcher-client-writer.ts index 42feaa68218..514ee47b8da 100644 --- a/src/relay/dispatcher-client-writer.ts +++ b/src/relay/dispatcher-client-writer.ts @@ -56,10 +56,6 @@ export class DispatcherClientWriter { return this.admission.retainedProducerBytes } - get supportsWriteCallback(): boolean { - return this.sink.supportsWriteCallback - } - get producerFrameCapacity(): number { return this.sink.producerFrameCapacity } diff --git a/src/relay/dispatcher-contract.ts b/src/relay/dispatcher-contract.ts index 679d4657e62..23c62431d2c 100644 --- a/src/relay/dispatcher-contract.ts +++ b/src/relay/dispatcher-contract.ts @@ -9,8 +9,6 @@ import type { DispatcherClientWriter, SinkWriteSettlement } from './dispatcher-c export type RequestContext = { clientId: number - /** Monotonic transport generation; optional for in-process callers. */ - transportGeneration?: number isStale: () => boolean signal?: AbortSignal sessionIdentity?: RelayClientSessionIdentity diff --git a/src/relay/dispatcher-frame-codec.ts b/src/relay/dispatcher-frame-codec.ts index ffa67d2b12c..5a25fa1100f 100644 --- a/src/relay/dispatcher-frame-codec.ts +++ b/src/relay/dispatcher-frame-codec.ts @@ -85,8 +85,7 @@ export abstract class RelayDispatcherFrameCodec extends RelayDispatcherCapacityS frame: PreparedRelayFrame, lane: DispatcherWriterLane, onSettled: (result: SinkWriteSettlement) => void = () => {}, - controlOverflow: 'close-client' | 'reject' = 'close-client', - publicationAdmission?: () => boolean + controlOverflow: 'close-client' | 'reject' = 'close-client' ): boolean { if (this.disposed || client.closed) { return false @@ -96,12 +95,9 @@ export abstract class RelayDispatcherFrameCodec extends RelayDispatcherCapacityS return encodePreparedJsonRpcFrame(frame.payload, seq, client.highestReceivedSeq) } const admissionParams = frame.ptyDataAdmissionParams - const isStillAdmitted = - admissionParams || publicationAdmission - ? () => - (publicationAdmission?.() ?? true) && - (!admissionParams || this.admitsPtyDataPublication(client.id, admissionParams)) - : undefined + const isStillAdmitted = admissionParams + ? () => this.admitsPtyDataPublication(client.id, admissionParams) + : undefined return client.writer.enqueue( lane, encode, diff --git a/src/relay/dispatcher-notification-publication.ts b/src/relay/dispatcher-notification-publication.ts index ca09a503814..ab0851b4203 100644 --- a/src/relay/dispatcher-notification-publication.ts +++ b/src/relay/dispatcher-notification-publication.ts @@ -1,5 +1,4 @@ import type { SinkWriteSettlement } from './dispatcher-client-writer' -import { onceDispatcherWriterSettlement } from './dispatcher-writer-admission' import type { JsonRpcNotification } from './protocol' import { DROPPED_NOTIFICATION_LOG_KEY_LIMIT, @@ -9,16 +8,6 @@ import { import { RelayDispatcherPtyPublication } from './dispatcher-pty-publication' export abstract class RelayDispatcherNotificationPublication extends RelayDispatcherPtyPublication { - assertSettledProducerTransport(clientId: number, generation: number): void { - const client = this.clients.get(clientId) - if (this.disposed || !client || client.closed || client.generation !== generation) { - throw new Error('relay_producer_transport_unverifiable') - } - if (!client.writer.supportsWriteCallback) { - throw new Error('relay_producer_write_callback_required') - } - } - notify(method: string, params?: Record): void { if (this.disposed) { return @@ -61,24 +50,14 @@ export abstract class RelayDispatcherNotificationPublication extends RelayDispat params?: Record, // Why: a caller that recovers from rejection itself (the watcher emitter re-sends the batch in // chunks) would otherwise log "Dropped" for a frame it goes on to deliver in full. - options?: { - logDrop?: boolean - onSettled?: (result: SinkWriteSettlement) => void - settledTransportGeneration?: number - isStillAdmitted?: () => boolean - } + options?: { logDrop?: boolean } ): boolean { - const settle = onceDispatcherWriterSettlement(options?.onSettled ?? (() => {})) - const refuse = (message: string): false => { - settle({ ok: false, error: new Error(message) }) - return false - } if (this.disposed) { - return refuse('Relay dispatcher is disposed') + return false } const client = this.clients.get(clientId) if (!client || client.closed) { - return refuse('Relay client is not connected') + return false } const msg: JsonRpcNotification = { jsonrpc: '2.0', @@ -86,29 +65,17 @@ export abstract class RelayDispatcherNotificationPublication extends RelayDispat ...(params !== undefined ? { params } : {}) } if (method === 'pty.data' && !this.admitsPtyDataPublication(client.id, params ?? {})) { - return refuse('Relay PTY publication is not admitted') + return false } - let frame: PreparedRelayFrame - try { - if (options?.settledTransportGeneration !== undefined) { - this.assertSettledProducerTransport(clientId, options.settledTransportGeneration) - } - frame = this.prepareFrame(msg) - if (options?.settledTransportGeneration !== undefined) { - this.assertSettledProducerTransport(clientId, options.settledTransportGeneration) - } - } catch (error) { - settle({ ok: false, error: error instanceof Error ? error : new Error(String(error)) }) - throw error - } - if (this.publishPreparedToClient(client, frame, 'ordinary', settle, options?.isStillAdmitted)) { + const frame = this.prepareFrame(msg) + if (this.publishPreparedToClient(client, frame, 'ordinary')) { return true } // Why: same diagnostics as notify() — a producer that drops here must not do so silently. if (options?.logDrop !== false) { this.logDroppedProducerNotification(client, method, frame.frameBytes) } - return refuse('Relay producer publication was not accepted') + return false } notifyClient(clientId: number, method: string, params?: Record): void { diff --git a/src/relay/dispatcher-producer-settlement.test.ts b/src/relay/dispatcher-producer-settlement.test.ts deleted file mode 100644 index 2c04f2cb6f7..00000000000 --- a/src/relay/dispatcher-producer-settlement.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' - -describe('producer notification settlement', () => { - const dispatchers: RelayDispatcher[] = [] - const setup = (highWaterMark?: number) => { - const writes: ((result: SinkWriteSettlement) => void)[] = [] - const dispatcher = new RelayDispatcher( - (_bytes, settled) => { - writes.push(settled) - return true - }, - { supportsWriteCallback: true, writableHighWaterMark: () => highWaterMark ?? Infinity } - ) - dispatchers.push(dispatcher) - return { dispatcher, writes } - } - afterEach(() => { - for (const dispatcher of dispatchers.splice(0)) { - dispatcher.dispose() - } - }) - - it('waits for sink settlement rather than enqueue acceptance and settles once', () => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - expect(dispatcher.publishProducerNotification(1, 'tunnel.frame', {}, { onSettled })).toBe(true) - expect(onSettled).not.toHaveBeenCalled() - writes[0]({ ok: true }) - writes[0]({ ok: false, error: new Error('late duplicate') }) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: true }) - }) - - it('retains sink failure and cannot turn disposal into successful publication', () => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - dispatcher.publishProducerNotification(1, 'tunnel.frame', {}, { onSettled }) - const error = new Error('write failed') - writes[0]({ ok: false, error }) - dispatcher.dispose() - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error }) - }) - - it('fails outstanding publication on disposal even if a late write succeeds', () => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - dispatcher.publishProducerNotification(1, 'tunnel.frame', {}, { onSettled }) - dispatcher.dispose() - writes[0]({ ok: true }) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - }) - - it('reports bounded producer refusal without closing the client or taking the control lane', () => { - const { dispatcher, writes } = setup(16384) - const onSettled = vi.fn() - const detached = vi.fn() - dispatcher.onClientDetached(detached) - expect( - dispatcher.publishProducerNotification( - 1, - 'tunnel.frame', - { data: 'x'.repeat(20000) }, - { - onSettled, - logDrop: false - } - ) - ).toBe(false) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - expect(writes).toHaveLength(0) - expect(detached).not.toHaveBeenCalled() - expect(dispatcher.publishProducerNotification(1, 'tunnel.frame', {})).toBe(true) - }) - - it.each(['missing', 'disposed', 'pty-admission'] as const)('settles %s refusal', (reason) => { - const { dispatcher, writes } = setup() - const onSettled = vi.fn() - if (reason === 'disposed') { - dispatcher.dispose() - } - if (reason === 'pty-admission') { - dispatcher.registerPtyDataPublicationAdmission(() => false) - } - expect( - dispatcher.publishProducerNotification( - reason === 'missing' ? 999 : 1, - reason === 'pty-admission' ? 'pty.data' : 'tunnel.frame', - {}, - { onSettled } - ) - ).toBe(false) - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - expect(writes).toHaveLength(0) - }) - - it('settles serialization failure while preserving the existing throw contract', () => { - const { dispatcher } = setup() - const onSettled = vi.fn() - const params: Record = {} - params.circular = params - expect(() => - dispatcher.publishProducerNotification(1, 'tunnel.frame', params, { onSettled }) - ).toThrow() - expect(onSettled).toHaveBeenCalledExactlyOnceWith({ ok: false, error: expect.any(Error) }) - }) -}) diff --git a/src/relay/dispatcher-producer-transport.ts b/src/relay/dispatcher-producer-transport.ts index 5ef9717108f..089a69577d1 100644 --- a/src/relay/dispatcher-producer-transport.ts +++ b/src/relay/dispatcher-producer-transport.ts @@ -83,8 +83,7 @@ export abstract class RelayDispatcherProducerTransport extends RelayDispatcherRp client: RelayClient, frame: PreparedRelayFrame, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', - onSettled: (result: SinkWriteSettlement) => void = () => {}, - isStillAdmitted?: () => boolean + onSettled: (result: SinkWriteSettlement) => void = () => {} ): boolean { const bytes = frame.frameBytes const fixedBlocked = @@ -97,7 +96,7 @@ export abstract class RelayDispatcherProducerTransport extends RelayDispatcherRp if (!leases) { return false } - return this.enqueueLeasedFrame(client, frame, lane, leases[0], onSettled, isStillAdmitted) + return this.enqueueLeasedFrame(client, frame, lane, leases[0], onSettled) } protected publishBulkWhenAvailable( @@ -149,21 +148,13 @@ export abstract class RelayDispatcherProducerTransport extends RelayDispatcherRp frame: PreparedRelayFrame, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', lease: LegacyPublicationLease, - onSettled: (result: SinkWriteSettlement) => void = () => {}, - isStillAdmitted?: () => boolean + onSettled: (result: SinkWriteSettlement) => void = () => {} ): boolean { - const accepted = this.enqueuePreparedFrame( - client, - frame, - lane, - (result) => { - lease.release() - onSettled(result) - this.notifyLegacyCapacityIfLow() - }, - undefined, - isStillAdmitted - ) + const accepted = this.enqueuePreparedFrame(client, frame, lane, (result) => { + lease.release() + onSettled(result) + this.notifyLegacyCapacityIfLow() + }) if (!accepted) { lease.release() this.notifyLegacyCapacityIfLow() diff --git a/src/relay/dispatcher-rpc-routing.ts b/src/relay/dispatcher-rpc-routing.ts index 37689654a0d..a6e6c24190c 100644 --- a/src/relay/dispatcher-rpc-routing.ts +++ b/src/relay/dispatcher-rpc-routing.ts @@ -104,7 +104,6 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode } const context: RequestContext = { clientId: client.id, - transportGeneration: gen, isStale: () => client.generation !== gen || !this.clients.has(client.id) || abortController.signal.aborted, signal: abortController.signal, @@ -180,7 +179,6 @@ export abstract class RelayDispatcherRpcRouting extends RelayDispatcherFrameCode const gen = client.generation handler(notif.params ?? {}, { clientId: client.id, - transportGeneration: gen, isStale: () => client.generation !== gen || !this.clients.has(client.id), sessionIdentity: client.sessionIdentity, onResponseSettled: () => { diff --git a/src/relay/dispatcher-work-drain.test.ts b/src/relay/dispatcher-work-drain.test.ts deleted file mode 100644 index 8724dbabec1..00000000000 --- a/src/relay/dispatcher-work-drain.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RelayDispatcher } from './dispatcher' -import type { RequestContext } from './dispatcher' -import { - encodeJsonRpcFrame, - type JsonRpcRequest, - type JsonRpcResponse, - type JsonRpcNotification -} from './protocol' - -const dispatchers: RelayDispatcher[] = [] - -afterEach(() => { - for (const dispatcher of dispatchers.splice(0)) { - dispatcher.dispose() - } -}) -function fixture() { - const responses: Record[] = [] - const dispatcher = new RelayDispatcher((frame) => { - const length = frame.readUInt32BE(9) - responses.push(JSON.parse(frame.subarray(13, 13 + length).toString())) - return true - }) - dispatchers.push(dispatcher) - let sequence = 0 - const send = ( - message: - | Omit - | Omit - | Omit - ) => dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', ...message }, ++sequence, 0)) - return { dispatcher, responses, send } -} - -it('rejects new mutations over the wire while waiting for uncancellable admitted work', async () => { - const f = fixture() - const pending = Promise.withResolvers() - const mutation = vi.fn(() => pending.promise) - f.dispatcher.onRequest('fs.writeFile', mutation) - f.send({ id: 1, method: 'fs.writeFile' }) - const drained = vi.fn() - const drain = f.dispatcher.beginWorkDrain().then(drained) - f.send({ id: 2, method: 'fs.writeFile' }) - await vi.waitFor(() => - expect(f.responses).toContainEqual( - expect.objectContaining({ - id: 2, - error: expect.objectContaining({ message: 'relay_work_admission_closed' }) - }) - ) - ) - expect(mutation).toHaveBeenCalledOnce() - expect(drained).not.toHaveBeenCalled() - pending.resolve() - await drain -}) - -it('excludes only the initiating request from its own drain', async () => { - const f = fixture() - const work = Promise.withResolvers() - f.dispatcher.onRequest('fs.writeFile', () => work.promise) - const drained = vi.fn() - f.dispatcher.onRequest('test.drain', async (_params, context) => { - await f.dispatcher.beginWorkDrain(context) - drained() - return 'drained' - }) - f.send({ id: 1, method: 'fs.writeFile' }) - f.send({ id: 2, method: 'test.drain' }) - await new Promise((resolve) => setImmediate(resolve)) - expect(drained).not.toHaveBeenCalled() - work.resolve() - await vi.waitFor(() => - expect(f.responses).toContainEqual({ jsonrpc: '2.0', id: 2, result: 'drained' }) - ) -}) - -it('rejects copied and settled initiators without closing admission', async () => { - const f = fixture() - let actual: RequestContext | undefined - f.dispatcher.onRequest('test.capture', async (_params, context) => { - actual = context - await expect(f.dispatcher.beginWorkDrain({ ...context })).rejects.toThrow( - 'relay_work_drain_context_not_active' - ) - return 'captured' - }) - f.send({ id: 1, method: 'test.capture' }) - await vi.waitFor(() => expect(f.responses.some((response) => response.id === 1)).toBe(true)) - expect(() => f.dispatcher.assertActiveWorkContext(actual!)).toThrow( - 'relay_work_drain_context_not_active' - ) - const mutate = vi.fn(async () => 'still open') - f.dispatcher.onRequest('fs.writeFile', mutate) - f.send({ id: 2, method: 'fs.writeFile' }) - await vi.waitFor(() => expect(mutate).toHaveBeenCalledOnce()) -}) - -it('stamps the transport generation on request and notification contexts', async () => { - const f = fixture() - const seen: (number | undefined)[] = [] - f.dispatcher.onRequest('test.request', async (_params, context) => { - seen.push(context.transportGeneration) - return 'ok' - }) - f.dispatcher.onNotification('test.notify', (_params, context) => { - seen.push(context.transportGeneration) - }) - f.send({ id: 1, method: 'test.request' }) - f.send({ method: 'test.notify' }) - await vi.waitFor(() => expect(seen).toHaveLength(2)) - expect(seen[0]).toEqual(expect.any(Number)) - expect(seen[1]).toBe(seen[0]) -}) - -it('preserves ACK notifications and cancellation during drain without admitting new notifications', async () => { - const f = fixture() - const pending = Promise.withResolvers() - let signal: AbortSignal | undefined - f.dispatcher.onRequest('git.diff', (_params, context) => { - signal = context.signal - return pending.promise - }) - const write = vi.fn() - const ack = vi.fn(() => pending.resolve()) - f.dispatcher.onNotification('pty.write', write) - f.dispatcher.onNotification('git.responseAck', ack) - f.send({ id: 1, method: 'git.diff' }) - const drain = f.dispatcher.beginWorkDrain() - f.send({ method: 'pty.write' }) - f.send({ method: 'rpc.cancel', params: { id: 1 } }) - expect(signal?.aborted).toBe(true) - f.send({ method: 'git.responseAck' }) - await drain - expect(write).not.toHaveBeenCalled() - expect(ack).toHaveBeenCalledOnce() -}) - -it('lets an admitted operation receive its reverse RPC response while draining', async () => { - const f = fixture() - f.dispatcher.onRequest('orca.cli', () => f.dispatcher.requestPrimary('client.operation')) - f.send({ id: 10, method: 'orca.cli' }) - const drain = f.dispatcher.beginWorkDrain() - const request = f.responses.find((frame) => frame.method === 'client.operation')! - expect(request).toBeDefined() - f.send({ id: Number(request.id), result: 'done' }) - await drain - await vi.waitFor(() => - expect(f.responses).toContainEqual({ jsonrpc: '2.0', id: 10, result: 'done' }) - ) -}) - -it('keeps skill upload cancellation executable while admitted installation work drains', async () => { - const f = fixture() - const work = Promise.withResolvers() - f.dispatcher.onRequest('skills.install', () => work.promise) - const cancel = vi.fn(async () => work.resolve()) - f.dispatcher.onRequest('skills.cancelUpload', cancel) - f.send({ id: 1, method: 'skills.install' }) - const drain = f.dispatcher.beginWorkDrain() - f.send({ id: 2, method: 'skills.cancelUpload' }) - await drain - expect(cancel).toHaveBeenCalledOnce() -}) - -it('keeps PTY source delivery cancellation executable while admitted work drains', async () => { - const f = fixture() - const work = Promise.withResolvers() - f.dispatcher.onRequest('pty.attach', () => work.promise) - const cancel = vi.fn(async () => ({ canceled: true, sentEndSu: 0, creditedEndSu: 0 })) - f.dispatcher.onRequest('pty.cancelDelivery', cancel) - f.send({ id: 1, method: 'pty.attach' }) - const drain = f.dispatcher.beginWorkDrain() - f.send({ id: 2, method: 'pty.cancelDelivery' }) - await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()) - work.resolve() - await drain - await vi.waitFor(() => - expect(f.responses).toContainEqual( - expect.objectContaining({ id: 2, result: expect.objectContaining({ canceled: true }) }) - ) - ) -}) diff --git a/src/relay/fs-file-stream-shutdown.test.ts b/src/relay/fs-file-stream-shutdown.test.ts deleted file mode 100644 index c26fb73b2c4..00000000000 --- a/src/relay/fs-file-stream-shutdown.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import { readRelayFileStreamMetadata } from './fs-handler-file-read' -import { RelayStreamRegistry } from './fs-stream-registry' -import { STREAM_CHUNK_SIZE } from './protocol' - -let directory: string -let filePath: string -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-file-stream-shutdown-')) - filePath = join(directory, 'sample.png') - await writeFile(filePath, Buffer.alloc(12, 42)) -}) -afterEach(async () => { - vi.restoreAllMocks() - await rm(directory, { recursive: true, force: true }) -}) - -function fixture() { - const registry = new RelayStreamRegistry() - const bulk = vi.fn(async () => {}) - const terminal = vi.fn((_id, _method, _params, settled) => { - settled({ ok: true }) - return true - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the file stream reads only these two dispatcher methods. - const dispatcher = { notifyBulk: bulk, tryNotifyClient: terminal } as unknown as RelayDispatcher - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream reads only clientId and isStale from its context. - const context = { clientId: 1, isStale: () => false } as RequestContext - const start = (paceWithAcks = false) => - readRelayFileStreamMetadata(filePath, dispatcher, registry, context, { - clientId: 1, - paceWithAcks - }) - return { registry, bulk, terminal, start } -} - -it('waits for a scheduled pump and permanently fences later metadata requests', async () => { - const f = fixture() - const scheduled: (() => void)[] = [] - // A real, already-cleared handle satisfies the return type without scheduling anything. - const handle = setImmediate(() => {}) - clearImmediate(handle) - const schedule = vi.spyOn(globalThis, 'setImmediate').mockImplementation((callback) => { - scheduled.push(callback) - return handle - }) - await f.start() - schedule.mockRestore() - const finished = vi.fn() - const drain = f.registry.disposeAll().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - await expect(f.start()).rejects.toThrow('relay_file_stream_shutdown_fenced') - scheduled.forEach((run) => run()) - await drain - expect(f.bulk).not.toHaveBeenCalled() - expect(f.terminal).not.toHaveBeenCalled() -}) - -it.each(['resolve', 'reject'] as const)( - 'waits for an in-flight final chunk to %s without a terminal frame', - async (outcome) => { - const f = fixture() - const pending = Promise.withResolvers() - f.bulk.mockReturnValue(pending.promise) - await f.start() - await vi.waitFor(() => expect(f.bulk).toHaveBeenCalledOnce()) - const finished = vi.fn() - const drain = f.registry.disposeAll().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - if (outcome === 'resolve') { - pending.resolve() - } else { - pending.reject(new Error('transport closed')) - } - await drain - expect(f.terminal).not.toHaveBeenCalled() - } -) - -it('wakes an ACK-parked producer during shutdown', async () => { - const f = fixture() - await writeFile(filePath, Buffer.alloc(STREAM_CHUNK_SIZE * 6, 42)) - await f.start(true) - await vi.waitFor(() => expect(f.bulk).toHaveBeenCalledTimes(4)) - await f.registry.disposeAll() - expect(f.bulk).toHaveBeenCalledTimes(4) - expect(f.terminal).not.toHaveBeenCalled() -}) - -it('retains a binary-probe handle when its close fails and retries shutdown cleanup', async () => { - const f = fixture() - filePath = join(directory, 'sample.txt') - await writeFile(filePath, 'text content') - const failure = new Error('probe close failed') - const release = f.registry.releaseUnregisteredHandle.bind(f.registry) - vi.spyOn(f.registry, 'releaseUnregisteredHandle').mockImplementationOnce((handle) => { - vi.spyOn(handle, 'close').mockRejectedValueOnce(failure) - return release(handle) - }) - try { - await expect(f.start()).rejects.toBe(failure) - expect(f.registry.size()).toBe(1) - await f.registry.disposeAll() - expect(f.registry.size()).toBe(0) - } finally { - await f.registry.disposeAll() - } -}) diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index c07c897fc8b..e8b7993fbcd 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -2,9 +2,8 @@ import { open, readFile, stat } from 'node:fs/promises' import type { FileHandle } from 'node:fs/promises' import { extname } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' -import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from './protocol' -import type { RelayStreamRegistry } from './fs-stream-registry' -import { reserveTerminalFrameSlot } from './fs-stream-terminal-frame-slots' +import { MAX_CONCURRENT_STREAMS, STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from './protocol' +import { TooManyStreamsError, type RelayStreamRegistry } from './fs-stream-registry' import { BINARY_PROBE_BYTES, IMAGE_MIME_TYPES, @@ -78,21 +77,6 @@ export async function readRelayFileStreamMetadata( registry: RelayStreamRegistry, context: RequestContext, pumpOptions?: StreamPumpOptions -): Promise { - const finish = registry.beginOperation() - try { - return await prepareRelayFileStream(filePath, dispatcher, registry, context, pumpOptions) - } finally { - finish() - } -} - -async function prepareRelayFileStream( - filePath: string, - dispatcher: RelayDispatcher, - registry: RelayStreamRegistry, - context: RequestContext, - pumpOptions?: StreamPumpOptions ): Promise { const stats = await stat(filePath) const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] @@ -115,10 +99,7 @@ async function prepareRelayFileStream( // Why: unlike the legacy single-shot path, streaming does not read the full // buffer before classifying content. Probe every unknown file so small binary // files do not get decoded as UTF-8 text over SSH. - if ( - !mimeType && - (await isBinaryFilePrefix(filePath, (handle) => registry.releaseUnregisteredHandle(handle))) - ) { + if (!mimeType && (await isBinaryFilePrefix(filePath))) { return { totalSize: 0, isBinary: true, empty: true } } @@ -131,13 +112,8 @@ async function prepareRelayFileStream( handle = await open(filePath, 'r') streamId = registry.register(handle) } catch (err) { - try { - if (handle) { - await registry.releaseUnregisteredHandle(handle) - } - } finally { - releaseTerminalFrameSlot() - } + await handle?.close() + releaseTerminalFrameSlot() throw err } @@ -147,7 +123,6 @@ async function prepareRelayFileStream( // setImmediate kicks the pump off the metadata-response task so the client // sees the response before the first chunk frame. const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } - const finishPump = registry.beginOperation() setImmediate(() => { void pumpChunks( streamId, @@ -158,10 +133,6 @@ async function prepareRelayFileStream( resolvedPumpOptions, releaseTerminalFrameSlot ) - .catch((error: unknown) => { - process.stderr.write(`[relay] stream cleanup failed id=${streamId}: ${String(error)}\n`) - }) - .finally(finishPump) }) return { @@ -175,6 +146,48 @@ async function prepareRelayFileStream( } } +/** + * Terminal frames (fs.streamEnd/fs.streamError) ride the control lane, which does not + * drop on overflow — it destroys the link at 256 queued frames / 1 MB. A stream's + * registry slot is gone the moment its last chunk is read, so on a socket that is not + * draining, back-to-back reads could stack one undelivered terminal frame each until + * that budget blew. Holding the slot until the frame settles keeps the number of queued + * terminal frames at MAX_CONCURRENT_STREAMS, far below the killing threshold; the + * overflow now costs one refused read (TooManyStreams, which clients already handle) + * instead of the whole connection. + * + * Counted per client, because the control queue this protects is per client: one peer whose + * socket stopped draining must not refuse reads for every other peer on the same relay. + */ +const pendingTerminalFramesByClient = new WeakMap>() + +function reserveTerminalFrameSlot(registry: RelayStreamRegistry, clientId: number): () => void { + let byClient = pendingTerminalFramesByClient.get(registry) + if (!byClient) { + byClient = new Map() + pendingTerminalFramesByClient.set(registry, byClient) + } + const pending = byClient.get(clientId) ?? 0 + if (pending >= MAX_CONCURRENT_STREAMS) { + throw new TooManyStreamsError() + } + byClient.set(clientId, pending + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (byClient.get(clientId) ?? 1) - 1 + // Drop the entry at zero so a long-lived registry cannot accumulate one per detached client. + if (remaining <= 0) { + byClient.delete(clientId) + return + } + byClient.set(clientId, remaining) + } +} + async function pumpChunks( streamId: number, totalSize: number, @@ -289,11 +302,6 @@ async function pumpChunks( releaseTerminalFrameSlot ) } - if (registry.isAborted(streamId)) { - endReason = 'aborted' - } else if (context.isStale()) { - endReason = 'stale' - } if (endReason === 'end') { publishTerminal('fs.streamEnd', { streamId }) process.stderr.write(`[relay] stream end id=${streamId}\n`) @@ -317,12 +325,9 @@ async function pumpChunks( } finally { // Why: the fd goes back first — a terminal frame that can never be delivered must not // strand it. Cancelled/stale streams publish nothing, so nothing else frees their slot. - try { - await registry.release(streamId) - } finally { - if (!slotReleaseDeferred) { - releaseTerminalFrameSlot() - } + await registry.release(streamId) + if (!slotReleaseDeferred) { + releaseTerminalFrameSlot() } } } diff --git a/src/relay/fs-handler-stream.test.ts b/src/relay/fs-handler-stream.test.ts index 9cbfd687376..ba6cfbf1877 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -492,29 +492,6 @@ describe('FsHandler readFileStream', () => { await waitFor(() => terminalFrames().length === MAX_CONCURRENT_STREAMS + 2) }) - it('holds file stream shutdown until an undelivered terminal frame settles', async () => { - const filePath = path.join(tmpDir, 'shutdown-terminal.png') - writeFileSync(filePath, Buffer.alloc(STREAM_CHUNK_SIZE, 0x42)) - dispatcher.holdControlSettlements() - const context = { clientId: 4, isStale: () => false } - await dispatcher.callRequest('fs.readFileStream', { filePath }, context) - await waitFor(() => collectStream(dispatcher).end !== null) - - let drained = false - const drain = handler.disposeFileStreams().then(() => { - drained = true - }) - await flush(10) - expect(drained).toBe(false) - await expect( - dispatcher.callRequest('fs.readFileStream', { filePath }, context) - ).rejects.toThrow('relay_file_stream_shutdown_fenced') - - dispatcher.settleHeldControlFrames() - await drain - expect(drained).toBe(true) - }) - it('rejects the 17th concurrent stream with TooManyStreams', async () => { const paths: string[] = [] for (let i = 0; i < 17; i++) { diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 0696b48046d..a3b2234ced0 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -64,18 +64,14 @@ export function isBinaryBuffer(buffer: Buffer): boolean { return false } -export async function isBinaryFilePrefix( - filePath: string, - releaseHandle: (handle: Awaited>) => Promise = (handle) => - handle.close() -): Promise { +export async function isBinaryFilePrefix(filePath: string): Promise { const handle = await open(filePath, 'r') try { const probe = Buffer.alloc(BINARY_PROBE_BYTES) const { bytesRead } = await handle.read(probe, 0, probe.length, 0) return isBinaryBuffer(probe.subarray(0, bytesRead)) } finally { - await releaseHandle(handle) + await handle.close() } } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 87ef3444d83..36765721ef3 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -267,16 +267,6 @@ export class FsHandler { dispose(): void { this.watchRegistry.dispose() - void this.disposeFileStreams().catch((error: unknown) => { - process.stderr.write(`[relay] file stream shutdown failed: ${String(error)}\n`) - }) - } - - disposeFileStreams(): Promise { - return this.streamRegistry.disposeAll() - } - - disposeWatchers(): Promise { - return this.watchRegistry.disposeAndWait() + void this.streamRegistry.disposeAll() } } diff --git a/src/relay/fs-stream-registry-shutdown.test.ts b/src/relay/fs-stream-registry-shutdown.test.ts deleted file mode 100644 index c79dfb860d4..00000000000 --- a/src/relay/fs-stream-registry-shutdown.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { FileHandle } from 'node:fs/promises' -import { describe, expect, it, vi } from 'vitest' -import { RelayStreamRegistry } from './fs-stream-registry' - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -async function flushTasks(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -function handle(close: ReturnType): FileHandle { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry only ever calls close() on a handle. - return { close } as unknown as FileHandle -} - -describe('RelayStreamRegistry shutdown', () => { - it('retains failed late pre-registration cleanup after the initial disposal snapshot', async () => { - const registry = new RelayStreamRegistry() - const finishMetadata = registry.beginOperation() - const failure = new Error('late handle close failed') - const close = vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(undefined) - const result = registry.disposeAll().catch((error: unknown) => error) - await flushTasks() - const openedHandle = handle(close) - expect(() => registry.register(openedHandle)).toThrow('relay_file_stream_shutdown_fenced') - await expect(registry.releaseUnregisteredHandle(openedHandle)).rejects.toBe(failure) - finishMetadata() - expect(await result).toMatchObject({ - message: 'relay_file_stream_shutdown_incomplete', - errors: [failure] - }) - expect(registry.size()).toBe(1) - await registry.disposeAll() - expect(registry.size()).toBe(0) - expect(close).toHaveBeenCalledTimes(2) - }) - - it('waits for a late pre-registration close owned by an admitted metadata operation', async () => { - const registry = new RelayStreamRegistry() - const finishMetadata = registry.beginOperation() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const finished = vi.fn() - const drain = registry.disposeAll().then(finished) - await flushTasks() - const cleanup = registry.releaseUnregisteredHandle(handle(close)).finally(finishMetadata) - await flushTasks() - expect(finished).not.toHaveBeenCalled() - pending.resolve() - await Promise.all([cleanup, drain]) - expect(registry.size()).toBe(0) - }) - - it('joins a release already waiting for the file handle to close', async () => { - const registry = new RelayStreamRegistry() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const id = registry.register(handle(close)) - const release = registry.release(id) - let disposed = false - const dispose = registry.disposeAll().then(() => { - disposed = true - }) - - try { - await flushTasks() - expect(disposed).toBe(false) - expect(close).toHaveBeenCalledTimes(1) - expect(registry.isAborted(id)).toBe(true) - } finally { - pending.resolve() - await Promise.all([release, dispose]) - } - expect(disposed).toBe(true) - expect(registry.size()).toBe(0) - }) - - it('coalesces concurrent release calls until the single close settles', async () => { - const registry = new RelayStreamRegistry() - const pending = deferred() - const close = vi.fn(() => pending.promise) - const id = registry.register(handle(close)) - const first = registry.release(id) - let secondSettled = false - const second = registry.release(id).then(() => { - secondSettled = true - }) - - try { - await flushTasks() - expect(close).toHaveBeenCalledTimes(1) - expect(secondSettled).toBe(false) - } finally { - pending.resolve() - await Promise.all([first, second]) - } - expect(registry.size()).toBe(0) - }) - - it('retains a failed close for a later disposal retry', async () => { - const registry = new RelayStreamRegistry() - const failure = Object.assign(new Error('close failed'), { code: 'EIO' }) - const close = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined) - const id = registry.register(handle(close)) - - await expect(registry.release(id)).rejects.toBe(failure) - expect(registry.size()).toBe(1) - expect(registry.isAborted(id)).toBe(true) - await registry.disposeAll() - - expect(close).toHaveBeenCalledTimes(2) - expect(registry.size()).toBe(0) - }) - - it('waits for every close before rejecting disposal and retries only failed handles', async () => { - const registry = new RelayStreamRegistry() - const failure = Object.assign(new Error('close failed'), { code: 'EIO' }) - const failedClose = vi.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined) - const pending = deferred() - const pendingClose = vi.fn(() => pending.promise) - registry.register(handle(failedClose)) - registry.register(handle(pendingClose)) - let settled = false - const result = registry.disposeAll().then( - () => { - settled = true - return undefined - }, - (error: unknown) => { - settled = true - return error - } - ) - - try { - await flushTasks() - expect(settled).toBe(false) - expect(failedClose).toHaveBeenCalledTimes(1) - expect(pendingClose).toHaveBeenCalledTimes(1) - } finally { - pending.resolve() - } - const error = await result - expect(error).toBeInstanceOf(AggregateError) - expect(error).toMatchObject({ errors: [failure] }) - expect(registry.size()).toBe(1) - - await registry.disposeAll() - expect(failedClose).toHaveBeenCalledTimes(2) - expect(pendingClose).toHaveBeenCalledTimes(1) - expect(registry.size()).toBe(0) - }) - - it('tolerates EBADF as an already-closed handle', async () => { - const registry = new RelayStreamRegistry() - const close = vi.fn().mockRejectedValue(Object.assign(new Error('closed'), { code: 'EBADF' })) - const id = registry.register(handle(close)) - - await registry.release(id) - await registry.disposeAll() - - expect(close).toHaveBeenCalledTimes(1) - expect(registry.size()).toBe(0) - }) -}) diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts index 23a8aab662a..2bca76c9495 100644 --- a/src/relay/fs-stream-registry.ts +++ b/src/relay/fs-stream-registry.ts @@ -21,44 +21,11 @@ export class TooManyStreamsError extends Error { export class RelayStreamRegistry { private streams = new Map() private nextId = 1 - private disposed = false - private closing = new Map>() - private closeFailures = new Map() - private operations = new Set>() - - /** Tracks work that may still open or release a handle; disposeAll waits for it. */ - beginOperation(): () => void { - if (this.disposed) { - throw new Error('relay_file_stream_shutdown_fenced') - } - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let resolve!: () => void - const pending = new Promise((settle) => { - resolve = settle - }) - this.operations.add(pending) - return () => { - this.operations.delete(pending) - resolve() - } - } register(handle: FileHandle): number { - if (this.disposed) { - throw new Error('relay_file_stream_shutdown_fenced') - } if (this.streams.size >= MAX_CONCURRENT_STREAMS) { throw new TooManyStreamsError() } - return this.retainHandle(handle) - } - - /** Closes a handle opened outside a stream; a failed close stays retained for disposal retry. */ - releaseUnregisteredHandle(handle: FileHandle): Promise { - return this.release(this.retainHandle(handle)) - } - - private retainHandle(handle: FileHandle): number { const streamId = this.nextId++ this.streams.set(streamId, { handle, @@ -138,43 +105,26 @@ export class RelayStreamRegistry { } } - /** Concurrent callers share one close; only EBADF counts as already closed. */ - release(streamId: number): Promise { - const pending = this.closing.get(streamId) - if (pending) { - return pending - } + async release(streamId: number): Promise { const entry = this.streams.get(streamId) if (!entry) { - return Promise.resolve() + return + } + this.wakeAckWaiters(entry) + this.streams.delete(streamId) + try { + await entry.handle.close() + } catch { + // release runs from multiple exit paths (pump, cancel, dispose); a + // second close throws EBADF — swallow it. } - this.abort(streamId) - const close = Promise.resolve() - .then(() => entry.handle.close()) - .catch((error: unknown) => { - if (!isErrorWithCode(error, 'EBADF')) { - this.closeFailures.set(streamId, error) - throw error - } - }) - .then(() => { - this.streams.delete(streamId) - this.closeFailures.delete(streamId) - }) - .finally(() => { - this.closing.delete(streamId) - }) - this.closing.set(streamId, close) - return close } size(): number { return this.streams.size } - /** Permanently fences new streams; rejects while any handle is still unclosed so a retry can finish. */ async disposeAll(): Promise { - this.disposed = true // Why: flag every stream as aborted so any in-flight pump exits its loop // cleanly on the next iteration boundary instead of seeing EBADF when // release closes the handle out from under an in-flight read. @@ -182,20 +132,6 @@ export class RelayStreamRegistry { this.abort(id) } const ids = Array.from(this.streams.keys()) - const results = await Promise.allSettled(ids.map((id) => this.release(id))) - await Promise.all(this.operations) - const failures = results.filter((result) => result.status === 'rejected') - if (failures.length > 0 || this.streams.size > 0) { - throw new AggregateError( - [ - ...new Set([...failures.map((failure) => failure.reason), ...this.closeFailures.values()]) - ], - 'relay_file_stream_shutdown_incomplete' - ) - } + await Promise.all(ids.map((id) => this.release(id))) } } - -function isErrorWithCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code -} diff --git a/src/relay/fs-stream-terminal-frame-slots.ts b/src/relay/fs-stream-terminal-frame-slots.ts deleted file mode 100644 index 5ede13b5d79..00000000000 --- a/src/relay/fs-stream-terminal-frame-slots.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { MAX_CONCURRENT_STREAMS } from './protocol' -import { TooManyStreamsError, type RelayStreamRegistry } from './fs-stream-registry' - -/** - * Terminal frames (fs.streamEnd/fs.streamError) ride the control lane, which does not - * drop on overflow — it destroys the link at 256 queued frames / 1 MB. A stream's - * registry slot is gone the moment its last chunk is read, so on a socket that is not - * draining, back-to-back reads could stack one undelivered terminal frame each until - * that budget blew. Holding the slot until the frame settles keeps the number of queued - * terminal frames at MAX_CONCURRENT_STREAMS, far below the killing threshold; the - * overflow now costs one refused read (TooManyStreams, which clients already handle) - * instead of the whole connection. - * - * Counted per client, because the control queue this protects is per client: one peer whose - * socket stopped draining must not refuse reads for every other peer on the same relay. - * Counted independently of released file descriptors, and each slot is a registry operation - * so shutdown waits for undelivered terminal frames too. - */ -const pendingTerminalFramesByClient = new WeakMap>() - -export function reserveTerminalFrameSlot( - registry: RelayStreamRegistry, - clientId: number -): () => void { - let byClient = pendingTerminalFramesByClient.get(registry) - if (!byClient) { - byClient = new Map() - pendingTerminalFramesByClient.set(registry, byClient) - } - const pending = byClient.get(clientId) ?? 0 - if (pending >= MAX_CONCURRENT_STREAMS) { - throw new TooManyStreamsError() - } - const finish = registry.beginOperation() - byClient.set(clientId, pending + 1) - let released = false - return () => { - if (released) { - return - } - released = true - finish() - const remaining = (byClient.get(clientId) ?? 1) - 1 - // Drop the entry at zero so a long-lived registry cannot accumulate one per detached client. - if (remaining <= 0) { - byClient.delete(clientId) - return - } - byClient.set(clientId, remaining) - } -} diff --git a/src/relay/git-response-stream-shutdown.test.ts b/src/relay/git-response-stream-shutdown.test.ts deleted file mode 100644 index 4ae10c539c9..00000000000 --- a/src/relay/git-response-stream-shutdown.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import { GitResponseStreamRegistry } from './git-response-stream' -import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from './protocol' - -const context: RequestContext = { clientId: 7, isStale: () => false } - -async function flushPump(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -describe('GitResponseStreamRegistry shutdown', () => { - const registries: GitResponseStreamRegistry[] = [] - - function fixture(): { - registry: GitResponseStreamRegistry - dispatcher: RelayDispatcher - notifyBulk: ReturnType - } { - const registry = new GitResponseStreamRegistry() - registries.push(registry) - const notifyBulk = vi.fn().mockResolvedValue(undefined) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the response pump only calls notifyBulk (producerDataBudget is optional). - return { registry, notifyBulk, dispatcher: { notifyBulk } as unknown as RelayDispatcher } - } - - afterEach(async () => { - await Promise.all(registries.splice(0).map((registry) => registry.disposeAllAndWait())) - }) - - it('drains a scheduled producer without publishing any frames', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - registry.startStream(Buffer.from('payload'), dispatcher, context) - - await registry.disposeAllAndWait() - await flushPump() - - expect(notifyBulk).not.toHaveBeenCalled() - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - await registry.disposeAllAndWait() - }) - - it('waits for an in-flight final chunk and never publishes responseEnd after abort', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const write = deferred() - notifyBulk.mockImplementationOnce(() => write.promise) - registry.startStream(Buffer.from('one chunk'), dispatcher, context) - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(1) - - let drained = false - const firstDrain = registry.disposeAllAndWait().then(() => { - drained = true - }) - const secondDrain = registry.disposeAllAndWait() - try { - await flushPump() - expect(drained).toBe(false) - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - } finally { - write.resolve() - await Promise.all([firstDrain, secondDrain]) - } - - expect(drained).toBe(true) - expect(notifyBulk).toHaveBeenCalledTimes(1) - expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') - }) - - it('wakes producers parked on client ACKs without waiting for the stall timer', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - registry.startStream( - Buffer.alloc(GIT_RESPONSE_CHUNK_SIZE * (STREAM_ACK_WINDOW_CHUNKS + 1)), - dispatcher, - context - ) - await flushPump() - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - await flushPump() - - expect(drained).toBe(true) - await drain - expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) - expect(notifyBulk.mock.calls.every(([method]) => method === 'git.responseChunk')).toBe(true) - }) - - it('waits for an already-published responseEnd to settle', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const endWrite = deferred() - notifyBulk.mockResolvedValueOnce(undefined).mockImplementationOnce(() => endWrite.promise) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - expect(notifyBulk.mock.calls.map(([method]) => method)).toEqual([ - 'git.responseChunk', - 'git.responseEnd' - ]) - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - try { - await flushPump() - expect(drained).toBe(false) - } finally { - endWrite.resolve() - await drain - } - expect(drained).toBe(true) - expect(notifyBulk).toHaveBeenCalledTimes(2) - }) - - it('suppresses error publication when an aborted in-flight write rejects', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - let rejectWrite!: (error: Error) => void - notifyBulk.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectWrite = reject - }) - ) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - - const drain = registry.disposeAllAndWait() - rejectWrite(new Error('channel closed during shutdown')) - await drain - - expect(notifyBulk).toHaveBeenCalledTimes(1) - expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') - }) - - it('synchronous disposal also permanently fences admission and retains the pending drain', async () => { - const { registry, dispatcher, notifyBulk } = fixture() - const write = deferred() - notifyBulk.mockImplementationOnce(() => write.promise) - registry.startStream(Buffer.from('payload'), dispatcher, context) - await flushPump() - registry.disposeAll() - - let drained = false - const drain = registry.disposeAllAndWait().then(() => { - drained = true - }) - try { - expect(() => registry.startStream(Buffer.from('next'), dispatcher, context)).toThrow( - 'relay_response_stream_shutdown_fenced' - ) - await flushPump() - expect(drained).toBe(false) - } finally { - write.resolve() - await drain - } - - expect(notifyBulk).toHaveBeenCalledTimes(1) - await registry.disposeAllAndWait() - }) -}) diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts index 922c8637d3a..c9d8d2ce290 100644 --- a/src/relay/git-response-stream.ts +++ b/src/relay/git-response-stream.ts @@ -44,13 +44,8 @@ function encodeChunks(payload: Buffer, chunkBytes = GIT_RESPONSE_CHUNK_SIZE): st export class GitResponseStreamRegistry { private streams = new Map() private nextId = 1 - private disposed = false - private readonly pendingPumps = new Set>() private register(ownerClientId: number): number { - if (this.disposed) { - throw new Error('relay_response_stream_shutdown_fenced') - } const streamId = this.nextId++ this.streams.set(streamId, { ownerClientId, @@ -146,17 +141,8 @@ export class GitResponseStreamRegistry { const chunks = encodeChunks(payload, Math.min(GIT_RESPONSE_CHUNK_SIZE, sinkChunkBytes)) // Why: kick the pump off the response task so the client sees the sentinel // (and can subscribe/reassemble) before the first chunk frame arrives. - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let finish!: () => void - const completion = new Promise((resolve) => { - finish = () => { - this.pendingPumps.delete(completion) - resolve() - } - }) - this.pendingPumps.add(completion) setImmediate(() => { - void this.pump(streamId, chunks, dispatcher, context).then(finish, finish) + void this.pump(streamId, chunks, dispatcher, context) }) return { __orcaGitResponseStream: { streamId, totalBytes: payload.length, chunkCount: chunks.length } @@ -213,8 +199,7 @@ export class GitResponseStreamRegistry { } ) } - // Why: disposal may abort while the final chunk write is in flight. - if (endReason === 'end' && !entry.aborted && !context.isStale()) { + if (endReason === 'end') { await dispatcher.notifyBulk('git.responseEnd', { streamId }, { clientId }) } } catch (err) { @@ -238,20 +223,13 @@ export class GitResponseStreamRegistry { } } - /** Permanently fences new streams and aborts every pump; use disposeAllAndWait to await them. */ disposeAll(): void { - this.disposed = true for (const entry of this.streams.values()) { entry.aborted = true this.wake(entry) } this.streams.clear() } - - async disposeAllAndWait(): Promise { - this.disposeAll() - await Promise.all(this.pendingPumps) - } } /** diff --git a/src/relay/node-pty-binding-survey.test.ts b/src/relay/node-pty-binding-survey.test.ts index b51a57fd4a8..8787713c117 100644 --- a/src/relay/node-pty-binding-survey.test.ts +++ b/src/relay/node-pty-binding-survey.test.ts @@ -1,4 +1,12 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' @@ -7,6 +15,7 @@ import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-d import { collectNodePtyUnavailableDiagnosis, readNodeGypBuildRecord, + resolveNodePtyInstallDir, surveyNodePtyBinding } from './node-pty-binding-survey' import { formatNodePtyUnavailableMessage } from './node-pty-unavailable-diagnosis' @@ -73,6 +82,26 @@ describe('readNodeGypBuildRecord', () => { }) }) +describe('resolveNodePtyInstallDir', () => { + it('finds the install an ancestor node_modules supplies, as the bare import does', () => { + // realpath: the resolver answers the real path, and macOS tmpdir is a link. + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-node-pty-'))) + roots.push(root) + const installDir = join(root, 'node_modules', 'node-pty') + mkdirSync(installDir, { recursive: true }) + writeFileSync(join(installDir, 'package.json'), '{}\n') + + expect(resolveNodePtyInstallDir(join(root, 'relay', 'abc123'))).toBe(installDir) + }) + + it('answers nothing when no node_modules up the tree holds node-pty', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) + roots.push(root) + + expect(resolveNodePtyInstallDir(root)).toBeNull() + }) +}) + describe('collectNodePtyUnavailableDiagnosis', () => { it("recovers the dynamic loader's own words that node-pty threw away", async () => { // The whole defect in one assertion: what reaches the relay is FLATTENED, which names @@ -107,17 +136,61 @@ describe('collectNodePtyUnavailableDiagnosis', () => { } }, 20_000) - it('reports an unlocatable install as unverifiable, not as a diagnosis', async () => { + it('diagnoses a node-pty directory that does not exist instead of calling it unverifiable (#20386)', async () => { + // What a Linux host without a compiler gets: the deploy reinstalls with node-pty removed. + const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) + roots.push(root) + const missingDir = join(root, 'node-pty') const diagnosis = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir: null, - error: new Error(FLATTENED) + nodePtyDir: missingDir, + error: new Error(`no node-pty at ${join(missingDir, 'lib', 'index.js')}`) }) - expect(diagnosis.status).toBe('unverifiable') - const text = formatNodePtyUnavailableMessage(diagnosis) - expect(text).toContain('could not establish why') - // It still has to be reportable: the raw error is the only thing an issue can quote. - expect(text).toContain(FLATTENED) - }) + expect(diagnosis.status).toBe('blocked') + expect(['toolchain_missing', 'dependency_missing']).toContain(diagnosis.reason) + expect(diagnosis.survey).toMatchObject({ installed: false, bindingPath: null }) + expect(diagnosis.toolchain === null).toBe(process.platform !== 'linux') + expect(formatNodePtyUnavailableMessage(diagnosis)).toContain( + `node-pty is not installed at ${missingDir}` + ) + }, 20_000) + + it.skipIf(process.platform === 'win32')( + 'treats a node-pty link whose target is gone as not installed', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) + roots.push(root) + const linked = join(root, 'node-pty') + symlinkSync(join(root, 'gone'), linked) + const diagnosis = await collectNodePtyUnavailableDiagnosis({ nodePtyDir: linked }) + expect(diagnosis.survey).toMatchObject({ installed: false, bindingPath: null }) + }, + 20_000 + ) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'keeps a directory it was refused as unverifiable rather than absent', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-node-pty-')) + roots.push(root) + const locked = join(root, 'locked') + mkdirSync(join(locked, 'node-pty'), { recursive: true }) + chmodSync(locked, 0o000) + try { + const diagnosis = await collectNodePtyUnavailableDiagnosis({ + nodePtyDir: join(locked, 'node-pty'), + error: new Error(FLATTENED) + }) + expect(diagnosis.status).toBe('unverifiable') + expect(diagnosis.detail).toContain('EACCES') + const text = formatNodePtyUnavailableMessage(diagnosis) + expect(text).toContain('could not establish why') + // It still has to be reportable: the raw error is the only thing an issue can quote. + expect(text).toContain(FLATTENED) + } finally { + chmodSync(locked, 0o755) + } + } + ) it('probes the host toolchain only when nothing was compiled', async () => { const diagnosis = await collectNodePtyUnavailableDiagnosis({ diff --git a/src/relay/node-pty-binding-survey.ts b/src/relay/node-pty-binding-survey.ts index 805527f1aad..16a051c2302 100644 --- a/src/relay/node-pty-binding-survey.ts +++ b/src/relay/node-pty-binding-survey.ts @@ -19,8 +19,9 @@ * Every step is best-effort and failure-tolerant: whatever cannot be established is * reported as unestablished rather than guessed (docs/reference/ssh-execution-boundary.md). */ -import { existsSync, readFileSync } from 'node:fs' -import { join } from 'node:path' +import { existsSync, readFileSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' import { release } from 'node:os' import process from 'node:process' import { runProcess } from '../shared/child-process/run-process' @@ -85,6 +86,7 @@ export function surveyNodePtyBinding( const built = readNodeGypBuildRecord(nodePtyDir) return { moduleDir: nodePtyDir, + installed: true, bindingPath, searched, builtNodeAbi: built.nodeAbi, @@ -181,6 +183,34 @@ async function probeRelayBuildToolchain( } } +/** The install a bare `node-pty` import from `fromDir` loads — Node walks up, so it can be an ancestor's. */ +export function resolveNodePtyInstallDir(fromDir: string): string | null { + try { + return dirname(createRequire(join(fromDir, 'relay.js')).resolve('node-pty/package.json')) + } catch { + return null + } +} + +// Only ENOENT is absence — the relay observing its own host. `stat` judges the directory the +// loader reads, so a dangling link is absent; `existsSync` would also answer false for EACCES. +function readNodePtyDirPresence( + nodePtyDir: string +): 'present' | 'absent' | { unverifiable: string } { + try { + statSync(nodePtyDir) + return 'present' + } catch (error) { + const code = error instanceof Error && 'code' in error ? String(error.code) : null + if (code === 'ENOENT') { + return 'absent' + } + return { + unverifiable: `the relay could not read its node-pty install directory (${code ?? readErrorMessage(error) ?? 'unknown error'})` + } + } +} + function readErrorMessage(error: unknown): string | null { if (error instanceof Error) { return error.message @@ -193,21 +223,32 @@ function readErrorMessage(error: unknown): string | null { * one's answer. Called only on the failure path, so a spawn that works pays nothing. */ export async function collectNodePtyUnavailableDiagnosis(options: { - nodePtyDir: string | null + nodePtyDir: string error?: unknown }): Promise { const abi = detectNativeHostAbi() const host: NodePtyUnavailableHost = { ...abi, nodeVersion: process.version } const requireError = readErrorMessage(options.error) - if (!options.nodePtyDir) { + const presence = readNodePtyDirPresence(options.nodePtyDir) + if (typeof presence === 'object') { return diagnoseNodePtyUnavailable({ host, survey: null, requireError, - unverifiableBecause: 'the relay could not locate its node-pty install directory' + unverifiableBecause: presence.unverifiable }) } - const survey = surveyNodePtyBinding(options.nodePtyDir, host) + const survey: NodePtyBindingSurvey | null = + presence === 'absent' + ? { + moduleDir: options.nodePtyDir, + installed: false, + bindingPath: null, + searched: [], + builtNodeAbi: null, + builtArch: null + } + : surveyNodePtyBinding(options.nodePtyDir, host) const probed = survey?.bindingPath ? await probeNodePtyLoader(options.nodePtyDir) : {} const toolchain = survey && !survey.bindingPath ? await probeRelayBuildToolchain(host.platform) : null diff --git a/src/relay/node-pty-unavailable-diagnosis.test.ts b/src/relay/node-pty-unavailable-diagnosis.test.ts index 7bed6ef256e..ca661e4bd2e 100644 --- a/src/relay/node-pty-unavailable-diagnosis.test.ts +++ b/src/relay/node-pty-unavailable-diagnosis.test.ts @@ -27,6 +27,7 @@ const SEARCHED = ['build/Release', 'build/Debug', 'prebuilds/linux-x64'] const INSTALLED: NodePtyBindingSurvey = { moduleDir: MODULE_DIR, + installed: true, bindingPath: `${MODULE_DIR}/build/Release/pty.node`, searched: SEARCHED, builtNodeAbi: null, @@ -140,6 +141,53 @@ describe('diagnoseNodePtyUnavailable', () => { }) expect(present.reason).toBe('dependency_missing') expect(formatNodePtyUnavailableMessage(present)).not.toContain('apt-get') + expect(formatNodePtyUnavailableMessage(present)).toContain( + 'build tools needed to compile it are present' + ) + }) + + it('never claims the build tools are present when no toolchain probe answered', () => { + // docs/reference/ssh-execution-boundary.md: a probe that timed out established nothing. + const linuxUnchecked = message({ survey: NOTHING_INSTALLED, toolchain: null }) + expect(linuxUnchecked).not.toContain('are present') + expect(linuxUnchecked).toContain('could not be checked') + expect(linuxUnchecked).toContain('Reconnect to reinstall') + + // Off Linux the probe never runs: node-pty ships prebuilds, so tools are beside the point. + const macos = message({ + host: { ...UBUNTU_2004, platform: 'darwin', libc: 'none', glibcVersion: null }, + survey: NOTHING_INSTALLED, + toolchain: null + }) + expect(macos).not.toContain('build tools') + expect(macos).toContain('Reconnect to reinstall') + }) + + it('names an absent node-pty directory as not installed and still offers the build tools (#20386)', () => { + // The no-toolchain deploy removes node-pty outright, so there is no directory to search. + const text = message({ + survey: { ...NOTHING_INSTALLED, installed: false, searched: [] }, + toolchain: toolchain(['python3']) + }) + expect(text).toContain(`node-pty is not installed at ${MODULE_DIR}`) + expect(text).toContain('sudo apt-get install -y build-essential python3') + expect(text).not.toContain('reconnect to retry') + }) + + it('does not blame the build tools for an absent directory on a host that has them', () => { + // The node-pty-less reinstall also leaves no directory when it fails for a non-toolchain + // reason (ENOSPC, registry unreachable). Naming tools the host already has is the + // confidently-wrong diagnosis #20386's fix must not introduce. + const verdict = diagnose({ + survey: { ...NOTHING_INSTALLED, installed: false, searched: [] }, + toolchain: toolchain(['make', 'g++', 'python3']) + }) + expect(verdict.reason).toBe('dependency_missing') + const text = formatNodePtyUnavailableMessage(verdict) + expect(text).toContain(`node-pty is not installed at ${MODULE_DIR}`) + expect(text).toContain('build tools needed to compile it are present') + expect(text).not.toContain('apt-get') + expect(text).not.toContain('not installed.') }) it('reports a binding that killed the probe as a crash rather than a miss', () => { diff --git a/src/relay/node-pty-unavailable-diagnosis.ts b/src/relay/node-pty-unavailable-diagnosis.ts index 590eedcc375..b8d8fdbdda5 100644 --- a/src/relay/node-pty-unavailable-diagnosis.ts +++ b/src/relay/node-pty-unavailable-diagnosis.ts @@ -33,6 +33,8 @@ import type { TerminalUnavailableCause } from '../shared/terminal-unavailable-ca export type NodePtyBindingSurvey = { /** The node-pty install the relay would load from. */ moduleDir: string + /** False when `moduleDir` itself is absent — the no-toolchain deploy skips node-pty entirely. */ + installed: boolean /** The compiled binding the loader would open, or null when no directory holds one. */ bindingPath: string | null /** Directories checked, so "nothing is installed" is a statement with evidence. */ @@ -312,8 +314,7 @@ function remedyFor(diagnosis: NodePtyUnavailableDiagnosis): string { case 'dependency_missing': return ( `node-pty has no compiled binary on this host (${searchedPhrase(survey)}). ` + - `The C/C++ build tools needed to compile it are present, so reconnect to reinstall ` + - `the relay's native modules.` + `${toolchainPresenceSentence(host, toolchain)}Reconnect to reinstall the relay's native modules.` ) case 'abi_mismatch': return ( @@ -355,7 +356,25 @@ function remedyFor(diagnosis: NodePtyUnavailableDiagnosis): string { } } +// Claims "present" only off a probe that answered; the probe runs on Linux only, since +// node-pty ships prebuilds elsewhere and a reinstall there needs no compiler. +function toolchainPresenceSentence( + host: NodePtyUnavailableHost, + toolchain: BuildToolchainStatus | null +): string { + if (toolchain) { + return 'The C/C++ build tools needed to compile it are present. ' + } + if (host.platform === 'linux') { + return 'Whether the C/C++ build tools needed to compile it are installed could not be checked. ' + } + return '' +} + function searchedPhrase(survey: NodePtyBindingSurvey | null): string { + if (survey && !survey.installed) { + return `node-pty is not installed at ${survey.moduleDir}` + } return survey && survey.searched.length > 0 ? `checked ${survey.searched.join(', ')} under ${survey.moduleDir}` : 'nothing was found where node-pty looks' diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index ea5c6ca3486..7e0dc3f70c6 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -1,11 +1,13 @@ import './mock-descendant-sweep' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import * as ptyChildProcessInspection from './pty-child-process-inspection' import * as ptyShellUtils from './pty-shell-utils' import * as processTableSnapshotReader from '../shared/process-table-snapshot-reader' +import * as runProcessModule from '../shared/child-process/run-process' +import * as nodePtyBindingSurvey from './node-pty-binding-survey' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), @@ -325,30 +327,78 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(0) }) - it('keeps the load error it was handed instead of replacing it with guesses', async () => { - // #17830: the user got three remedies for four possible faults and could verify none. - // The relay must carry what it was actually told, and must not prescribe a toolchain - // install it never probed for. - const thrown = - 'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64' - mockPtySpawn.mockImplementationOnce(() => { - throw new Error(thrown) - }) + const THROWN_LOAD_ERROR = + 'Failed to load native module: pty.node, checked: build/Release, prebuilds/linux-x64' - const message = await dispatcher.callRequest('pty.spawn', {}).then( - () => '', - (error: Error) => error.message + function failSpawnWithToolchainProbe( + toolchainProbeStdout: string + ): Promise<(Error & { data?: unknown }) | null> { + const realRunProcess = runProcessModule.runProcess + vi.spyOn(runProcessModule, 'runProcess').mockImplementation((spec) => + spec.program === '/bin/sh' + ? Promise.resolve({ + stdout: toolchainProbeStdout, + stderr: '', + code: 0, + signal: null, + timedOut: false + }) + : realRunProcess(spec) ) + mockPtySpawn.mockImplementationOnce(() => { + throw new Error(THROWN_LOAD_ERROR) + }) + return dispatcher.callRequest('pty.spawn', {}).then( + () => null, + (error: Error & { data?: unknown }) => error + ) + } - expect(message).toContain(thrown) - expect(message).not.toContain('install make, a C++ compiler, and python3') - // Nothing here established a cause — the relay's node-pty directory is not on disk in - // this harness — so per docs/reference/ssh-execution-boundary.md it must say so rather - // than pick a diagnosis. Every message still names the host, for the bug report. - expect(message).toContain('could not establish why') + it('diagnoses a relay installed without node-pty instead of asking for a reconnect (#20386)', async () => { + // Relies on no node-pty beside the relay source — what the no-toolchain deploy leaves. Absence + // is observed on the owning host, so it is a diagnosis (docs/reference/ssh-execution-boundary.md). + expect(typeof process.resourcesPath, 'packaged node-pty lookup must be off').not.toBe('string') + expect( + existsSync(join(__dirname, 'node_modules', 'node-pty')), + 'a node-pty beside src/relay would turn this into the installed-but-unbuilt case' + ).toBe(false) + // The checkout's own node_modules holds a node-pty an SSH host's relay dir never has. + vi.spyOn(nodePtyBindingSurvey, 'resolveNodePtyInstallDir').mockReturnValue(null) + + const rejection = await failSpawnWithToolchainProbe('HAVE python3\nPKG apt-get\n') + const message = rejection?.message ?? '' + + // #17830: the load error the relay was handed still travels with the rejection. + // No compiler means no rebuild, so the client must not auto-reconnect on this one. + expect(rejection?.data).toMatchObject({ + reason: 'toolchain_missing', + repairable: false, + rawError: THROWN_LOAD_ERROR + }) + expect(message).not.toContain('could not establish why') + expect(message).toContain('node-pty is not installed at') + expect(message).toContain('sudo apt-get install -y build-essential python3') + // Every message still names the host, for the bug report. expect(message).toMatch(/Host: linux\/\w+, .*Node v[\d.]+ \(ABI \d+\)/) }) + it('diagnoses the node-pty an ancestor node_modules supplied, not the absent one beside the relay', async () => { + const ancestorInstall = join(mkdtempSync(join(tmpdir(), 'orca-node-pty-')), 'node-pty') + mkdirSync(ancestorInstall) + vi.spyOn(nodePtyBindingSurvey, 'resolveNodePtyInstallDir').mockReturnValue(ancestorInstall) + + try { + const message = + (await failSpawnWithToolchainProbe('HAVE make\nHAVE g++\nHAVE python3\nPKG apt-get\n')) + ?.message ?? '' + + expect(message).not.toContain('node-pty is not installed at') + expect(message).toContain(`under ${ancestorInstall}`) + } finally { + rmSync(dirname(ancestorInstall), { recursive: true, force: true }) + } + }) + it('preserves unrelated node-pty spawn failures', async () => { mockPtySpawn.mockImplementationOnce(() => { throw new Error('File not found: missing-shell.exe') diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index cb88537de44..fbad4a212a0 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -128,7 +128,10 @@ import { injectRelayHistoryEnv } from './terminal-history' import { isFlattenedNodePtyLoaderMessage } from '../main/orcad/node-pty-loader-diagnosis' -import { collectNodePtyUnavailableDiagnosis } from './node-pty-binding-survey' +import { + collectNodePtyUnavailableDiagnosis, + resolveNodePtyInstallDir +} from './node-pty-binding-survey' import { describeRelayRuntime } from './relay-runtime-identity' import { relayConptyDllSpawnOptions } from './relay-windows-conpty' import { @@ -670,9 +673,10 @@ export class PtyHandler { * healthy relay never pays for them. */ private async nodePtyUnavailableError(spawnError?: unknown): Promise { - const nodePtyDir = this.relayNodePtyDir() + // Why: diagnose the install the bare import loaded; the bundle's own dir is only the fallback. + const nodePtyDir = resolveNodePtyInstallDir(__dirname) ?? this.relayNodePtyDir() const diagnosis = await collectNodePtyUnavailableDiagnosis({ - nodePtyDir: existsSync(nodePtyDir) ? nodePtyDir : null, + nodePtyDir, error: spawnError ?? this.lastPtyLoadError }) return Object.assign(new Error(formatNodePtyUnavailableMessage(diagnosis)), { diff --git a/src/relay/relay-agent-process-lifetime.ts b/src/relay/relay-agent-process-lifetime.ts deleted file mode 100644 index 9e290d3e952..00000000000 --- a/src/relay/relay-agent-process-lifetime.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { terminateRelaySubprocessTree } from './subprocess-tree-termination' - -type Child = Parameters[0] - -// Why 10s: the same physical-exit deadline the relay's watcher children get after a kill. -export const RELAY_AGENT_CLOSE_DEADLINE_MS = 10_000 - -/** Request timeout/cancellation is not evidence that its host child has closed. */ -export class RelayAgentProcessLifetime { - private fenced = false - private readonly children = new Map>() - // Why: a retry re-checks close instead of re-killing a tree whose pid may already be reused. - private readonly signalled = new WeakSet() - private disposal: Promise | null = null - - constructor(private readonly closeDeadlineMs = RELAY_AGENT_CLOSE_DEADLINE_MS) {} - - assertAdmission(): void { - if (this.fenced) { - throw new Error('relay_agent_execution_shutdown_fenced') - } - } - - track(child: Child): void { - this.assertAdmission() - // Why: no Promise.withResolvers — the relay bundle still targets Node 18 hosts. - let resolveClosed!: () => void - const closed = new Promise((resolve) => { - resolveClosed = resolve - }) - this.children.set(child, closed) - // A late error after request timeout must not remove physical-close tracking or crash the host. - const onError = () => {} - child.on('error', onError) - child.once('close', () => { - child.off('error', onError) - this.children.delete(child) - resolveClosed() - }) - } - - dispose(): Promise { - this.fenced = true - if (this.disposal) { - return this.disposal - } - const pending = [...this.children.entries()] - for (const [child] of pending) { - if (!this.signalled.has(child)) { - this.signalled.add(child) - terminateRelaySubprocessTree(child) - } - } - const disposal = this.waitForClose(pending.map(([, closed]) => closed)).finally(() => { - this.disposal = null - }) - this.disposal = disposal - return disposal - } - - private waitForClose(closes: Promise[]): Promise { - if (closes.length === 0) { - return Promise.resolve() - } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('relay_agent_execution_shutdown_incomplete')) - }, this.closeDeadlineMs) - timer.unref?.() - void Promise.all(closes).then(() => { - clearTimeout(timer) - resolve() - }) - }) - } -} diff --git a/src/relay/relay-daemon.ts b/src/relay/relay-daemon.ts index f9018000352..649fde3427a 100644 --- a/src/relay/relay-daemon.ts +++ b/src/relay/relay-daemon.ts @@ -1,8 +1,4 @@ -import { join } from 'node:path' import { installRelayLogRotation } from './rotating-log-writer' -import { registerRelayOwnerReset } from './relay-owner-reset-registration' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' -import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import { readLaunchVersion } from './relay-handshake' import type { RelayLaunchOptions } from './relay-launch-options' import { RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS, RELAY_IDLE_GRACE_MS } from './relay-launch-options' @@ -94,24 +90,6 @@ export async function runRelayDaemon(options: RelayLaunchOptions): Promise } } ) - // Beside the relay endpoint so a prepared reset survives a daemon restart. - const resetJournal = new RelayOwnerResetPreparationJournal( - join( - options.endpointDir ?? endpointDirForRelaySocket(options.sockPath), - 'owner-reset-preparations' - ), - options.sockPath, - launchVersion - ) - const readOwnerResetStatus = registerRelayOwnerReset(primaryChannel.dispatcher, { - owners: runtime.ptyConsumerSessionAdapter, - lifecycle, - persistPrepared: (request, principal, authenticationKind, assertAuthority) => - resetJournal.persist(request, principal, authenticationKind, assertAuthority), - describePreparation: (principal, authenticationKind) => - resetJournal.describe(principal, authenticationKind), - socket: socketOwnership - }) const startedAt = Date.now() registerRelayStatus( primaryChannel, @@ -120,8 +98,7 @@ export async function runRelayDaemon(options: RelayLaunchOptions): Promise socketOwnership, lifecycle, options, - startedAt, - readOwnerResetStatus + startedAt ) try { @@ -170,37 +147,32 @@ function registerRelayStatus( socketOwnership: RelaySocketOwnership, lifecycle: RelayGraceLifecycle, options: RelayLaunchOptions, - startedAt: number, - readOwnerResetStatus: ReturnType + startedAt: number ): void { - primaryChannel.dispatcher.onRequest('relay.status', async (_params, context) => { - const resetStatus = readOwnerResetStatus(context) - return { - ...resetStatus, - capabilities: [...SKILL_RELAY_CAPABILITIES, ...resetStatus.capabilities], - pid: process.pid, - uptimeMs: Date.now() - startedAt, - detached: options.detached, - stdoutAlive: primaryChannel.isAlive, - memory: process.memoryUsage(), - ptys: { active: runtime.ptyHandler.activePtyCount }, - ptySourceCredit: { - enabled: true, - session: runtime.ptyConsumerSessionAdapter.getDebugSnapshot(), - publication: runtime.ptySourcePublication.getDebugSnapshot() - }, - socket: { - path: options.sockPath, - owned: socketOwnership.owned, - listening: socketOwnership.server?.listening ?? false, - clients: reconnectListener.clientCount, - acceptedConnections: reconnectListener.acceptedConnections - }, - grace: { - active: runtime.ptyHandler.graceTimerActive, - deadlineAt: lifecycle.deadlineAt, - reason: lifecycle.reason - } + primaryChannel.dispatcher.onRequest('relay.status', async () => ({ + capabilities: SKILL_RELAY_CAPABILITIES, + pid: process.pid, + uptimeMs: Date.now() - startedAt, + detached: options.detached, + stdoutAlive: primaryChannel.isAlive, + memory: process.memoryUsage(), + ptys: { active: runtime.ptyHandler.activePtyCount }, + ptySourceCredit: { + enabled: true, + session: runtime.ptyConsumerSessionAdapter.getDebugSnapshot(), + publication: runtime.ptySourcePublication.getDebugSnapshot() + }, + socket: { + path: options.sockPath, + owned: socketOwnership.owned, + listening: socketOwnership.server?.listening ?? false, + clients: reconnectListener.clientCount, + acceptedConnections: reconnectListener.acceptedConnections + }, + grace: { + active: runtime.ptyHandler.graceTimerActive, + deadlineAt: lifecycle.deadlineAt, + reason: lifecycle.reason } - }) + })) } diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts index e0e538b0736..14f5d5b2ee8 100644 --- a/src/relay/relay-filesystem-watch-registry.ts +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -1,5 +1,6 @@ import type { RelayDispatcher, RequestContext } from './dispatcher' import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event-batch' +import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions @@ -9,7 +10,7 @@ import { type RelayWatcherProcessPool } from './relay-watcher-process-pool' import { emitRelayWatcherEvents, emitRelayWatcherOverflow } from './relay-watcher-event-emitter' -import { awaitRelayWatcherSetupForClient, startInitialRelayWatch } from './relay-watcher-setup-wait' +import { awaitRelayWatcherSetup, shouldRetryInitialRelayWatch } from './relay-watcher-setup-wait' import { RelayWatcherTeardownTracker, type RelayWatcherTeardownState @@ -26,7 +27,6 @@ import { releaseStaleRelayWatches } from './relay-watcher-stale-client-release' import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' import { joinRelayWatcherPendingSetup } from './relay-watcher-pending-setup-join' import { createRelayWatcherState } from './relay-watcher-state' -import { closeRelayWatchesAndWait, disposeRelayWatchesAndWait } from './relay-watcher-shutdown' const RELAY_WATCH_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) @@ -40,7 +40,6 @@ export class RelayFilesystemWatchRegistry { ) private readonly teardownTracker: RelayWatcherTeardownTracker private readonly removalFence: RelayWatcherRemovalFence - private disposed = false constructor( private readonly dispatcher: RelayDispatcher, @@ -59,9 +58,6 @@ export class RelayFilesystemWatchRegistry { } watch(rootPath: string, context?: RequestContext, watchId?: number): Promise { - if (this.disposed) { - return Promise.reject(new Error('relay_watcher_shutdown_fenced')) - } const rootKey = normalizeRuntimePathForComparison(rootPath) if (this.removalFence.isActive(rootKey)) { return Promise.reject(new Error('Remote worktree deletion already in progress')) @@ -75,9 +71,7 @@ export class RelayFilesystemWatchRegistry { if (watchId !== undefined) { existing.clientWatchIds.set(clientId, watchId) } - return awaitRelayWatcherSetupForClient(existing, context, () => - this.releaseWatchClient(existing, clientId) - ) + return this.awaitSetupForClient(existing, clientId, context) } void this.closeWatch(existing).catch(() => {}) } @@ -110,9 +104,6 @@ export class RelayFilesystemWatchRegistry { if (capacityRelease) { await capacityRelease } - if (this.disposed) { - throw new Error('relay_watcher_shutdown_fenced') - } const clientId = context?.clientId ?? 0 const isStale = context?.isStale ?? (() => false) const existing = this.watches.get(rootKey) @@ -121,9 +112,7 @@ export class RelayFilesystemWatchRegistry { if (watchId !== undefined) { existing.clientWatchIds.set(clientId, watchId) } - await awaitRelayWatcherSetupForClient(existing, context, () => - this.releaseWatchClient(existing, clientId) - ) + await this.awaitSetupForClient(existing, clientId, context) return } @@ -132,9 +121,7 @@ export class RelayFilesystemWatchRegistry { const state = createRelayWatcherState(rootKey, rootPath, clientId, isStale, watchId) this.watches.set(rootKey, state) state.setupWaiters = new PromiseSettlementWaiters(this.startInitialWatch(state)) - await awaitRelayWatcherSetupForClient(state, context, () => - this.releaseWatchClient(state, clientId) - ) + await this.awaitSetupForClient(state, clientId, context) } unwatch(rootPath: string, context?: RequestContext): void { @@ -196,31 +183,27 @@ export class RelayFilesystemWatchRegistry { } dispose(): void { - this.disposed = true this.watches.forEach((state) => void this.closeWatch(state).catch(() => {})) this.watcherPool.dispose() } - closeWatchesAndWait(): Promise { - this.disposed = true - return closeRelayWatchesAndWait( - this.watches, - this.pendingSetups, - this.teardownTracker, - (state) => this.closeWatch(state) - ) - } - - disposeAndWait = (): Promise => - disposeRelayWatchesAndWait(() => this.closeWatchesAndWait(), this.watcherPool) - - private startInitialWatch(state: RelayWatcherTeardownState): Promise { - return startInitialRelayWatch( - state, - () => this.subscribeState(state), - () => emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed), - () => this.closeWatch(state) - ) + private async startInitialWatch(state: RelayWatcherTeardownState): Promise { + try { + await this.subscribeState(state) + } catch (firstError) { + if (!state.closed && shouldRetryInitialRelayWatch(firstError)) { + try { + await this.subscribeState(state) + emitRelayWatcherOverflow(this.dispatcher, state.rootPath, state.closed) + return + } catch (quarantineError) { + void this.closeWatch(state).catch(() => {}) + throw quarantineError + } + } + void this.closeWatch(state).catch(() => {}) + throw firstError + } } private subscribeState(state: RelayWatcherTeardownState): Promise { @@ -262,13 +245,7 @@ export class RelayFilesystemWatchRegistry { state.generation !== generation || this.watches.get(state.rootKey) !== state ) { - if (state.closed) { - state.subscription = subscription - } await subscription.unsubscribe() - if (state.subscription === subscription) { - state.subscription = null - } return } state.subscription = subscription @@ -299,6 +276,30 @@ export class RelayFilesystemWatchRegistry { }) } + private async awaitSetupForClient( + state: RelayWatcherTeardownState, + clientId: number, + context?: RequestContext + ): Promise { + try { + await awaitRelayWatcherSetup(state.setupWaiters, context?.signal) + } catch (error) { + this.releaseWatchClient(state, clientId) + const expectedAbort = + (error instanceof Error && error.name === 'AbortError') || + (isWatcherProcessFailure(error) && error.code === 'subscribe_aborted') + if (expectedAbort) { + return + } + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`[relay] File watcher not available for ${state.rootPath}: ${message}\n`) + throw error + } + if (context?.isStale()) { + this.releaseWatchClient(state, clientId) + } + } + private releaseClientWatches(clientId: number): void { this.watches.forEach((state) => this.releaseWatchClient(state, clientId)) } diff --git a/src/relay/relay-grace-lifecycle.test.ts b/src/relay/relay-grace-lifecycle.test.ts deleted file mode 100644 index 499d87dd34c..00000000000 --- a/src/relay/relay-grace-lifecycle.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import type { RelayDispatcher, RequestContext } from './dispatcher' -import type { PtyHandler } from './pty-handler' -import { RelayGraceLifecycle } from './relay-grace-lifecycle' - -afterEach(() => { - vi.restoreAllMocks() -}) - -function fixture(options: { clients?: number; dispose?: () => Promise } = {}) { - const graceCallbacks: (() => void)[] = [] - const ptyHandler = { - configuredGraceTimeMs: 1_000, - activePtyCount: 0, - pendingPtyCreationCount: 0, - graceTimerActive: false, - startGraceTimer: vi.fn((callback: () => void) => graceCallbacks.push(callback)), - cancelGraceTimer: vi.fn(), - onPtyPoolEmpty: vi.fn(() => () => {}), - onPtyPoolActive: vi.fn(() => () => {}), - dispose: vi.fn(options.dispose ?? (async () => {})) - } - const dispatcher = { - onNotification: vi.fn(), - onRequest: vi.fn(), - assertActiveWorkContext: vi.fn(), - beginWorkDrain: vi.fn(async () => {}) - } - const disposeOwnedProcesses = vi.fn(async () => {}) - const disposeRuntime = vi.fn() - const lifecycle = new RelayGraceLifecycle({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every dispatcher member the lifecycle calls. - dispatcher: dispatcher as unknown as RelayDispatcher, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every PTY handler member the lifecycle calls. - ptyHandler: ptyHandler as unknown as PtyHandler, - detached: true, - emptyDetachedStartupGraceMs: 100, - idleRelayGraceMs: 100, - readSocketClientCount: () => options.clients ?? 0, - hasAcceptedSocketClient: () => false, - ownsSocketPath: () => true, - disposeOwnedProcesses, - disposeRuntime - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: process.exit never returns; the stub only records the call. - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) - return { - lifecycle, - ptyHandler, - dispatcher, - disposeOwnedProcesses, - disposeRuntime, - exit, - graceCallbacks - } -} - -it('exits after idle shutdown without waiting on admitted requests', async () => { - const f = fixture() - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.exit).toHaveBeenCalledWith(0)) - expect(f.ptyHandler.dispose).toHaveBeenCalledOnce() - expect(f.disposeOwnedProcesses).toHaveBeenCalledOnce() - expect(f.disposeRuntime).toHaveBeenCalledOnce() - expect(f.dispatcher.beginWorkDrain).not.toHaveBeenCalled() -}) - -it('defers a failed idle shutdown and retries it through the grace timer', async () => { - let attempts = 0 - const f = fixture({ - dispose: async () => { - attempts += 1 - if (attempts === 1) { - throw new Error('pty still exiting') - } - } - }) - vi.spyOn(console, 'error').mockImplementation(() => {}) - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.ptyHandler.startGraceTimer).toHaveBeenCalledOnce()) - expect(f.lifecycle.reason).toBe('shutdown deferred') - expect(f.exit).not.toHaveBeenCalled() - - f.graceCallbacks[0]() - await vi.waitFor(() => expect(f.exit).toHaveBeenCalledWith(0)) - expect(f.ptyHandler.dispose).toHaveBeenCalledTimes(2) -}) - -it('does not retry a deferred shutdown while a socket client is attached', async () => { - const f = fixture({ - clients: 1, - dispose: async () => { - throw new Error('pty still exiting') - } - }) - f.lifecycle.shutdown() - await vi.waitFor(() => expect(f.ptyHandler.dispose).toHaveBeenCalledOnce()) - await Promise.resolve() - expect(f.ptyHandler.startGraceTimer).not.toHaveBeenCalled() - expect(f.exit).not.toHaveBeenCalled() -}) - -it('keeps the transport up until a reset initiator finishes, draining around owned disposal', async () => { - const f = fixture() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const initiator = { clientId: 1 } as RequestContext - await f.lifecycle.prepareShutdown(initiator) - expect(f.dispatcher.assertActiveWorkContext).toHaveBeenCalledWith(initiator) - expect(f.dispatcher.beginWorkDrain).toHaveBeenCalledTimes(2) - expect(f.dispatcher.beginWorkDrain).toHaveBeenCalledWith(initiator) - expect(f.disposeOwnedProcesses).toHaveBeenCalledOnce() - expect(f.disposeRuntime).not.toHaveBeenCalled() - expect(f.exit).not.toHaveBeenCalled() - - f.lifecycle.finishShutdown() - expect(f.disposeRuntime).toHaveBeenCalledOnce() - expect(f.exit).toHaveBeenCalledWith(0) -}) - -it('joins the same initiator, refuses a different one, and requires preparation to finish', async () => { - const f = fixture() - expect(() => f.lifecycle.finishShutdown()).toThrow('relay_shutdown_preparation_required') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const initiator = { clientId: 1 } as RequestContext - const first = f.lifecycle.prepareShutdown(initiator) - expect(f.lifecycle.prepareShutdown(initiator)).toBe(first) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const other = { clientId: 2 } as RequestContext - await expect(f.lifecycle.prepareShutdown(other)).rejects.toThrow( - 'relay_shutdown_preparation_in_progress' - ) - await first - expect(f.ptyHandler.dispose).toHaveBeenCalledOnce() -}) - -it('lets a failed reset preparation be retried', async () => { - let attempts = 0 - const f = fixture({ - dispose: async () => { - attempts += 1 - if (attempts === 1) { - throw new Error('pty still exiting') - } - } - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lifecycle only forwards the initiator to the dispatcher stub. - const initiator = { clientId: 1 } as RequestContext - await expect(f.lifecycle.prepareShutdown(initiator)).rejects.toThrow('pty still exiting') - expect(f.disposeOwnedProcesses).not.toHaveBeenCalled() - await f.lifecycle.prepareShutdown(initiator) - expect(f.disposeOwnedProcesses).toHaveBeenCalledOnce() -}) diff --git a/src/relay/relay-grace-lifecycle.ts b/src/relay/relay-grace-lifecycle.ts index b32578fa9d4..116b8873115 100644 --- a/src/relay/relay-grace-lifecycle.ts +++ b/src/relay/relay-grace-lifecycle.ts @@ -6,7 +6,7 @@ import { } from './relay-grace-branch' import { relayLogLine } from './relay-diagnostic-log' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types' -import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { RelayDispatcher } from './dispatcher' type RelayGraceLifecycleOptions = { dispatcher: RelayDispatcher @@ -26,9 +26,6 @@ export class RelayGraceLifecycle { private graceReason: string | null = null private graceBranch: RelayGraceBranch | null = null private shutdownInFlight = false - private shutdownPreparation: Promise | null = null - private shutdownPrepared = false - private shutdownInitiator: RequestContext | undefined private stopPoolWatch = (): void => {} private stopPoolActiveWatch = (): void => {} @@ -116,11 +113,22 @@ export class RelayGraceLifecycle { if (this.shutdownInFlight) { return } + this.shutdownInFlight = true relayLogLine( `[relay] Shutdown: ptys=${this.options.ptyHandler.activePtyCount}, clients=${this.options.readSocketClientCount()}, ownsSocket=${this.options.ownsSocketPath()}` ) - void this.prepareShutdown() - .then(() => this.finishShutdown()) + this.graceDeadlineAt = null + this.graceReason = null + this.graceBranch = null + void this.options.ptyHandler + .dispose() + .then(async () => { + await this.options.disposeOwnedProcesses() + this.stopPoolWatch() + this.stopPoolActiveWatch() + this.options.disposeRuntime() + process.exit(0) + }) .catch((error) => { this.shutdownInFlight = false relayLogLine( @@ -132,79 +140,6 @@ export class RelayGraceLifecycle { }) } - /** - * Disposes PTYs and owned processes but leaves the transport up, so a host-owned reset can - * settle its response before {@link finishShutdown} exits. Only a reset initiator also drains - * admitted requests; idle and signal shutdown keep exiting without waiting on them. - */ - prepareShutdown(initiator?: RequestContext, onAdmitted?: () => void): Promise { - if (initiator) { - try { - this.options.dispatcher.assertActiveWorkContext(initiator) - } catch (error) { - return Promise.reject(error) - } - } - if (this.shutdownPreparation) { - if (initiator && initiator !== this.shutdownInitiator) { - return Promise.reject(new Error('relay_shutdown_preparation_in_progress')) - } - return this.shutdownPreparation - } - try { - onAdmitted?.() - } catch (error) { - return Promise.reject(error) - } - this.shutdownInFlight = true - this.shutdownInitiator = initiator - let drainage: Promise - let disposal: Promise - try { - this.cancel('shutdown preparation') - drainage = initiator ? this.options.dispatcher.beginWorkDrain(initiator) : Promise.resolve() - disposal = this.options.ptyHandler.dispose() - } catch (error) { - this.shutdownInFlight = false - this.shutdownInitiator = undefined - return Promise.reject(error) - } - const preparation = Promise.allSettled([drainage, disposal]).then(async (results) => { - const failures = results.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] - ) - if (failures.length === 1) { - throw failures[0] - } - if (failures.length > 1) { - throw new AggregateError(failures, 'relay_shutdown_admitted_work_incomplete') - } - await this.options.disposeOwnedProcesses() - if (initiator) { - // Cleanup controls may have arrived while owned producers were settling. - await this.options.dispatcher.beginWorkDrain(initiator) - } - this.shutdownPrepared = true - }) - this.shutdownPreparation = preparation.catch((error: unknown) => { - this.shutdownPreparation = null - this.shutdownInitiator = undefined - this.shutdownInFlight = false - throw error - }) - return this.shutdownPreparation - } - - finishShutdown(): void { - if (!this.shutdownPrepared) { - throw new Error('relay_shutdown_preparation_required') - } - this.stopPoolWatch() - this.stopPoolActiveWatch() - this.options.disposeRuntime() - process.exit(0) - } - private configure(params: Record): { graceTimeMs: number } { return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { readConfiguredGraceMs: () => this.options.ptyHandler.configuredGraceTimeMs, diff --git a/src/relay/relay-owner-reset-dispatch.test.ts b/src/relay/relay-owner-reset-dispatch.test.ts deleted file mode 100644 index b3fa8fef34f..00000000000 --- a/src/relay/relay-owner-reset-dispatch.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' -import { RelayGraceLifecycle } from './relay-grace-lifecycle' -import { RelayOwnerReset } from './relay-owner-reset' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import type { PtyHandler } from './pty-handler' -import { encodeJsonRpcFrame } from './protocol' -import { RELAY_PREPARED_RESET_RECOVERY_METHOD } from '../shared/relay-owner-reset-contract' - -let dispatcher: RelayDispatcher | undefined -afterEach(() => { - dispatcher?.dispose() - vi.restoreAllMocks() -}) - -it.each(['same-transport', 'reconnect', 'new-coordinator'])( - 'replays prepared reset through %s without repeating real lifecycle disposal', - async (mode) => { - const writes: { - message: Record - settle: (result: SinkWriteSettlement) => void - }[] = [] - const write: ConstructorParameters[0] = (data, settle) => { - const length = data.readUInt32BE(9) - writes.push({ message: JSON.parse(data.subarray(13, 13 + length).toString()), settle }) - return true - } - const identity = { - principal: 'owner', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' as const - } - dispatcher = new RelayDispatcher(write, { supportsWriteCallback: true }, identity) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build') - const dispose = vi.fn(async () => {}) - const owned = vi.fn(async () => {}) - const runtime = vi.fn() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: process.exit never returns; the stub only records the call. - const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) - const lifecycle = new RelayGraceLifecycle({ - dispatcher, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every PTY handler member the lifecycle calls. - ptyHandler: { - cancelGraceTimer: vi.fn(), - dispose - } as unknown as PtyHandler, - detached: true, - emptyDetachedStartupGraceMs: 100, - idleRelayGraceMs: 100, - readSocketClientCount: () => 1, - hasAcceptedSocketClient: () => true, - ownsSocketPath: () => true, - disposeOwnedProcesses: owned, - disposeRuntime: runtime - }) - const reset = new RelayOwnerReset({ owners: adapter, lifecycle, ownsEndpoint: () => true }) - dispatcher.onRequest('relay.reset', (params, context) => reset.prepare(params, context)) - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - reset.recoverPrepared(params, context) - ) - const mutation = vi.fn(async () => {}) - dispatcher.onRequest('fs.writeFile', mutation) - let seq = 0 - let clientId = dispatcher.activeClientIds()[0] - const send = (method: string, params: Record, id?: number) => - dispatcher!.feedClient( - clientId, - encodeJsonRpcFrame( - { jsonrpc: '2.0', method, params, ...(id === undefined ? {} : { id }) }, - ++seq, - 0 - ) - ) - send( - 'pty.openClient', - { protocolVersion: 1, clientInstanceId: 'desktop', requestedRole: 'session-owner' }, - 1 - ) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 1)).toBe(true)) - const grant = writes.find((write) => write.message.id === 1)! - grant.settle({ ok: true }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pty.openClient's grant is a plain JSON record on this wire. - const owner = grant.message.result as Record - const params = { - version: 1, - runtimeIncarnation: reset.runtimeIncarnation, - operationId: 'reset-1', - ownerGeneration: owner.ownerGeneration, - ownerLease: owner.ownerLease - } - send('relay.reset', params, 2) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 2)).toBe(true)) - expect(dispose).toHaveBeenCalledOnce() - expect(owned).toHaveBeenCalledOnce() - send('rpc.cancel', { id: 2 }) - writes.find((write) => write.message.id === 2)!.settle({ ok: true }) - expect(exit).not.toHaveBeenCalled() - if (mode !== 'same-transport') { - dispatcher.invalidateClient('peer-closed') - clientId = dispatcher.attachClient(write, { supportsWriteCallback: true }, identity) - expect(adapter.activeSessionOwner(clientId)).toBeNull() - send( - 'pty.openClient', - { - protocolVersion: 1, - clientInstanceId: 'desktop', - requestedRole: 'session-owner' - }, - 22 - ) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 22)).toBe(true)) - const fencedGrant = writes.find((write) => write.message.id === 22)! - expect(fencedGrant.message.error).toMatchObject({ message: 'relay_work_admission_closed' }) - fencedGrant.settle({ ok: true }) - expect(adapter.activeSessionOwner(clientId)).toBeNull() - if (mode === 'new-coordinator') { - const replacement = new RelayOwnerReset({ - owners: adapter, - lifecycle, - ownsEndpoint: () => true - }) - expect(replacement.runtimeIncarnation).not.toBe(reset.runtimeIncarnation) - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - replacement.recoverPrepared(params, context) - ) - for (const [id, runtimeIncarnation] of [ - [20, reset.runtimeIncarnation], - [21, replacement.runtimeIncarnation] - ] as const) { - send(RELAY_PREPARED_RESET_RECOVERY_METHOD, { ...params, runtimeIncarnation }, id) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === id)).toBe(true)) - const refusal = writes.find((write) => write.message.id === id)! - expect(refusal.message.error).toMatchObject({ - message: 'relay_reset_continuation_unauthorized' - }) - refusal.settle({ ok: true }) - expect(exit).not.toHaveBeenCalled() - } - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - reset.recoverPrepared(params, context) - ) - } - } - send( - mode === 'same-transport' ? 'relay.reset' : RELAY_PREPARED_RESET_RECOVERY_METHOD, - params, - 3 - ) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 3)).toBe(true)) - const retry = writes.find((write) => write.message.id === 3)! - expect(retry.message.result).toEqual({ - version: 1, - operationId: params.operationId, - runtimeIncarnation: reset.runtimeIncarnation, - prepared: true - }) - expect(dispose).toHaveBeenCalledOnce() - expect(owned).toHaveBeenCalledOnce() - send('fs.writeFile', {}, 4) - await vi.waitFor(() => expect(writes.some((write) => write.message.id === 4)).toBe(true)) - expect(mutation).not.toHaveBeenCalled() - expect(runtime).not.toHaveBeenCalled() - retry.settle({ ok: true }) - expect(runtime).toHaveBeenCalledOnce() - expect(exit).toHaveBeenCalledWith(0) - } -) diff --git a/src/relay/relay-owner-reset-preparation-journal.test.ts b/src/relay/relay-owner-reset-preparation-journal.test.ts deleted file mode 100644 index 2a572aa1bd8..00000000000 --- a/src/relay/relay-owner-reset-preparation-journal.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { tmpdir } from 'node:os' -import { beforeEach, afterEach, expect, it, vi } from 'vitest' -import * as secure from '../shared/secure-file' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' - -let directory: string -const request = { - version: 1 as const, - operationId: 'operation', - runtimeIncarnation: 'runtime', - ownerGeneration: 1, - ownerLease: 'secret' -} -const store = () => new RelayOwnerResetPreparationJournal(directory, '/socket', 'build') -const persist = () => store().persist(request, 'principal', 'endpoint-credential', () => {}) -beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), 'orca-reset-preparation-')) -}) -afterEach(() => { - vi.restoreAllMocks() - rmSync(directory, { recursive: true, force: true }) -}) - -it('advertises an absolute journal location without creating files', () => { - const journal = new RelayOwnerResetPreparationJournal('relative-journal', '/socket', 'build') - expect(journal.describe('owner', 'endpoint-credential')).toMatchObject({ - journalDirectory: resolve('relative-journal'), - readerVersion: 1, - principal: 'owner', - sockPath: '/socket', - serverBuildId: 'build' - }) -}) - -it('retains exact preparation across store recreation without claiming process exit', () => { - expect(store().read(request)).toBeNull() - persist() - const record = store().read(request) - expect(record).toEqual({ - version: 1, - prepared: true, - request, - principal: 'principal', - authenticationKind: 'endpoint-credential', - sockPath: '/socket', - serverBuildId: 'build' - }) - expect(Object.isFrozen(record)).toBe(true) - expect(Object.isFrozen(record!.request)).toBe(true) - expect(readdirSync(directory)[0]).not.toContain('secret') -}) - -it('reflushes exact retry after a write published but its return was lost', () => { - const write = secure.writeDurableSecureJsonFile - const spy = vi - .spyOn(secure, 'writeDurableSecureJsonFile') - .mockImplementationOnce((path, record) => { - write(path, record) - throw new Error('return lost') - }) - expect(persist).toThrow('return lost') - expect(store().read(request)?.prepared).toBe(true) - persist() - expect(spy).toHaveBeenCalledTimes(2) -}) - -it('does not overwrite a conflicting principal or operation owner', () => { - persist() - const path = join(directory, readdirSync(directory)[0]) - const original = readFileSync(path, 'utf8') - expect(() => store().persist(request, 'other', 'endpoint-credential', () => {})).toThrow( - 'conflict' - ) - expect(() => - store().persist( - { ...request, ownerLease: 'other' }, - 'principal', - 'endpoint-credential', - () => {} - ) - ).toThrow('conflict') - expect(readFileSync(path, 'utf8')).toBe(original) -}) - -it('refuses a different socket or build even with matching operation identifiers', () => { - persist() - expect(() => - new RelayOwnerResetPreparationJournal(directory, '/other', 'build').read(request) - ).toThrow('conflict') - expect(() => - new RelayOwnerResetPreparationJournal(directory, '/socket', 'other').read(request) - ).toThrow('conflict') -}) - -it('does not grant preparation when the secure write cannot be confirmed', () => { - vi.spyOn(secure, 'writeDurableSecureJsonFile').mockReturnValue(false) - expect(persist).toThrow('write_unconfirmed') - expect(store().read(request)).toBeNull() -}) - -it('preserves corrupt evidence rather than overwriting it on retry', () => { - persist() - const path = join(directory, readdirSync(directory)[0]) - writeFileSync(path, '{broken') - expect(persist).toThrow() - expect(readFileSync(path, 'utf8')).toBe('{broken') -}) - -it('refuses reentrant publication through a second store instance', () => { - const authority = () => expect(persist).toThrow('busy') - store().persist(request, 'principal', 'endpoint-credential', authority) - expect(store().read(request)?.prepared).toBe(true) -}) - -it('checks authority before any write and again after publication', () => { - const write = vi.spyOn(secure, 'writeDurableSecureJsonFile') - expect(() => - store().persist(request, 'principal', 'endpoint-credential', () => { - throw new Error('stale') - }) - ).toThrow('stale') - expect(write).not.toHaveBeenCalled() - let checks = 0 - expect(() => - store().persist(request, 'principal', 'endpoint-credential', () => { - if (++checks === 3) { - throw new Error('stale after write') - } - }) - ).toThrow('stale after write') - expect(store().read(request)?.prepared).toBe(true) -}) diff --git a/src/relay/relay-owner-reset-preparation-journal.ts b/src/relay/relay-owner-reset-preparation-journal.ts deleted file mode 100644 index b22c750caba..00000000000 --- a/src/relay/relay-owner-reset-preparation-journal.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createHash } from 'node:crypto' -import { lstatSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../shared/node-bounded-file-reader' -import { writeDurableSecureJsonFile } from '../shared/secure-file' -import { - parseRelayOwnerResetRequest, - type RelayOwnerResetRequest -} from '../shared/relay-owner-reset-contract' -import { - parseRelayResetPreparationBinding, - parseRelayResetPreparationRecord as parsePreparation, - type RelayResetPreparationRecord as Preparation -} from '../shared/relay-reset-preparation-contract' -const writing = new Set() - -/** Immutable preparation evidence, not evidence that the daemon or its processes exited. */ -export class RelayOwnerResetPreparationJournal { - private readonly directory: string - - constructor( - directory: string, - private readonly sockPath: string, - private readonly serverBuildId: string - ) { - this.directory = resolve(directory) - } - - describe(principal: string, authenticationKind: string) { - return parseRelayResetPreparationBinding({ - version: 1, - journalDirectory: this.directory, - readerVersion: 1, - sockPath: this.sockPath, - serverBuildId: this.serverBuildId, - principal, - authenticationKind - }) - } - - private path(request: RelayOwnerResetRequest): string { - const hash = createHash('sha256') - .update(JSON.stringify([request.runtimeIncarnation, request.operationId])) - .digest('hex') - return join(this.directory, `${hash}.json`) - } - - read(value: RelayOwnerResetRequest): Preparation | null { - const request = parseRelayOwnerResetRequest(value) - const path = this.path(request) - try { - const stat = lstatSync(path) - if (!stat.isFile() || stat.size > 64 * 1024) { - throw new Error('relay_reset_preparation_journal_invalid') - } - const record = parsePreparation( - JSON.parse(readNodeFileSyncWithinLimit(path, 64 * 1024).buffer.toString('utf8')) - ) - if ( - JSON.stringify(record.request) !== JSON.stringify(request) || - record.sockPath !== this.sockPath || - record.serverBuildId !== this.serverBuildId - ) { - throw new Error('relay_reset_preparation_journal_conflict') - } - return record - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return null - } - throw error - } - } - - persist( - request: RelayOwnerResetRequest, - principal: string, - authenticationKind: string, - assertAuthority: () => void - ): undefined { - const record = parsePreparation({ - version: 1, - prepared: true, - request, - principal, - authenticationKind, - sockPath: this.sockPath, - serverBuildId: this.serverBuildId - }) - const path = this.path(record.request) - if (writing.has(path)) { - throw new Error('relay_reset_preparation_journal_busy') - } - writing.add(path) - try { - assertAuthority() - const previous = this.read(record.request) - if (previous && JSON.stringify(previous) !== JSON.stringify(record)) { - throw new Error('relay_reset_preparation_journal_conflict') - } - assertAuthority() - if (!writeDurableSecureJsonFile(path, record)) { - throw new Error('relay_reset_preparation_journal_write_unconfirmed') - } - assertAuthority() - if (JSON.stringify(this.read(record.request)) !== JSON.stringify(record)) { - throw new Error('relay_reset_preparation_journal_write_unconfirmed') - } - } finally { - writing.delete(path) - } - } -} diff --git a/src/relay/relay-owner-reset-registration.test.ts b/src/relay/relay-owner-reset-registration.test.ts deleted file mode 100644 index dc757b4db99..00000000000 --- a/src/relay/relay-owner-reset-registration.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { Server } from 'node:net' -import type { MethodHandler, RequestContext } from './dispatcher' -import { registerRelayOwnerReset } from './relay-owner-reset-registration' -import { parseRelayResetPreparationBinding } from '../shared/relay-reset-preparation-contract' -import { - RELAY_OWNER_RESET_CAPABILITY, - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from '../shared/relay-owner-reset-contract' - -function connection(clientId = 1) { - let callback: Parameters>[0] | undefined - const context: RequestContext = { - clientId, - transportGeneration: clientId, - isStale: () => false, - sessionIdentity: { - principal: 'owner', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' - }, - onResponseSettled: (settle) => { - callback = settle - } - } - return { context, settle: () => callback!({ ok: true }) } -} - -function fixture( - runtimeIncarnation?: string, - persistPrepared?: Parameters[1]['persistPrepared'], - describePreparation?: Parameters[1]['describePreparation'] -) { - const methods = new Map() - const owner = { ownerGeneration: 1, ownerLease: 'lease' } - const owners = { - activeSessionOwner: vi.fn((): typeof owner | null => owner), - assertOwnerPublicationSettled: vi.fn() - } - const lifecycle = { - prepareShutdown: vi.fn(async (_context?: RequestContext, admitted?: () => void) => { - admitted?.() - }), - finishShutdown: vi.fn() - } - const server = { listening: true } - const socket = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub exposes only the listening flag the registration reads. - server: server as Server | null, - ownsCurrentPath: vi.fn(() => true) - } - const status = registerRelayOwnerReset( - { onRequest: (name, handler) => methods.set(name, handler) }, - { owners, lifecycle, socket, runtimeIncarnation, persistPrepared, describePreparation } - ) - const params = { - version: 1, - operationId: 'reset-1', - runtimeIncarnation: status().ownerReset!.runtimeIncarnation, - ...owner - } - const call = (method: string, context: RequestContext) => methods.get(method)!(params, context) - return { methods, owners, lifecycle, server, socket, status, params, call } -} - -it('publishes coordinates bound to the requesting authenticated owner only', () => { - const describe = vi.fn((principal: string, authenticationKind: string) => - parseRelayResetPreparationBinding({ - version: 1, - journalDirectory: '/journal', - sockPath: '/socket', - serverBuildId: 'build', - principal, - authenticationKind - }) - ) - const f = fixture(undefined, () => undefined, describe) - const { context } = connection() - expect(f.status(context).ownerReset?.preparation).toMatchObject({ - principal: 'owner', - journalDirectory: '/journal' - }) - expect(f.status().ownerReset?.preparation).toBeUndefined() - expect(f.status({ ...context, isStale: () => true }).ownerReset?.preparation).toBeUndefined() - expect( - f.status({ ...context, sessionIdentity: undefined }).ownerReset?.preparation - ).toBeUndefined() - describe.mockClear() - f.server.listening = false - expect(f.status(context)).toEqual({ capabilities: [] }) - expect(describe).not.toHaveBeenCalled() -}) - -it('advertises durable preparation only when the journal writer is installed', () => { - expect(fixture().status().capabilities).not.toContain(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) - const f = fixture(undefined, () => undefined) - expect(f.status().capabilities).toContain(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) - f.server.listening = false - expect(f.status().capabilities).not.toContain(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) -}) - -it('registers both operations before advertising a stable incarnation', () => { - const f = fixture() - expect([...f.methods.keys()]).toEqual([ - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD - ]) - expect(f.status()).toEqual({ - capabilities: [RELAY_OWNER_RESET_CAPABILITY], - ownerReset: { version: 1, runtimeIncarnation: expect.any(String) } - }) - expect(f.status()).toEqual(f.status()) - expect(f.params.runtimeIncarnation).not.toBe('') -}) - -it('uses the daemon incarnation shared with network tunnel ownership', () => { - const f = fixture('shared-daemon-incarnation') - expect(f.status().ownerReset?.runtimeIncarnation).toBe('shared-daemon-incarnation') -}) - -it.each(['missing-server', 'not-listening', 'lost-path'])( - 'withdraws capability and refuses initial reset on %s', - async (failure) => { - const f = fixture() - if (failure === 'missing-server') { - f.socket.server = null - } else if (failure === 'not-listening') { - f.server.listening = false - } else { - f.socket.ownsCurrentPath.mockReturnValue(false) - } - expect(f.status()).toEqual({ capabilities: [] }) - await expect(f.call(RELAY_OWNER_RESET_METHOD, connection().context)).rejects.toThrow() - expect(f.lifecycle.prepareShutdown).not.toHaveBeenCalled() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) - -it('reuses the same incarnation and prepared result across authenticated reconnect', async () => { - const f = fixture() - const result = await f.call(RELAY_OWNER_RESET_METHOD, connection().context) - f.owners.activeSessionOwner.mockReturnValue(null) - const recovered = connection(2) - await expect(f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, recovered.context)).resolves.toEqual( - result - ) - expect(f.status().ownerReset!.runtimeIncarnation).toBe(f.params.runtimeIncarnation) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - recovered.settle() - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(1) -}) - -it.each(['initial', 'recovery'] as const)( - 'refuses %s settlement after endpoint ownership disappears', - async (mode) => { - const f = fixture() - const initial = connection() - await f.call(RELAY_OWNER_RESET_METHOD, initial.context) - const response = mode === 'initial' ? initial : connection(2) - if (mode === 'recovery') { - await f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, response.context) - } - f.socket.ownsCurrentPath.mockReturnValue(false) - expect(() => response.settle()).toThrow() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - expect(f.status()).toEqual({ capabilities: [] }) - } -) - -it('refuses prepared recovery when endpoint stops listening and allows exact retry after restoration', async () => { - const f = fixture() - await f.call(RELAY_OWNER_RESET_METHOD, connection().context) - f.server.listening = false - await expect( - f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, connection(2).context) - ).rejects.toThrow() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.server.listening = true - const retry = connection(3) - await f.call(RELAY_PREPARED_RESET_RECOVERY_METHOD, retry.context) - retry.settle() - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(1) -}) diff --git a/src/relay/relay-owner-reset-registration.ts b/src/relay/relay-owner-reset-registration.ts deleted file mode 100644 index ae3b7e6b189..00000000000 --- a/src/relay/relay-owner-reset-registration.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { RelayDispatcher, RequestContext } from './dispatcher' -import type { RelayResetPreparationBinding } from '../shared/relay-reset-preparation-contract' -import type { RelayGraceLifecycle } from './relay-grace-lifecycle' -import type { RelaySocketOwnership } from './relay-socket-ownership' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { RelayOwnerReset } from './relay-owner-reset' -import { - RELAY_OWNER_RESET_CAPABILITY, - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from '../shared/relay-owner-reset-contract' - -export function registerRelayOwnerReset( - dispatcher: Pick, - options: { - runtimeIncarnation?: string - owners: Pick< - SshPtyConsumerSessionAdapter, - 'activeSessionOwner' | 'assertOwnerPublicationSettled' - > - lifecycle: Pick - persistPrepared?: ConstructorParameters[0]['persistPrepared'] - describePreparation?: ( - principal: string, - authenticationKind: string - ) => RelayResetPreparationBinding - socket: Pick - } -): (context?: RequestContext) => { - capabilities: string[] - ownerReset?: { - version: 1 - runtimeIncarnation: string - preparation?: RelayResetPreparationBinding - } -} { - const ownsEndpoint = () => - options.socket.server?.listening === true && options.socket.ownsCurrentPath() - const reset = new RelayOwnerReset({ ...options, ownsEndpoint }) - dispatcher.onRequest(RELAY_OWNER_RESET_METHOD, (params, context) => - reset.prepare(params, context) - ) - dispatcher.onRequest(RELAY_PREPARED_RESET_RECOVERY_METHOD, async (params, context) => - reset.recoverPrepared(params, context) - ) - return (context) => { - if (!ownsEndpoint()) { - return { capabilities: [] } - } - const identity = context?.sessionIdentity - const preparation = - options.persistPrepared && - identity?.authenticated && - identity.allowSessionOwner && - ['launch-nonce', 'endpoint-credential'].includes(identity.authenticationKind) && - !context!.isStale() && - !context!.signal?.aborted - ? options.describePreparation?.(identity.principal, identity.authenticationKind) - : undefined - return ownsEndpoint() - ? { - capabilities: [ - RELAY_OWNER_RESET_CAPABILITY, - ...(options.persistPrepared ? [RELAY_DURABLE_RESET_PREPARATION_CAPABILITY] : []) - ], - ownerReset: { - version: 1, - runtimeIncarnation: reset.runtimeIncarnation, - ...(preparation ? { preparation } : {}) - } - } - : { capabilities: [] } - } -} diff --git a/src/relay/relay-owner-reset.test.ts b/src/relay/relay-owner-reset.test.ts deleted file mode 100644 index 4b7449be98a..00000000000 --- a/src/relay/relay-owner-reset.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { RequestContext } from './dispatcher' -import { RelayOwnerReset } from './relay-owner-reset' - -function fixture( - persistPrepared?: ConstructorParameters[0]['persistPrepared'] -) { - let settlement: Parameters>[0] | undefined - const context: RequestContext = { - clientId: 1, - isStale: () => false, - sessionIdentity: { - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential', - principal: 'owner' - }, - onResponseSettled: (callback) => { - settlement = callback - } - } - const owner = { ownerGeneration: 1, ownerLease: 'lease' } - const owners = { - activeSessionOwner: vi.fn((): typeof owner | null => owner), - assertOwnerPublicationSettled: vi.fn() - } - const lifecycle = { - prepareShutdown: vi.fn(async (_context?: RequestContext, admitted?: () => void) => { - admitted?.() - }), - finishShutdown: vi.fn() - } - const ownsEndpoint = vi.fn(() => true) - const reset = new RelayOwnerReset({ owners, lifecycle, ownsEndpoint, persistPrepared }) - const params = { - version: 1, - operationId: 'reset-1', - runtimeIncarnation: reset.runtimeIncarnation, - ...owner - } - return { - context, - owners, - lifecycle, - ownsEndpoint, - reset, - params, - settle: (ok: boolean) => - settlement?.(ok ? { ok: true } : { ok: false, error: new Error('write failed') }) - } -} - -it('withholds acknowledgment on journal failure and reflushes without repeating preparation', async () => { - const persist = vi - .fn[0]['persistPrepared']>>() - .mockImplementationOnce(() => { - throw new Error('disk unavailable') - }) - .mockReturnValue(undefined) - const f = fixture(persist) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('disk unavailable') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - await expect(f.reset.prepare(f.params, f.context)).resolves.toMatchObject({ prepared: true }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist).toHaveBeenLastCalledWith( - f.params, - 'owner', - 'endpoint-credential', - expect.any(Function) - ) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(1) -}) - -it('prepared continuation also requires a confirmed journal reflush before acknowledgment', async () => { - const persist = vi - .fn[0]['persistPrepared']>>() - .mockReturnValue(undefined) - const f = fixture(persist) - await f.reset.prepare(f.params, f.context) - f.settle(false) - persist.mockImplementationOnce(() => { - throw new Error('journal uncertain') - }) - expect(() => f.reset.recoverPrepared(f.params, { ...f.context, clientId: 2 })).toThrow( - 'journal uncertain' - ) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - expect(f.reset.recoverPrepared(f.params, { ...f.context, clientId: 3 })).toMatchObject({ - prepared: true - }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(1) - expect(persist).toHaveBeenCalledTimes(3) -}) - -it('rejects an asynchronous journal hook rather than acknowledging before durability', async () => { - const write = vi.fn(() => Promise.resolve()) - const f = fixture( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub records writes; the dispatcher only calls it as a function. - write as unknown as NonNullable< - ConstructorParameters[0]['persistPrepared'] - > - ) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('not_synchronous') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it('finishes only after successful preparation and successful response settlement', async () => { - const f = fixture() - const pending = Promise.withResolvers() - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - const result = f.reset.prepare(f.params, f.context) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledWith(f.context, expect.any(Function)) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - pending.resolve() - await expect(result).resolves.toEqual({ - version: 1, - operationId: 'reset-1', - runtimeIncarnation: f.reset.runtimeIncarnation, - prepared: true - }) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.settle(true) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it.each(['unauthenticated', 'subscriber', 'stale', 'endpoint', 'lease', 'callback'])( - 'refuses %s admission before destructive preparation', - async (failure) => { - const f = fixture() - if (failure === 'unauthenticated') { - f.context.sessionIdentity!.authenticated = false - } - if (failure === 'subscriber') { - f.owners.activeSessionOwner.mockReturnValue(null) - } - if (failure === 'stale') { - f.context.isStale = () => true - } - if (failure === 'endpoint') { - f.ownsEndpoint.mockReturnValue(false) - } - if (failure === 'lease') { - f.params.ownerLease = 'another' - } - if (failure === 'callback') { - f.context.onResponseSettled = () => { - throw new Error('registration failed') - } - } - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow() - expect(f.lifecycle.prepareShutdown).not.toHaveBeenCalled() - } -) - -it('does not finish on failed preparation even when its error response is written', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockRejectedValue(new Error('transfer fenced')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('transfer fenced') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it('does not infer successful reset from a lost response', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it('recovers only a retained prepared result on a fresh authenticated transport without ownership admission', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - f.owners.activeSessionOwner.mockReturnValue(null) - const context = { ...f.context, clientId: 2, transportGeneration: 2 } - expect(f.reset.recoverPrepared(f.params, context)).toMatchObject({ prepared: true }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it.each(['missing', 'pending', 'failed'])( - 'refuses %s preparation without invoking cleanup from recovery', - async (state) => { - const f = fixture() - const pending = Promise.withResolvers() - let initial: Promise | undefined - if (state === 'pending') { - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - initial = f.reset.prepare(f.params, f.context) - } else if (state === 'failed') { - f.lifecycle.prepareShutdown.mockImplementationOnce(async (_context, admitted) => { - admitted?.() - throw new Error('failed') - }) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('failed') - } - const calls = f.lifecycle.prepareShutdown.mock.calls.length - expect(() => f.reset.recoverPrepared(f.params, { ...f.context, clientId: 2 })).toThrow( - 'relay_reset_continuation_unauthorized' - ) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(calls) - pending.resolve() - await initial - } -) - -it.each(['incarnation', 'lease', 'principal', 'endpoint', 'unauthenticated'])( - 'rejects changed %s in reconnect continuation', - async (changed) => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - const params = { ...f.params } - if (changed === 'incarnation') { - params.runtimeIncarnation = 'old-process' - } - if (changed === 'lease') { - params.ownerLease = 'wrong' - } - if (changed === 'principal') { - f.context.sessionIdentity!.principal = 'other' - } - if (changed === 'endpoint') { - f.ownsEndpoint.mockReturnValue(false) - } - if (changed === 'unauthenticated') { - f.context.sessionIdentity!.authenticated = false - } - expect(() => f.reset.recoverPrepared(params, { ...f.context, clientId: 2 })).toThrow( - 'relay_reset_continuation_unauthorized' - ) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) - -it('rechecks the recovery transport at response settlement and preserves later recovery', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - let stale = false - f.reset.recoverPrepared(f.params, { ...f.context, clientId: 2, isStale: () => stale }) - stale = true - expect(() => f.settle(true)).toThrow('relay_reset_continuation_unauthorized') - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - f.reset.recoverPrepared(f.params, { ...f.context, clientId: 3 }) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it('replays a prepared same-transport retry without repeating destructive cleanup', async () => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - await expect(f.reset.prepare(f.params, { ...f.context })).resolves.toMatchObject({ - prepared: true - }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledOnce() -}) - -it('refuses pending duplicates without waiting on the first request', async () => { - const f = fixture() - const pending = Promise.withResolvers() - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - const first = f.reset.prepare(f.params, f.context) - await expect(f.reset.prepare(f.params, { ...f.context })).rejects.toThrow( - 'relay_reset_preparation_in_progress' - ) - pending.resolve() - await first - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() -}) - -it.each(['operation', 'transport', 'principal'])( - 'refuses changed %s on prepared replay', - async (changed) => { - const f = fixture() - await f.reset.prepare(f.params, f.context) - f.settle(false) - if (changed === 'operation') { - f.params.operationId = 'different' - } else if (changed === 'transport') { - f.context.transportGeneration = 2 - } else { - f.context.sessionIdentity!.principal = 'different' - } - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow( - 'relay_reset_operation_conflict' - ) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) - -it('retries failed cleanup explicitly instead of replaying it as prepared', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockRejectedValueOnce(new Error('cleanup failed')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('cleanup failed') - await f.reset.prepare(f.params, { ...f.context }) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledTimes(2) -}) - -it('releases an operation refused before admission so a new operation can proceed', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockRejectedValueOnce(new Error('transfer fenced')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('transfer fenced') - f.params.operationId = 'new-reset' - await expect(f.reset.prepare(f.params, f.context)).resolves.toMatchObject({ - operationId: 'new-reset' - }) -}) - -it('retains an admitted operation through cleanup failure and a later pre-admission refusal', async () => { - const f = fixture() - f.lifecycle.prepareShutdown.mockImplementationOnce(async (_context, admitted) => { - admitted?.() - throw new Error('cleanup failed') - }) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('cleanup failed') - f.lifecycle.prepareShutdown.mockRejectedValueOnce(new Error('early retry refusal')) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow('early retry refusal') - await expect( - f.reset.prepare({ ...f.params, operationId: 'different' }, f.context) - ).rejects.toThrow('relay_reset_operation_conflict') - await f.reset.prepare(f.params, f.context) -}) - -it('allows an explicit prepared replay to retry final shutdown after it throws', async () => { - const f = fixture() - f.lifecycle.finishShutdown.mockImplementationOnce(() => { - throw new Error('finish failed') - }) - await f.reset.prepare(f.params, f.context) - expect(() => f.settle(true)).toThrow('finish failed') - await f.reset.prepare(f.params, { ...f.context }) - f.settle(true) - expect(f.lifecycle.finishShutdown).toHaveBeenCalledTimes(2) - expect(f.lifecycle.prepareShutdown).toHaveBeenCalledOnce() -}) - -it('refuses a queued replacement grant before starting shutdown', async () => { - const f = fixture() - f.owners.assertOwnerPublicationSettled.mockImplementation(() => { - throw new Error('pty_consumer_owner_publication_pending') - }) - await expect(f.reset.prepare(f.params, f.context)).rejects.toThrow( - 'pty_consumer_owner_publication_pending' - ) - expect(f.lifecycle.prepareShutdown).not.toHaveBeenCalled() - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() -}) - -it.each(['owner', 'endpoint'])( - 'refuses completion if %s changes during preparation', - async (changed) => { - const f = fixture() - const pending = Promise.withResolvers() - f.lifecycle.prepareShutdown.mockReturnValue(pending.promise) - const result = f.reset.prepare(f.params, f.context) - if (changed === 'owner') { - f.owners.activeSessionOwner.mockReturnValue(null) - } else { - f.ownsEndpoint.mockReturnValue(false) - } - pending.resolve() - await expect(result).rejects.toThrow('relay_reset_unauthorized') - f.settle(true) - expect(f.lifecycle.finishShutdown).not.toHaveBeenCalled() - } -) diff --git a/src/relay/relay-owner-reset.ts b/src/relay/relay-owner-reset.ts deleted file mode 100644 index 829d342e3f3..00000000000 --- a/src/relay/relay-owner-reset.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { RequestContext } from './dispatcher' -import { randomUUID } from 'node:crypto' -import type { RelayGraceLifecycle } from './relay-grace-lifecycle' -import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { - parseRelayOwnerResetRequest, - type RelayOwnerResetRequest, - type RelayOwnerResetAcknowledgment -} from '../shared/relay-owner-reset-contract' - -type ResetOptions = { - runtimeIncarnation?: string - owners: Pick - lifecycle: Pick - ownsEndpoint: () => boolean - persistPrepared?: ( - request: RelayOwnerResetRequest, - principal: string, - authenticationKind: string, - assertAuthority: () => void - ) => undefined -} - -/** Prepared continuation never authorizes new cleanup or ownership admission. */ -export class RelayOwnerReset { - readonly runtimeIncarnation: string - private finished = false - private admitted: { - request: RelayOwnerResetRequest - principal: string - authenticationKind: string - clientId: number - transportGeneration: number | undefined - state: 'pending' | 'prepared' | 'failed' - } | null = null - - constructor(private readonly options: ResetOptions) { - this.runtimeIncarnation = options.runtimeIncarnation ?? randomUUID() - } - - recoverPrepared( - params: Record, - context: RequestContext - ): RelayOwnerResetAcknowledgment { - const request = parseRelayOwnerResetRequest(params) - const expectedIncarnation = request.runtimeIncarnation - const retained = this.admitted - const clientId = context.clientId - const generation = context.transportGeneration - const assertContinuation = (): void => { - const identity = context.sessionIdentity - if ( - !retained || - this.admitted !== retained || - retained.state !== 'prepared' || - expectedIncarnation !== this.runtimeIncarnation || - request.operationId !== retained.request.operationId || - request.ownerGeneration !== retained.request.ownerGeneration || - request.ownerLease !== retained.request.ownerLease || - identity?.authenticated !== true || - identity.allowSessionOwner !== true || - identity.principal !== retained.principal || - identity.authenticationKind !== retained.authenticationKind || - context.isStale() || - context.signal?.aborted || - context.clientId !== clientId || - context.transportGeneration !== generation || - !context.onResponseSettled || - !this.options.ownsEndpoint() - ) { - throw new Error('relay_reset_continuation_unauthorized') - } - } - assertContinuation() - const persisted = this.options.persistPrepared?.( - request, - retained!.principal, - retained!.authenticationKind, - assertContinuation - ) - if (persisted !== undefined) { - throw new Error('relay_reset_preparation_journal_not_synchronous') - } - assertContinuation() - context.onResponseSettled!((settlement) => { - if (!settlement.ok || this.finished) { - return - } - assertContinuation() - this.finish() - }) - assertContinuation() - return { - version: 1, - operationId: request.operationId, - runtimeIncarnation: this.runtimeIncarnation, - prepared: true - } - } - - private finish(): void { - this.finished = true - try { - this.options.lifecycle.finishShutdown() - } catch (error) { - this.finished = false - throw error - } - } - - async prepare( - params: Record, - context: RequestContext - ): Promise { - const request = parseRelayOwnerResetRequest(params) - if (request.runtimeIncarnation !== this.runtimeIncarnation) { - throw new Error('relay_reset_incarnation_mismatch') - } - const principal = context.sessionIdentity?.principal - const authenticationKind = context.sessionIdentity?.authenticationKind - const clientId = context.clientId - const transportGeneration = context.transportGeneration - const assertOwner = (): void => { - const identity = context.sessionIdentity - const owner = this.options.owners.activeSessionOwner(context.clientId) - if ( - context.isStale() || - context.signal?.aborted || - identity?.authenticated !== true || - identity.allowSessionOwner !== true || - !['launch-nonce', 'endpoint-credential'].includes(identity.authenticationKind) || - !principal || - identity.principal !== principal || - identity.authenticationKind !== authenticationKind || - context.clientId !== clientId || - context.transportGeneration !== transportGeneration || - !context.onResponseSettled || - !this.options.ownsEndpoint() || - owner?.ownerGeneration !== request.ownerGeneration || - owner.ownerLease !== request.ownerLease - ) { - throw new Error('relay_reset_unauthorized') - } - } - assertOwner() - const previous = this.admitted - if (previous) { - if ( - previous.request.operationId !== request.operationId || - previous.request.ownerGeneration !== request.ownerGeneration || - previous.request.ownerLease !== request.ownerLease || - previous.principal !== principal || - previous.authenticationKind !== context.sessionIdentity!.authenticationKind || - previous.clientId !== context.clientId || - previous.transportGeneration !== context.transportGeneration - ) { - throw new Error('relay_reset_operation_conflict') - } - if (previous.state === 'pending') { - throw new Error('relay_reset_preparation_in_progress') - } - } - let prepared = false - let preparationRecord: typeof this.admitted = null - context.onResponseSettled!((settlement) => { - if ( - !prepared || - !settlement.ok || - this.finished || - !preparationRecord || - this.admitted !== preparationRecord || - preparationRecord.state !== 'prepared' - ) { - return - } - assertOwner() - this.finish() - }) - assertOwner() - this.options.owners.assertOwnerPublicationSettled() - if (previous?.state !== 'prepared') { - const admitted = previous ?? { - request, - principal: principal!, - authenticationKind: context.sessionIdentity!.authenticationKind, - clientId: context.clientId, - transportGeneration: context.transportGeneration, - state: 'pending' as const - } - this.admitted = admitted - admitted.state = 'pending' - let began = false - try { - await this.options.lifecycle.prepareShutdown(context, () => { - began = true - }) - admitted.state = 'prepared' - } catch (error) { - if (!began && !previous && this.admitted === admitted) { - this.admitted = null - } else { - admitted.state = 'failed' - } - throw error - } - } - assertOwner() - if ( - this.options.persistPrepared?.(request, principal!, authenticationKind!, assertOwner) !== - undefined - ) { - throw new Error('relay_reset_preparation_journal_not_synchronous') - } - assertOwner() - preparationRecord = this.admitted - prepared = true - return { - version: 1, - operationId: request.operationId, - runtimeIncarnation: this.runtimeIncarnation, - prepared: true - } - } -} diff --git a/src/relay/relay-producer-publication-drain.test.ts b/src/relay/relay-producer-publication-drain.test.ts deleted file mode 100644 index 0b1ddbe89a2..00000000000 --- a/src/relay/relay-producer-publication-drain.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' -import { RelayProducerPublicationDrain } from './relay-producer-publication-drain' - -describe('relay producer publication drain', () => { - const dispatchers: RelayDispatcher[] = [] - const setup = (synchronous = false) => { - const writes: ((result: SinkWriteSettlement) => void)[] = [] - const dispatcher = new RelayDispatcher( - (_bytes, settle) => { - writes.push(settle) - if (synchronous) { - settle({ ok: true }) - } - return true - }, - { supportsWriteCallback: true } - ) - dispatchers.push(dispatcher) - const assertAuthority = vi.fn() - const publication = new RelayProducerPublicationDrain(dispatcher, 1, 0, assertAuthority) - return { dispatcher, publication, writes, assertAuthority } - } - afterEach(() => { - for (const dispatcher of dispatchers.splice(0)) { - dispatcher.dispose() - } - }) - - it('waits for all callbacks, including frames published while waiting', async () => { - const { publication, writes } = setup() - publication.publish('tunnel.frame', {}) - const done = vi.fn() - const drain = publication.drain(new AbortController().signal).then(done) - publication.publish('tunnel.frame', {}) - writes[0]({ ok: true }) - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - expect(() => publication.assertDrained()).toThrow('not_drained') - writes[1]({ ok: true }) - await drain - expect(done).toHaveBeenCalledOnce() - }) - - it('accounts before synchronous settlement', async () => { - const { publication } = setup(true) - expect(publication.publish('tunnel.frame', {})).toBe(true) - await publication.drain(new AbortController().signal) - expect(() => publication.assertDrained()).not.toThrow() - }) - - it('aborts only observation and retains an ensuing failure for retry', async () => { - const { publication, writes } = setup() - publication.publish('tunnel.frame', {}) - const controller = new AbortController() - const drain = publication.drain(controller.signal) - controller.abort(new Error('observer cancelled')) - await expect(drain).rejects.toThrow('observer cancelled') - expect(() => publication.assertDrained()).toThrow('not_drained') - writes[0]({ ok: false, error: new Error('lost write') }) - await expect(publication.drain(new AbortController().signal)).rejects.toThrow('lost write') - expect(publication.publish('tunnel.frame', {})).toBe(false) - expect(writes).toHaveLength(1) - }) - - it('can retry an aborted observation after successful write settlement', async () => { - const { publication, writes } = setup() - publication.publish('tunnel.frame', {}) - const controller = new AbortController() - const drain = publication.drain(controller.signal) - controller.abort() - await expect(drain).rejects.toThrow() - writes[0]({ ok: true }) - await publication.drain(new AbortController().signal) - }) - - it('wakes every observer on failure despite outstanding writes', async () => { - const { publication } = setup() - publication.publish('tunnel.frame', {}) - const first = publication.drain(new AbortController().signal) - const second = publication.drain(new AbortController().signal) - publication.fail(new Error('transport lost')) - await expect(first).rejects.toThrow('transport lost') - await expect(second).rejects.toThrow('transport lost') - }) - - it('rejects replacement even when all old writes already completed', () => { - const { publication, dispatcher } = setup(true) - publication.publish('tunnel.frame', {}) - const replacement = vi.fn(() => true) - dispatcher.setWrite(replacement, { supportsWriteCallback: true }) - expect(() => publication.assertDrained()).toThrow('transport_unverifiable') - expect(publication.publish('tunnel.frame', {})).toBe(false) - expect(replacement).not.toHaveBeenCalled() - }) - - it('fails pending writes on replacement and ignores their late success', async () => { - const { publication, dispatcher, writes } = setup() - publication.publish('tunnel.frame', {}) - const drain = publication.drain(new AbortController().signal) - dispatcher.setWrite(() => true, { supportsWriteCallback: true }) - writes[0]({ ok: true }) - await expect(drain).rejects.toThrow('sink replaced') - }) - - it('refuses callback-less sinks, even if they synchronously accept writes', () => { - const dispatcher = new RelayDispatcher(() => true) - dispatchers.push(dispatcher) - expect(() => new RelayProducerPublicationDrain(dispatcher, 1, 0, () => {})).toThrow( - 'write_callback_required' - ) - }) - - it('retains pre-drain serialization failure', async () => { - const { publication, writes } = setup() - const params: Record = {} - params.circular = params - expect(publication.publish('tunnel.frame', params)).toBe(false) - await expect(publication.drain(new AbortController().signal)).rejects.toThrow() - expect(writes).toHaveLength(0) - }) - - it('revalidates generation after serialization before enqueue', () => { - const { publication, dispatcher, writes } = setup() - const replacement = vi.fn(() => true) - expect( - publication.publish('tunnel.frame', { - toJSON: () => { - dispatcher.setWrite(replacement, { supportsWriteCallback: true }) - return {} - } - }) - ).toBe(false) - expect(writes).toHaveLength(0) - expect(replacement).not.toHaveBeenCalled() - expect(() => publication.assertDrained()).toThrow('transport_unverifiable') - }) - - it('revalidates owner at emission and retains its failure', () => { - const { publication, assertAuthority, writes } = setup() - expect( - publication.publish('tunnel.frame', { - toJSON: () => { - assertAuthority.mockImplementation(() => { - throw new Error('owner changed') - }) - return {} - } - }) - ).toBe(false) - expect(writes).toHaveLength(0) - expect(() => publication.assertDrained()).toThrow('owner changed') - }) - - it('refuses queued frames when ownership changes during backpressure', async () => { - let resume: (() => void) | undefined - let ownerCurrent = true - const writes: ((result: SinkWriteSettlement) => void)[] = [] - const dispatcher = new RelayDispatcher( - (_bytes, settle) => { - writes.push(settle) - return false - }, - { - supportsWriteCallback: true, - waitWriteDrain: (callback) => { - resume = callback - } - } - ) - dispatchers.push(dispatcher) - const publication = new RelayProducerPublicationDrain(dispatcher, 1, 0, () => { - if (!ownerCurrent) { - throw new Error('owner replaced while queued') - } - }) - expect(publication.publish('tunnel.frame', { sequence: 1 })).toBe(true) - expect(publication.publish('tunnel.frame', { sequence: 2 })).toBe(true) - expect(writes).toHaveLength(1) - const drain = publication.drain(new AbortController().signal) - ownerCurrent = false - writes[0]({ ok: true }) - expect(resume).toBeTypeOf('function') - resume!() - await expect(drain).rejects.toThrow('owner replaced while queued') - expect(writes).toHaveLength(1) - }) -}) diff --git a/src/relay/relay-producer-publication-drain.ts b/src/relay/relay-producer-publication-drain.ts deleted file mode 100644 index 8ead8ac105d..00000000000 --- a/src/relay/relay-producer-publication-drain.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { TransportPublicationDrain } from '../shared/transport-publication-drain' -import type { RelayDispatcher } from './dispatcher' - -type PublicationDispatcher = Pick< - RelayDispatcher, - 'publishProducerNotification' | 'assertSettledProducerTransport' -> - -/** Local write completion only; the tunnel separately proves downstream consumption. */ -export class RelayProducerPublicationDrain extends TransportPublicationDrain { - constructor( - private readonly dispatcher: PublicationDispatcher, - private readonly clientId: number, - private readonly transportGeneration: number, - assertAuthority: () => void, - onFailure: (error: Error) => void = () => {} - ) { - super(() => { - assertAuthority() - dispatcher.assertSettledProducerTransport(clientId, transportGeneration) - }, onFailure) - } - - publish(method: string, params: Record): boolean { - const settle = this.trackWrite() - try { - this.assertCurrent() - const accepted = this.dispatcher.publishProducerNotification(this.clientId, method, params, { - logDrop: false, - settledTransportGeneration: this.transportGeneration, - isStillAdmitted: () => { - try { - this.assertCurrent() - return true - } catch { - return false - } - }, - onSettled: settle - }) - if (!accepted) { - settle({ ok: false, error: new Error('relay_producer_publication_refused') }) - } - return accepted && !this.failure - } catch (error) { - settle({ ok: false, error: error instanceof Error ? error : new Error(String(error)) }) - return false - } - } -} diff --git a/src/relay/relay-reset-preparation-reader.integration.test.ts b/src/relay/relay-reset-preparation-reader.integration.test.ts deleted file mode 100644 index 10a2cdc8ee6..00000000000 --- a/src/relay/relay-reset-preparation-reader.integration.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { build } from 'esbuild' -import { mkdtempSync, mkdirSync, readdirSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { beforeAll, afterAll, describe, expect, it } from 'vitest' -import { runProcess } from '../shared/child-process/run-process' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' -import { RELAY_RESET_PREPARATION_READ_FLAG } from './relay-reset-preparation-reader' - -let directory: string -let entry: string -let emptyPath: string -let envelope: object -beforeAll(async () => { - directory = mkdtempSync(join(tmpdir(), 'orca-reset-reader-bundle-')) - entry = join(directory, 'relay.js') - emptyPath = join(directory, 'empty-path') - mkdirSync(emptyPath) - const journal = new RelayOwnerResetPreparationJournal( - join(directory, 'journal'), - join(directory, 'never-created.sock'), - 'old-build' - ) - const request = { - version: 1 as const, - operationId: 'reset', - runtimeIncarnation: 'exited-daemon', - ownerGeneration: 1, - ownerLease: 'private-lease' - } - journal.persist(request, 'owner', 'endpoint-credential', () => {}) - envelope = { version: 1, binding: journal.describe('owner', 'endpoint-credential'), request } - await build({ - entryPoints: [join(__dirname, 'relay.ts')], - bundle: true, - platform: 'node', - target: 'node18', - format: 'cjs', - outfile: entry, - external: ['node-pty', '@parcel/watcher', 'electron'], - sourcemap: false - }) -}, 30_000) -afterAll(() => { - if (directory) { - rmSync(directory, { recursive: true, force: true }) - } -}) - -describe('Node built reader', () => { - const program = process.execPath - it('reads retained evidence in a fresh process with an empty PATH and no daemon artifacts', async () => { - const before = readdirSync(directory).sort() - const result = await runProcess({ - program, - args: [entry, RELAY_RESET_PREPARATION_READ_FLAG], - cwd: directory, - input: JSON.stringify(envelope), - timeoutMs: 15_000, - maxOutputBytes: 128 * 1024, - env: { - ...process.env, - PATH: emptyPath, - NODE_PATH: '', - NODE_OPTIONS: '', - ORCA_BACKGROUND_LAUNCH: '1' - } - }) - expect(result.timedOut).toBe(false) - expect(result.code, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toMatchObject({ - version: 1, - preparation: { - prepared: true, - serverBuildId: 'old-build', - request: { runtimeIncarnation: 'exited-daemon' } - } - }) - expect(readdirSync(directory).sort()).toEqual(before) - }, 20_000) - - it('rejects mixed launch modes without starting a daemon', async () => { - const before = readdirSync(directory).sort() - const result = await runProcess({ - program, - args: [entry, '--detached', RELAY_RESET_PREPARATION_READ_FLAG], - cwd: directory, - input: JSON.stringify(envelope), - timeoutMs: 15_000, - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } - }) - expect(result.timedOut).toBe(false) - expect(result.code).toBe(1) - expect(result.stdout).toBe('') - expect(result.stderr).toContain('arguments_invalid') - expect(readdirSync(directory).sort()).toEqual(before) - }, 20_000) -}) diff --git a/src/relay/relay-reset-preparation-reader.test.ts b/src/relay/relay-reset-preparation-reader.test.ts deleted file mode 100644 index 38b66e36b79..00000000000 --- a/src/relay/relay-reset-preparation-reader.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { PassThrough, Readable } from 'node:stream' -import { beforeEach, afterEach, expect, it, vi } from 'vitest' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' -import { - isRelayResetPreparationReadMode, - readRelayResetPreparation, - readRelayResetPreparationStdin, - RELAY_RESET_PREPARATION_READ_FLAG -} from './relay-reset-preparation-reader' - -let directory: string -const request = { - version: 1 as const, - operationId: 'reset', - runtimeIncarnation: 'runtime', - ownerGeneration: 1, - ownerLease: 'private-owner-lease' -} -const journal = () => new RelayOwnerResetPreparationJournal(directory, '/socket', 'build') -const envelope = () => ({ - version: 1, - binding: journal().describe('owner', 'endpoint-credential'), - request -}) -beforeEach(() => { - directory = mkdtempSync(join(tmpdir(), 'orca-reset-read-')) -}) -afterEach(() => { - vi.useRealTimers() - rmSync(directory, { recursive: true, force: true }) -}) - -it('requires an exclusive reader mode and rejects combinations before normal mode selection', () => { - expect( - isRelayResetPreparationReadMode(['bun', 'relay.js', RELAY_RESET_PREPARATION_READ_FLAG]) - ).toBe(true) - expect(isRelayResetPreparationReadMode(['bun', 'relay.js', '--connect'])).toBe(false) - for (const extra of ['--connect', '--detached', '--orca-cli', '--sock-path']) { - expect(() => - isRelayResetPreparationReadMode(['bun', 'relay.js', extra, RELAY_RESET_PREPARATION_READ_FLAG]) - ).toThrow('arguments_invalid') - } -}) - -it('reads preparation after writer recreation without changing journal bytes', async () => { - journal().persist(request, 'owner', 'endpoint-credential', () => {}) - const file = join(directory, readdirSync(directory)[0]) - const before = readFileSync(file, 'utf8') - const output = await readRelayResetPreparationStdin(Readable.from([JSON.stringify(envelope())])) - expect(JSON.parse(output)).toEqual({ version: 1, preparation: journal().read(request) }) - expect(readFileSync(file, 'utf8')).toBe(before) - expect(readdirSync(directory)).toHaveLength(1) -}) - -it('reports missing evidence without creating a directory or inventing preparation', async () => { - const value = envelope() - value.binding = { ...value.binding, journalDirectory: join(directory, 'missing') } - expect(readRelayResetPreparation(value)).toEqual({ version: 1, preparation: null }) - expect(readdirSync(directory)).toEqual([]) -}) - -it('requires the exact recorded principal and owner lease', () => { - journal().persist(request, 'owner', 'endpoint-credential', () => {}) - const value = envelope() - expect(() => - readRelayResetPreparation({ ...value, binding: { ...value.binding, principal: 'other' } }) - ).toThrow('conflict') - expect(() => - readRelayResetPreparation({ ...value, request: { ...request, ownerLease: 'other' } }) - ).toThrow('conflict') -}) - -it('refuses relative journal paths rather than depending on the reader cwd', () => { - const value = envelope() - expect(() => - readRelayResetPreparation({ - ...value, - binding: { ...value.binding, journalDirectory: 'relative' } - }) - ).toThrow('directory_invalid') -}) - -it('bounds stdin and suppresses credential-bearing parse errors', async () => { - await expect( - readRelayResetPreparationStdin(Readable.from([Buffer.alloc(64 * 1024 + 1)])) - ).rejects.toThrow('read_failed') - await expect( - readRelayResetPreparationStdin(Readable.from(['private-owner-lease is invalid JSON'])) - ).rejects.toThrow(/^relay_reset_preparation_read_failed$/) -}) - -it('times out a reader whose input never ends', async () => { - vi.useFakeTimers() - const input = new PassThrough() - const result = readRelayResetPreparationStdin(input) - const rejection = expect(result).rejects.toThrow('read_failed') - await vi.advanceTimersByTimeAsync(10_000) - await rejection - expect(input.destroyed).toBe(true) - expect(vi.getTimerCount()).toBe(0) -}) diff --git a/src/relay/relay-reset-preparation-reader.ts b/src/relay/relay-reset-preparation-reader.ts deleted file mode 100644 index a738b9712cc..00000000000 --- a/src/relay/relay-reset-preparation-reader.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { isAbsolute } from 'node:path' -import type { Readable } from 'node:stream' -import { - parseRelayOwnerResetRequest, - readRelayResetRecord -} from '../shared/relay-owner-reset-contract' -import { - parseRelayResetPreparationBinding, - RELAY_RESET_PREPARATION_READ_FLAG, - validateRelayResetPreparationRecord -} from '../shared/relay-reset-preparation-contract' -import { RelayOwnerResetPreparationJournal } from './relay-owner-reset-preparation-journal' - -export { RELAY_RESET_PREPARATION_READ_FLAG } from '../shared/relay-reset-preparation-contract' -const MAX_INPUT_BYTES = 64 * 1024 - -export function isRelayResetPreparationReadMode(argv: readonly string[]): boolean { - const args = argv.slice(2) - if (!args.includes(RELAY_RESET_PREPARATION_READ_FLAG)) { - return false - } - if (args.length !== 1 || args[0] !== RELAY_RESET_PREPARATION_READ_FLAG) { - throw new Error('relay_reset_preparation_read_arguments_invalid') - } - return true -} - -/** The invoking SSH account supplies OS read authority; no relay session or daemon is started. */ -export function readRelayResetPreparation(value: unknown) { - const envelope = readRelayResetRecord(value) - if (envelope?.version !== 1) { - throw new Error('relay_reset_preparation_read_invalid') - } - const binding = parseRelayResetPreparationBinding(envelope.binding) - const request = parseRelayOwnerResetRequest(envelope.request) - if (!isAbsolute(binding.journalDirectory)) { - throw new Error('relay_reset_preparation_read_directory_invalid') - } - const journal = new RelayOwnerResetPreparationJournal( - binding.journalDirectory, - binding.sockPath, - binding.serverBuildId - ) - const record = journal.read(request) - return { - version: 1 as const, - preparation: record ? validateRelayResetPreparationRecord(record, binding, request) : null - } -} - -export async function readRelayResetPreparationStdin(input: Readable): Promise { - const chunks: Buffer[] = [] - let bytes = 0 - const timeout = setTimeout( - () => input.destroy(new Error('relay_reset_preparation_read_timeout')), - 10_000 - ) - timeout.unref?.() - try { - for await (const value of input) { - const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) - bytes += chunk.length - if (bytes > MAX_INPUT_BYTES) { - throw new Error('relay_reset_preparation_read_too_large') - } - chunks.push(chunk) - } - const request = JSON.parse(Buffer.concat(chunks).toString('utf8')) - return `${JSON.stringify(readRelayResetPreparation(request))}\n` - } catch { - // Parse and filesystem errors can contain credentials from the request or journal. - throw new Error('relay_reset_preparation_read_failed') - } finally { - clearTimeout(timeout) - } -} diff --git a/src/relay/relay-runtime-owned-process-shutdown.test.ts b/src/relay/relay-runtime-owned-process-shutdown.test.ts deleted file mode 100644 index 15bf7c7742f..00000000000 --- a/src/relay/relay-runtime-owned-process-shutdown.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { RelayRuntimeServices } from './relay-runtime-services' - -afterEach(() => vi.restoreAllMocks()) - -function fixture() { - const agents = vi.fn(async () => {}) - const skill = vi.fn(async () => {}) - const vault = vi.fn(async () => {}) - const responses = vi.fn(async () => {}) - const fileStreams = vi.fn(async () => {}) - const watchers = vi.fn(async () => {}) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: disposeOwnedProcesses only reads the owners stubbed here. - const runtime = Object.assign(Object.create(RelayRuntimeServices.prototype), { - agentExecHandler: { dispose: agents }, - skillInstallHandler: { dispose: skill }, - aiVaultService: { dispose: vault }, - responseStreams: { disposeAllAndWait: responses }, - fsHandler: { disposeFileStreams: fileStreams, disposeWatchers: watchers } - }) as RelayRuntimeServices - return { runtime, agents, skill, vault, responses, fileStreams, watchers } -} - -it.each(['agents', 'responses', 'fileStreams', 'watchers'] as const)( - 'does not acknowledge cleanup before %s have settled', - async (owner) => { - const f = fixture() - let release!: () => void - f[owner].mockReturnValue( - new Promise((resolve) => { - release = resolve - }) - ) - const finished = vi.fn() - const shutdown = f.runtime.disposeOwnedProcesses().then(finished) - expect(f[owner]).toHaveBeenCalledOnce() - await vi.waitFor(() => expect(f.vault).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - release() - await shutdown - expect(finished).toHaveBeenCalledOnce() - } -) - -it('rejects stream drain failures after attempting every owner and supports retry', async () => { - const f = fixture() - const responseError = new Error('response drain failed') - const fileError = new Error('file drain failed') - f.responses.mockRejectedValueOnce(responseError) - f.fileStreams.mockRejectedValueOnce(fileError) - await expect(f.runtime.disposeOwnedProcesses()).rejects.toMatchObject({ - message: 'relay_owned_process_shutdown_incomplete', - errors: [responseError, fileError] - }) - expect(f.skill).toHaveBeenCalledOnce() - expect(f.vault).toHaveBeenCalledOnce() - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() -}) - -it('keeps skill and AI Vault cleanup failures log-and-continue', async () => { - const f = fixture() - vi.spyOn(process.stderr, 'write').mockReturnValue(true) - f.skill.mockRejectedValueOnce(new Error('skill cleanup failed')) - f.vault.mockRejectedValueOnce(new Error('vault cleanup failed')) - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() - expect(f.responses).toHaveBeenCalledOnce() - expect(f.fileStreams).toHaveBeenCalledOnce() -}) - -it('handles hosts without a vault service', async () => { - const f = fixture() - Object.assign(f.runtime, { aiVaultService: null }) - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() - expect(f.vault).not.toHaveBeenCalled() -}) - -it('rejects a failed agent or watcher shutdown after cleaning every other owner', async () => { - const f = fixture() - const agentError = new Error('agent cleanup failed') - const watcherError = new Error('watcher cleanup failed') - f.agents.mockRejectedValueOnce(agentError) - f.watchers.mockRejectedValueOnce(watcherError) - await expect(f.runtime.disposeOwnedProcesses()).rejects.toMatchObject({ - message: 'relay_owned_process_shutdown_incomplete', - errors: [agentError, watcherError] - }) - expect(f.skill).toHaveBeenCalledOnce() - expect(f.vault).toHaveBeenCalledOnce() - expect(f.fileStreams).toHaveBeenCalledOnce() - await expect(f.runtime.disposeOwnedProcesses()).resolves.toBeUndefined() -}) diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts index e52fa6afddf..4295014782f 100644 --- a/src/relay/relay-runtime-services.ts +++ b/src/relay/relay-runtime-services.ts @@ -32,8 +32,6 @@ export class RelayRuntimeServices { readonly fsHandler: FsHandler readonly gitHandler: GitHandler readonly skillInstallHandler: SkillInstallHandler - readonly agentExecHandler: AgentExecHandler - private readonly responseStreams: GitResponseStreamRegistry private readonly aiVaultService: ReturnType | null private readonly sessionSearch: { dispose(): void } | null private readonly registeredHandlers: readonly unknown[] @@ -68,7 +66,6 @@ export class RelayRuntimeServices { // so two registries would hand out the same id, and only GitHandler routes the `git.responseAck` // credit every pump waits on. A second registry is not an option — see git-response-stream.ts. const responseStreams = new GitResponseStreamRegistry() - this.responseStreams = responseStreams this.fsHandler = new FsHandler(dispatcher, context, undefined, responseStreams) const watchRegistry = this.fsHandler.getWatchRegistry() this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) @@ -80,7 +77,7 @@ export class RelayRuntimeServices { this.skillInstallHandler = new SkillInstallHandler(dispatcher) const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) const portScanHandler = new PortScanHandler(dispatcher) - this.agentExecHandler = new AgentExecHandler(dispatcher) + const agentExecHandler = new AgentExecHandler(dispatcher) const workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined @@ -106,7 +103,7 @@ export class RelayRuntimeServices { this.skillInstallHandler, externalAutomationsHandler, portScanHandler, - this.agentExecHandler, + agentExecHandler, workspaceSessionHandler, new AiVaultHandler(dispatcher, { hostPlatform, @@ -122,22 +119,7 @@ export class RelayRuntimeServices { this.registerRemoteCliRoutes() } - // Why: the handler work drain ends when a stream's metadata/sentinel is answered; the detached - // pumps and file descriptors behind it are only proven gone by these registry drains. async disposeOwnedProcesses(): Promise { - const failures: unknown[] = [] - const agents = this.agentExecHandler.dispose().catch((error: unknown) => { - failures.push(error) - }) - const responses = this.responseStreams.disposeAllAndWait().catch((error: unknown) => { - failures.push(error) - }) - const fileStreams = this.fsHandler.disposeFileStreams().catch((error: unknown) => { - failures.push(error) - }) - const watchers = this.fsHandler.disposeWatchers().catch((error: unknown) => { - failures.push(error) - }) await this.skillInstallHandler.dispose().catch((error) => { relayLogLine( `[relay] Skill upload cleanup failed: ${error instanceof Error ? error.message : String(error)}` @@ -148,15 +130,6 @@ export class RelayRuntimeServices { `[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}` ) }) - await agents - await responses - await fileStreams - await watchers - // Why: an unclosed fd, watcher child or agent child defers shutdown so the next attempt retries - // it; skill/AI Vault cleanup stays log-and-continue until a later T2 slice. - if (failures.length > 0) { - throw new AggregateError(failures, 'relay_owned_process_shutdown_incomplete') - } } disposeHandlers(): void { diff --git a/src/relay/relay-watcher-process-pool.ts b/src/relay/relay-watcher-process-pool.ts index 0ff1fe1a59e..e0093b9a166 100644 --- a/src/relay/relay-watcher-process-pool.ts +++ b/src/relay/relay-watcher-process-pool.ts @@ -5,8 +5,7 @@ import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-sup export type RelayWatcherProcessPool = Pick< RuntimeWatcherProcessPool, 'dispose' | 'forgetRoot' | 'subscribe' -> & - Partial> +> export function getRelayWatcherProcessEntryPath(): string { return join(__dirname, 'relay-watcher.js') diff --git a/src/relay/relay-watcher-setup-wait.ts b/src/relay/relay-watcher-setup-wait.ts index f09dfb7470b..a4c7b348b02 100644 --- a/src/relay/relay-watcher-setup-wait.ts +++ b/src/relay/relay-watcher-setup-wait.ts @@ -1,55 +1,5 @@ import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' -import type { RequestContext } from './dispatcher' -import type { RelayWatcherTeardownState } from './relay-watcher-teardown-tracker' - -export async function startInitialRelayWatch( - state: RelayWatcherTeardownState, - subscribe: () => Promise, - emitOverflow: () => void, - close: () => Promise -): Promise { - try { - await subscribe() - } catch (firstError) { - if (!state.closed && shouldRetryInitialRelayWatch(firstError)) { - try { - await subscribe() - emitOverflow() - return - } catch (quarantineError) { - void close().catch(() => {}) - throw quarantineError - } - } - void close().catch(() => {}) - throw firstError - } -} - -export async function awaitRelayWatcherSetupForClient( - state: RelayWatcherTeardownState, - context: RequestContext | undefined, - releaseClient: () => void -): Promise { - try { - await awaitRelayWatcherSetup(state.setupWaiters, context?.signal) - } catch (error) { - releaseClient() - const expectedAbort = - (error instanceof Error && error.name === 'AbortError') || - (isWatcherProcessFailure(error) && error.code === 'subscribe_aborted') - if (expectedAbort) { - return - } - const message = error instanceof Error ? error.message : String(error) - process.stderr.write(`[relay] File watcher not available for ${state.rootPath}: ${message}\n`) - throw error - } - if (context?.isStale()) { - releaseClient() - } -} export function shouldRetryInitialRelayWatch(error: unknown): boolean { return ( diff --git a/src/relay/relay-watcher-shutdown.test.ts b/src/relay/relay-watcher-shutdown.test.ts deleted file mode 100644 index 9cf371969b2..00000000000 --- a/src/relay/relay-watcher-shutdown.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import type { RelayDispatcher } from './dispatcher' -import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' - -function fixture() { - const unsubscribe = vi.fn(async () => {}) - const pool = { - subscribe: vi.fn(async () => ({ unsubscribe })), - forgetRoot: vi.fn(), - dispose: vi.fn(), - disposeAndWait: vi.fn(async () => {}) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: shutdown paths never reach the dispatcher. - const registry = new RelayFilesystemWatchRegistry({} as RelayDispatcher, pool) - const root = join(tmpdir(), 'orca-watcher-shutdown-fixture') - return { registry, pool, unsubscribe, root } -} - -it('fences new watches synchronously and joins an in-flight native unsubscribe', async () => { - const f = fixture() - await f.registry.watch(f.root) - const pending = Promise.withResolvers() - f.unsubscribe.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - const duplicate = f.registry.closeWatchesAndWait() - await expect(f.registry.watch(f.root)).rejects.toThrow('relay_watcher_shutdown_fenced') - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - expect(f.unsubscribe).toHaveBeenCalledOnce() - expect(f.pool.dispose).not.toHaveBeenCalled() - pending.resolve() - await Promise.all([shutdown, duplicate]) - expect(f.pool.forgetRoot).toHaveBeenCalledWith(f.root) -}) - -it('retains failed teardown and retries without admitting replacement watches', async () => { - const f = fixture() - await f.registry.watch(f.root) - const failure = new Error('native close failed') - f.unsubscribe.mockRejectedValueOnce(failure) - await expect(f.registry.closeWatchesAndWait()).rejects.toMatchObject({ - message: 'relay_watcher_shutdown_incomplete', - errors: [failure] - }) - expect(f.pool.forgetRoot).not.toHaveBeenCalled() - await expect(f.registry.watch(f.root)).rejects.toThrow('relay_watcher_shutdown_fenced') - await f.registry.closeWatchesAndWait() - expect(f.unsubscribe).toHaveBeenCalledTimes(2) - expect(f.pool.forgetRoot).toHaveBeenCalledOnce() -}) - -it('waits for a late subscription and its unsubscribe after setup has begun', async () => { - const f = fixture() - const setup = Promise.withResolvers<{ unsubscribe: () => Promise }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only unsubscribe from a subscription. - f.pool.subscribe.mockReturnValue(setup.promise as ReturnType) - const watch = f.registry.watch(f.root) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - setup.resolve({ unsubscribe: f.unsubscribe }) - await vi.waitFor(() => expect(f.unsubscribe).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - close.resolve() - await Promise.all([watch, shutdown]) - expect(f.pool.subscribe).toHaveBeenCalledOnce() -}) - -it('joins teardown already started by client unwatch', async () => { - const f = fixture() - await f.registry.watch(f.root) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - f.registry.unwatch(f.root) - const finished = vi.fn() - const shutdown = f.registry.closeWatchesAndWait().then(finished) - await new Promise((resolve) => setImmediate(resolve)) - expect(finished).not.toHaveBeenCalled() - close.resolve() - await shutdown - expect(f.unsubscribe).toHaveBeenCalledOnce() -}) - -it('retains a late subscription whose cleanup fails instead of treating it as failed setup', async () => { - const f = fixture() - const setup = Promise.withResolvers<{ unsubscribe: () => Promise }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only unsubscribe from a subscription. - f.pool.subscribe.mockReturnValue(setup.promise as ReturnType) - const failure = new Error('late native close failed') - f.unsubscribe.mockRejectedValueOnce(failure) - const watch = f.registry.watch(f.root).catch((error: unknown) => error) - const shutdown = f.registry.closeWatchesAndWait().catch((error: unknown) => error) - setup.resolve({ unsubscribe: f.unsubscribe }) - expect(await watch).toBe(failure) - expect(await shutdown).toMatchObject({ - message: 'relay_watcher_shutdown_incomplete', - errors: [failure] - }) - expect(f.pool.forgetRoot).not.toHaveBeenCalled() - await f.registry.closeWatchesAndWait() - expect(f.unsubscribe).toHaveBeenCalledTimes(2) - expect(f.pool.forgetRoot).toHaveBeenCalledOnce() -}) - -it('does not publish a replacement watch when its predecessor closes after shutdown fencing', async () => { - const f = fixture() - await f.registry.watch(f.root) - const close = Promise.withResolvers() - f.unsubscribe.mockReturnValue(close.promise) - f.registry.unwatch(f.root) - const replacement = f.registry.watch(f.root).catch((error: unknown) => error) - const shutdown = f.registry.closeWatchesAndWait() - close.resolve() - expect(await replacement).toMatchObject({ message: 'relay_watcher_shutdown_fenced' }) - await shutdown - expect(f.pool.subscribe).toHaveBeenCalledOnce() -}) - -it('full watcher disposal waits for physical pool exit after native unsubscribe completes', async () => { - const f = fixture() - await f.registry.watch(f.root) - const pending = Promise.withResolvers() - f.pool.disposeAndWait.mockReturnValue(pending.promise) - const finished = vi.fn() - const shutdown = f.registry.disposeAndWait().then(finished) - await vi.waitFor(() => expect(f.pool.forgetRoot).toHaveBeenCalledOnce()) - expect(finished).not.toHaveBeenCalled() - pending.resolve() - await shutdown -}) - -it('does not acknowledge a failed pool exit after native subscriptions close', async () => { - const f = fixture() - await f.registry.watch(f.root) - const error = new Error('pool exit unproven') - f.pool.disposeAndWait.mockRejectedValueOnce(error) - await expect(f.registry.disposeAndWait()).rejects.toMatchObject({ errors: [error] }) - await f.registry.disposeAndWait() -}) diff --git a/src/relay/relay-watcher-shutdown.ts b/src/relay/relay-watcher-shutdown.ts deleted file mode 100644 index bd0963adc52..00000000000 --- a/src/relay/relay-watcher-shutdown.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' -import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' -import type { - RelayWatcherTeardownState, - RelayWatcherTeardownTracker -} from './relay-watcher-teardown-tracker' - -export async function disposeRelayWatchesAndWait( - closeWatches: () => Promise, - pool: RelayWatcherProcessPool -): Promise { - const close = closeWatches() - const children = Promise.resolve().then(() => { - if (!pool.disposeAndWait) { - throw new Error('relay_watcher_pool_awaited_disposal_unavailable') - } - return pool.disposeAndWait() - }) - const results = await Promise.allSettled([close, children]) - const failures = results.filter((result) => result.status === 'rejected') - if (failures.length > 0) { - throw new AggregateError( - failures.map((failure) => failure.reason), - 'relay_watcher_shutdown_incomplete' - ) - } -} - -export async function closeRelayWatchesAndWait( - watches: ReadonlyMap, - pendingSetups: ReadonlyMap, - tracker: RelayWatcherTeardownTracker, - close: (state: RelayWatcherTeardownState) => Promise -): Promise { - const states = new Set(watches.values()) - for (const root of tracker.rootPaths()) { - const failed = tracker.failedState(root) - if (failed) { - states.add(failed) - } - } - const closures = Promise.allSettled([...states].map(close)) - // Setup refusal is expected after fencing; retained teardown failures remain authoritative. - await Promise.allSettled([...pendingSetups.values()].map((setup) => setup.promise)) - const results = await closures - // Why the fallback: join answers undefined for a root with nothing left in flight. - const remaining = await Promise.allSettled( - tracker.rootPaths().map((root) => tracker.join(root) ?? Promise.resolve()) - ) - const failures = [...results, ...remaining].filter((result) => result.status === 'rejected') - if (failures.length > 0) { - throw new AggregateError( - [...new Set(failures.map((failure) => failure.reason))], - 'relay_watcher_shutdown_incomplete' - ) - } -} diff --git a/src/relay/relay-watcher-teardown-tracker.ts b/src/relay/relay-watcher-teardown-tracker.ts index d9feff34b9f..785e4dd16ea 100644 --- a/src/relay/relay-watcher-teardown-tracker.ts +++ b/src/relay/relay-watcher-teardown-tracker.ts @@ -47,7 +47,7 @@ export class RelayWatcherTeardownTracker { }, (error) => { const physicalExit = isWatcherProcessFailure(error) ? error.physicalExit : undefined - if (!subscription && !state.subscription && !physicalExit) { + if (!subscription && !physicalExit) { this.failed.delete(state.rootKey) this.forgetRoot(state.rootPath) return diff --git a/src/relay/relay-work-admission.test.ts b/src/relay/relay-work-admission.test.ts deleted file mode 100644 index d5925953b11..00000000000 --- a/src/relay/relay-work-admission.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RequestContext } from './dispatcher-contract' -import { RelayWorkAdmission } from './relay-work-admission' -import { SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD } from '../shared/skill-ssh-relay-contract' - -function context(): RequestContext { - return { clientId: 1, isStale: () => false } -} - -function pendingOperation() { - let resolve!: () => void - const promise = new Promise((settle) => { - resolve = settle - }) - return { promise, resolve } -} - -async function nextTurn(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('RelayWorkAdmission', () => { - it('starts admitted operations synchronously and preserves their result', async () => { - const admission = new RelayWorkAdmission() - const operation = vi.fn(() => 'started') - const result = admission.run('fs.writeFile', context(), operation) - expect(operation).toHaveBeenCalledOnce() - await expect(result).resolves.toBe('started') - }) - - it('closes admission synchronously and drains existing work despite abort and disconnect', async () => { - const admission = new RelayWorkAdmission() - const controller = new AbortController() - let disconnected = false - const mutation = pendingOperation() - const running = admission.run( - 'fs.writeFile', - { ...context(), signal: controller.signal, isStale: () => disconnected }, - () => mutation.promise - ) - const drained = vi.fn() - const drain = admission.beginDrain().then(drained) - const rejectedOperation = vi.fn() - await expect(admission.run('fs.writeFile', context(), rejectedOperation)).rejects.toThrow( - 'relay_work_admission_closed' - ) - expect(rejectedOperation).not.toHaveBeenCalled() - controller.abort() - disconnected = true - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await running - await drain - expect(drained).toHaveBeenCalledOnce() - }) - - it.each([ - 'relay.status', - 'fs.unwatchAndWait', - 'agent.cancelExec', - SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD - ])('admits cleanup request %s while draining', async (method) => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('git.diff', context(), () => mutation.promise) - const drain = admission.beginDrain() - await expect( - admission.run(method, context(), () => { - mutation.resolve() - return 'acknowledged' - }) - ).resolves.toBe('acknowledged') - await running - await drain - }) - - it.each([ - 'rpc.cancel', - 'git.responseAck', - 'git.cancelResponseStream', - 'fs.streamAck', - 'fs.cancelStream', - 'fs.unwatch', - 'pty.ackData', - 'pty.setDeliveryPaused' - ])('admits cleanup notification %s while draining', async (method) => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('git.diff', context(), () => mutation.promise) - const drain = admission.beginDrain() - const acknowledge = vi.fn(() => mutation.resolve()) - admission.runNotification(method, context(), acknowledge) - expect(acknowledge).toHaveBeenCalledOnce() - await running - await drain - await expect(admission.run('git.diff', context(), () => {})).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) - - it('excludes the exact reset request without excluding another request from the same client', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('fs.writeFile', context(), () => mutation.promise) - const resetContext = context() - const drained = vi.fn() - const reset = admission.run('relay.reset', resetContext, async () => { - await admission.beginDrain(resetContext) - drained() - }) - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await running - await reset - expect(drained).toHaveBeenCalledOnce() - }) - - it('rejects a forged context without closing admission', async () => { - const admission = new RelayWorkAdmission() - const actualContext = context() - await admission.run('relay.reset', actualContext, async () => { - await expect(admission.beginDrain({ ...actualContext })).rejects.toThrow( - 'relay_work_drain_context_not_active' - ) - await expect(admission.run('fs.writeFile', context(), () => 'open')).resolves.toBe('open') - }) - }) - - it('rejects an already-settled request context without closing admission', async () => { - const admission = new RelayWorkAdmission() - const oldContext = context() - await admission.run('relay.reset', oldContext, () => {}) - await expect(admission.beginDrain(oldContext)).rejects.toThrow( - 'relay_work_drain_context_not_active' - ) - await expect(admission.run('fs.writeFile', context(), () => 'open')).resolves.toBe('open') - }) - - it('waits for reset work when the host lifecycle supplies no exclusion', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const reset = admission.run('relay.reset', context(), () => mutation.promise) - const drained = vi.fn() - const drain = admission.beginDrain().then(drained) - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await reset - await drain - expect(drained).toHaveBeenCalledOnce() - }) - - it('drops new mutation notifications without invoking their handlers during drain', async () => { - const admission = new RelayWorkAdmission() - await admission.beginDrain() - const mutate = vi.fn() - admission.runNotification('pty.write', context(), mutate) - expect(mutate).not.toHaveBeenCalled() - await expect(admission.run('pty.ackData', context(), mutate)).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) - - it('tracks asynchronous notifications through settlement while draining', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const operation = vi.fn(async () => mutation.promise) - admission.runNotification('pty.write', context(), operation) - expect(operation).toHaveBeenCalledOnce() - const drained = vi.fn() - const drain = admission.beginDrain().then(drained) - await nextTurn() - expect(drained).not.toHaveBeenCalled() - mutation.resolve() - await drain - expect(drained).toHaveBeenCalledOnce() - }) - - it('releases tracking after synchronous notification failure', async () => { - const admission = new RelayWorkAdmission() - expect(() => - admission.runNotification('pty.write', context(), () => { - throw new Error('notification_failed') - }) - ).toThrow('notification_failed') - await admission.beginDrain() - const mutate = vi.fn() - admission.runNotification('pty.write', context(), mutate) - expect(mutate).not.toHaveBeenCalled() - }) - - it('settles concurrent drains after an admitted operation fails without reopening', async () => { - const admission = new RelayWorkAdmission() - const mutation = pendingOperation() - const running = admission.run('fs.writeFile', context(), async () => { - await mutation.promise - throw new Error('write_failed') - }) - const failure = expect(running).rejects.toThrow('write_failed') - const firstDrain = admission.beginDrain() - const secondDrain = admission.beginDrain() - mutation.resolve() - await failure - await Promise.all([firstDrain, secondDrain]) - await expect(admission.run('fs.writeFile', context(), () => {})).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) - - it('turns synchronous operation failures into rejected promises and releases tracking', async () => { - const admission = new RelayWorkAdmission() - const result = admission.run('fs.writeFile', context(), () => { - throw new Error('write_failed') - }) - await expect(result).rejects.toThrow('write_failed') - await admission.beginDrain() - await expect(admission.run('fs.writeFile', context(), () => {})).rejects.toThrow( - 'relay_work_admission_closed' - ) - }) -}) diff --git a/src/relay/relay-work-admission.ts b/src/relay/relay-work-admission.ts deleted file mode 100644 index 2bf2ecdab4e..00000000000 --- a/src/relay/relay-work-admission.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { RequestContext } from './dispatcher-contract' -import { allowsRelayWorkDuringDrain } from '../shared/relay-work-drain-contract' - -/** Handler drain is not proof of physical process exit or detached producer cleanup. */ -export class RelayWorkAdmission { - private draining = false - private readonly active = new Map }>() - - allows(method: string, notification = false): boolean { - return !this.draining || allowsRelayWorkDuringDrain(method, notification) - } - - // Why not async: an async wrapper adds microtask turns to every relay handler's response. - run(method: string, context: RequestContext, operation: () => T | Promise): Promise { - if (!this.allows(method)) { - return Promise.reject(new Error('relay_work_admission_closed')) - } - const finish = this.trackEntry(context) - let result: Promise - try { - const value = operation() - result = value instanceof Promise ? value : Promise.resolve(value) - } catch (error) { - finish() - return Promise.reject(error) - } - result.then(finish, finish) - return result - } - - runNotification(method: string, context: RequestContext, operation: () => void): void { - if (!this.allows(method, true)) { - return - } - const finish = this.trackEntry(context) - try { - const result = operation() - void Promise.resolve(result).then(finish, (error) => { - finish() - process.stderr.write(`[relay] Notification handler failed: ${String(error)}\n`) - }) - } catch (error) { - finish() - throw error - } - } - - assertActiveContext(context: RequestContext): void { - if (![...this.active.values()].some((entry) => entry.context === context)) { - throw new Error('relay_work_drain_context_not_active') - } - } - - /** Only the actual initiating request context may be excluded from its own drain. */ - async beginDrain(exclude?: RequestContext): Promise { - if (exclude) { - this.assertActiveContext(exclude) - } - this.draining = true - for (;;) { - const pending = [...this.active.values()].filter((entry) => entry.context !== exclude) - if (pending.length === 0) { - return - } - await Promise.all(pending.map((entry) => entry.done)) - } - } - - // Why: no Promise.withResolvers — the legacy relay bundle still targets Node 18 hosts. - private trackEntry(context: RequestContext): () => void { - const key = {} - let resolve!: () => void - const done = new Promise((settle) => { - resolve = settle - }) - this.active.set(key, { context, done }) - return () => { - this.active.delete(key) - resolve() - } - } -} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 1ed674057f1..e986a19b709 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -11,10 +11,6 @@ import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' import { runRelayRuntimeSelfTestCommand } from './relay-runtime-self-test' import { runRelayWindowsBreakawayLaunchIfRequested } from './relay-windows-breakaway-launch' import { RELAY_RUNTIME_SELF_TEST_FLAG } from '../shared/relay-runtime-self-test-report' -import { - isRelayResetPreparationReadMode, - readRelayResetPreparationStdin -} from './relay-reset-preparation-reader' async function main(): Promise { const selfTestFlag = process.argv.indexOf(RELAY_RUNTIME_SELF_TEST_FLAG) @@ -25,11 +21,6 @@ async function main(): Promise { if (runRelayWindowsBreakawayLaunchIfRequested(process.argv)) { return } - // A read-only exec: the SSH account's own file access is the authority; no daemon starts. - if (isRelayResetPreparationReadMode(process.argv)) { - process.stdout.write(await readRelayResetPreparationStdin(process.stdin)) - return - } const options = parseRelayLaunchOptions(process.argv) if (options.connectMode) { runRelayConnectChannel(options.sockPath, readRelayEndpointCredential(options.credentialFile)) diff --git a/src/relay/ssh-pty-consumer-resume-client.test.ts b/src/relay/ssh-pty-consumer-resume-client.test.ts deleted file mode 100644 index d2b015f9214..00000000000 --- a/src/relay/ssh-pty-consumer-resume-client.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' -import { encodeJsonRpcFrame } from './protocol' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' - -const endpointIdentity: RelayClientSessionIdentity = { - principal: 'endpoint-principal', - authenticated: true, - allowSessionOwner: true, - authenticationKind: 'endpoint-credential' -} - -function frame(id: number, method: string, overrides: Record = {}): Buffer { - return encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id, - method, - params: { - protocolVersion: 1, - clientInstanceId: 'stable-client', - requestedRole: 'session-owner', - ...overrides - } - }, - id, - 0 - ) -} - -function response(buffer: Buffer) { - return JSON.parse(buffer.subarray(13, 13 + buffer.readUInt32BE(9)).toString('utf8')) -} - -async function flushRequests(): Promise { - await new Promise((resolve) => setImmediate(resolve)) -} - -describe('pty.resumeClient through RelayDispatcher', () => { - let dispatcher: RelayDispatcher | undefined - afterEach(() => dispatcher?.dispose()) - - function fixture() { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, settled) => { - writes.push(Buffer.from(data)) - settled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - return { dispatcher, adapter, writes } - } - - it('refuses absent historical ownership without minting a fresh owner or reserving the connection', async () => { - const { dispatcher, adapter, writes } = fixture() - const resume = { ownerGeneration: 1, ownerLease: 'forgotten-lease' } - dispatcher.feed(frame(1, 'pty.resumeClient', { resume })) - await flushRequests() - expect(response(writes[0]).error.message).toContain('pty_consumer_resume_owner_missing') - expect(adapter.activeSessionOwner(1)).toBeNull() - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - dispatcher.feed(frame(2, 'pty.openClient', { resume })) - await flushRequests() - expect(response(writes[1]).result).toMatchObject({ - role: 'session-owner', - clientGeneration: 1, - ownerGeneration: 1, - resumed: false - }) - expect(adapter.activeSessionOwner(1)).not.toBeNull() - }) - - it.each([true, false])( - 'settles exact-owner resume authority only on response publication (success=%s)', - async (ok) => { - const { dispatcher, adapter, writes } = fixture() - dispatcher.feed(frame(1, 'pty.openClient')) - await flushRequests() - const grant = response(writes[0]).result - const incumbent = adapter.activeSessionOwner(1) - expect(incumbent).not.toBeNull() - const resumedWrites: Buffer[] = [] - const settlements: ((result: { ok: true } | { ok: false; error: Error }) => void)[] = [] - const successor = dispatcher.attachClient( - (data, settled) => { - resumedWrites.push(Buffer.from(data)) - settlements.push(settled) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const release = vi.spyOn(dispatcher, 'releaseDisplacedClient') - dispatcher.feedClient( - successor, - frame(2, 'pty.resumeClient', { - resume: { ownerGeneration: grant.ownerGeneration, ownerLease: grant.ownerLease } - }) - ) - await flushRequests() - expect(response(resumedWrites[0]).result).toMatchObject({ - role: 'session-owner', - resumed: true, - ownerGeneration: 2, - ownerLease: grant.ownerLease - }) - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(adapter.activeSessionOwner(successor)).toBeNull() - expect(release).not.toHaveBeenCalled() - expect(() => adapter.assertOwnerPublicationSettled()).toThrow( - 'pty_consumer_owner_publication_pending' - ) - settlements[0](ok ? { ok: true } : { ok: false, error: new Error('response failed') }) - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - if (ok) { - expect(adapter.activeSessionOwner(1)).toBeNull() - expect(adapter.activeSessionOwner(successor)).not.toBeNull() - expect(release).toHaveBeenCalledTimes(1) - } else { - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(adapter.activeSessionOwner(successor)).toBeNull() - expect(release).not.toHaveBeenCalled() - } - release.mockRestore() - } - ) -}) diff --git a/src/relay/ssh-pty-consumer-session-adapter.test.ts b/src/relay/ssh-pty-consumer-session-adapter.test.ts index 262fe76a82a..7c38799b876 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.test.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.test.ts @@ -61,60 +61,6 @@ describe('SshPtyConsumerSessionAdapter', () => { vi.useRealTimers() }) - it('rolls back replacement ownership when response callback registration throws', async () => { - const writes: Buffer[] = [] - dispatcher = new RelayDispatcher( - (data, onSettled) => { - writes.push(Buffer.from(data)) - onSettled({ ok: true }) - return true - }, - { supportsWriteCallback: true }, - endpointIdentity - ) - const registration = vi.spyOn(dispatcher, 'onRequest') - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') - const openClient = registration.mock.calls.find(([method]) => method === 'pty.openClient')![1] - registration.mockRestore() - dispatcher.feed(openFrame(1)) - await flushRequests() - const grant = responseResult(writes[0]) - const incumbent = adapter.activeSessionOwner(1) - expect(incumbent).not.toBeNull() - const replacement = dispatcher.attachClient(() => true, {}, endpointIdentity) - const closeIncumbent = vi.spyOn(dispatcher, 'releaseDisplacedClient') - const params = { - protocolVersion: 1, - clientInstanceId: 'client-1', - requestedRole: 'session-owner', - resume: { ownerGeneration: grant.ownerGeneration, ownerLease: grant.ownerLease } - } - await expect( - openClient(params, { - clientId: replacement, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: () => { - throw new Error('registration failed') - } - }) - ).rejects.toThrow('registration failed') - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(adapter.activeSessionOwner(replacement)).toBeNull() - expect(closeIncumbent).not.toHaveBeenCalled() - await openClient(params, { - clientId: replacement, - isStale: () => false, - sessionIdentity: endpointIdentity, - onResponseSettled: (settle) => settle({ ok: false, error: new Error('cancel retry') }) - }) - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() - expect(adapter.activeSessionOwner(1)).toEqual(incumbent) - expect(closeIncumbent).not.toHaveBeenCalled() - closeIncumbent.mockRestore() - }) - it('does not activate owner authority until the grant write settles', async () => { const firstWrites: Buffer[] = [] const firstSettlements: ((result: { ok: true } | { ok: false; error: Error }) => void)[] = [] @@ -127,13 +73,10 @@ describe('SshPtyConsumerSessionAdapter', () => { { supportsWriteCallback: true }, endpointIdentity ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') dispatcher.feed(openFrame(1)) await flushRequests() - expect(() => adapter.assertOwnerPublicationSettled()).toThrow( - 'pty_consumer_owner_publication_pending' - ) const secondWrites: Buffer[] = [] const secondId = dispatcher.attachClient( @@ -159,7 +102,6 @@ describe('SshPtyConsumerSessionAdapter', () => { code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR }) firstSettlements[0]({ ok: true }) - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() }) it('rolls back owner election when the grant write fails', async () => { @@ -171,10 +113,9 @@ describe('SshPtyConsumerSessionAdapter', () => { { supportsWriteCallback: true }, endpointIdentity ) - const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') dispatcher.feed(openFrame(1)) await flushRequests() - expect(() => adapter.assertOwnerPublicationSettled()).not.toThrow() const retryWrites: Buffer[] = [] const retryId = dispatcher.attachClient( diff --git a/src/relay/ssh-pty-consumer-session-adapter.ts b/src/relay/ssh-pty-consumer-session-adapter.ts index e7b26689723..906edd47ff8 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.ts @@ -1,6 +1,5 @@ import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - PTY_CONSUMER_RESUME_CLIENT_METHOD, PtyConsumerSession, type PtyConsumerSessionAdmission, type PtyConsumerSessionGrant @@ -28,7 +27,6 @@ export class SshPtyConsumerSessionAdapter { private readonly session: PtyConsumerSession private readonly sourceCredit: SshPtySourceCreditAdapter private readonly pausedDeliveryByPty = new Map() - private readonly pendingPublications = new Set() constructor( private readonly dispatcher: RelayDispatcher, @@ -61,9 +59,6 @@ export class SshPtyConsumerSessionAdapter { dispatcher.onRequest(SSH_PTY_OPEN_CLIENT_METHOD, (params, context) => this.openClient(params, context) ) - dispatcher.onRequest(PTY_CONSUMER_RESUME_CLIENT_METHOD, (params, context) => - this.openClient(params, context, true) - ) dispatcher.onClientDetached((clientId, cause) => { const connectionKey = String(clientId) const grant = this.session.activeGrant(connectionKey) @@ -219,35 +214,9 @@ export class SshPtyConsumerSessionAdapter { return sshPtyDeliveryMode(this.session.activeGrant(String(clientId))) } - activeSessionOwner( - clientId: number - ): Readonly<{ ownerGeneration: number; ownerLease: string }> | null { - const grant = this.session.activeGrant(String(clientId)) - const ownerGeneration = grant?.ownerGeneration - if ( - grant?.role !== 'session-owner' || - typeof ownerGeneration !== 'number' || - !Number.isSafeInteger(ownerGeneration) || - !grant.ownerLease - ) { - return null - } - return Object.freeze({ - ownerGeneration, - ownerLease: grant.ownerLease - }) - } - - assertOwnerPublicationSettled(): void { - if (this.pendingPublications.size > 0) { - throw new Error('pty_consumer_owner_publication_pending') - } - } - private async openClient( rawParams: Record, - context: RequestContext, - resumeOnly = false + context: RequestContext ): Promise { const params = parseOpenClientParams(rawParams) if (params.protocolVersion !== PTY_CONSUMER_SESSION_PROTOCOL_VERSION) { @@ -256,38 +225,24 @@ export class SshPtyConsumerSessionAdapter { ) } const identity = requireIdentity(context) - const authenticate = { + const admission = this.session.admit(params, { connectionId: String(context.clientId), principal: identity.principal, authenticated: identity.authenticated, allowSessionOwner: identity.allowSessionOwner - } - const admission = resumeOnly - ? this.session.admitResumed(params, authenticate) - : this.session.admit(params, authenticate) + }) if (!context.onResponseSettled) { admission.rollbackPublication() throw new Error('SSH PTY consumer response publication fence is unavailable') } - this.pendingPublications.add(admission) - try { - context.onResponseSettled((result) => { - try { - if (!result.ok) { - admission.rollbackPublication() - return - } - admission.commitPublication() - this.closeDisplacedOwner(admission.displacedOwner) - } finally { - this.pendingPublications.delete(admission) - } - }) - } catch (error) { - this.pendingPublications.delete(admission) - admission.rollbackPublication() - throw error - } + context.onResponseSettled((result) => { + if (!result.ok) { + admission.rollbackPublication() + return + } + admission.commitPublication() + this.closeDisplacedOwner(admission.displacedOwner) + }) return admission.grant } diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 6758c058b58..4d1e9987a09 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -125,11 +125,11 @@ export function useAppSessionPersistence(): void { store: useAppStore, shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), subscribeToPersistGateOpen: onDirectSshRemoteWorkspaceApplyWindowClosed, - onPersistError: (error) => console.warn('[session] Local session patch failed:', error), persist: ({ patch }) => { const state = useAppStore.getState() // Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering. const localWrite = patchWorkspaceSessionByHost(window.api.session, patch, state) + void localWrite const uploadAuthorities = captureRemoteWorkspaceUploadAuthorities(state) const pendingLayoutEdits = state.pendingDirectSshLayoutEditsByTabId if (uploadAuthorities.length > 0) { @@ -193,7 +193,6 @@ export function useAppSessionPersistence(): void { } })() } - return localWrite } }) }, []) diff --git a/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx b/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx index 23531fc95f0..919118208cd 100644 --- a/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx @@ -62,6 +62,7 @@ vi.mock('./useEditorConflictNavigation', () => ({ vi.mock('@/store', () => { const state = { markdownRichModeSizeOverridden: false, + markdownRichModeSizeOverride: {}, setMarkdownRichModeSizeOverride: () => {}, reloadOpenCheckRunDetailsTab: () => {} } diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index c488bc1d25d..74e9e6d4086 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -8,6 +8,7 @@ import { EditorConflictReviewSurface } from './EditorConflictReviewSurface' import { EditorDiffFileSurface } from './EditorDiffFileSurface' import { EditorEditFileSurface } from './EditorEditFileSurface' import { EditorFileLoadErrorView } from './EditorFileLoadErrorView' +import { MarkdownPreviewSizeGate } from './MarkdownPreviewSizeGate' import type { FileContent } from './editor-panel-content-types' import { buildPdfScalePreferenceKey } from './pdf-scale-preference-storage' import { translate } from '@/i18n/i18n' @@ -208,22 +209,25 @@ export function EditorContent({ ) } const previewSourceFileId = activeFile.markdownPreviewSourceFileId ?? activeFile.filePath + const previewContent = editBuffers[previewSourceFileId] ?? fileContent.content return (
- + + +
) } diff --git a/src/renderer/src/components/editor/EditorDiffFileSurface.tsx b/src/renderer/src/components/editor/EditorDiffFileSurface.tsx index 1597e3cef27..1a9660d2316 100644 --- a/src/renderer/src/components/editor/EditorDiffFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorDiffFileSurface.tsx @@ -4,6 +4,7 @@ import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' import { getDiffContentSignature } from './diff-content-signature' import { DiffViewer, ImageDiffViewer, MarkdownPreview } from './editor-lazy-views' import { ExternalFileChangeBanner } from './ExternalFileChangeBanner' +import { MarkdownPreviewSizeGate } from './MarkdownPreviewSizeGate' import type { useMarkdownDocuments } from './useMarkdownDocuments' type MarkdownDocumentsController = ReturnType @@ -117,19 +118,25 @@ export function EditorDiffFileSurface({ )}
- + isDiff + > + +
) diff --git a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx index 4db7c9b5045..35a840cb284 100644 --- a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx @@ -6,6 +6,7 @@ import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' import { formatBytes } from '../status-bar/workspace-space-format' import { MarkdownPreview, RichMarkdownEditor } from './editor-lazy-views' import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter' +import { exceedsMarkdownRenderOverrideSizeLimit } from './markdown-rich-size-limit' import type { MarkdownRenderState } from './markdown-render-mode' import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary' import type { useMarkdownDocuments } from './useMarkdownDocuments' @@ -57,6 +58,8 @@ export function EditorMarkdownFileSurface({ if (renderMode === 'source' && mdViewMode === 'rich') { // Why: only a size fallback is recoverable — unsupported syntax would round-trip badly, so it gets no override. const isSizeFallback = richModeUnsupportedMessage === null + const canOverrideSize = + isSizeFallback && !exceedsMarkdownRenderOverrideSizeLimit(currentContent) const richFallbackMessage = richModeUnsupportedMessage ?? translate( @@ -68,7 +71,7 @@ export function EditorMarkdownFileSurface({
{richFallbackMessage} - {isSizeFallback ? ( + {canOverrideSize ? ( + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 97b4492750e..e7c01b40201 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -14,6 +14,7 @@ import type { FileContent } from './editor-panel-content-types' import { canUseChangesModeForFile } from './editor-panel-file-mode' import { getMarkdownRenderMode, type MarkdownRenderState } from './markdown-render-mode' import { getCachedMarkdownRichModeEligibility } from './markdown-rich-mode-eligibility-cache' +import { canRenderMarkdownAtSize } from './markdown-rich-size-limit' type StoreState = ReturnType @@ -163,7 +164,12 @@ export function getEditorPanelRenderModel({ ((activeFile.mode === 'markdown-preview' && fileContents[activeFile.id] !== undefined && fileContents[activeFile.id]?.isBinary !== true && - !fileContents[activeFile.id]?.loadError) || + !fileContents[activeFile.id]?.loadError && + canRenderMarkdownAtSize( + editorDrafts[activeFile.markdownPreviewSourceFileId ?? activeFile.filePath] ?? + fileContents[activeFile.id].content, + markdownRichModeSizeOverridden + )) || (activeFile.mode === 'edit' && fileContents[activeFile.id] !== undefined && !isChangesMode && diff --git a/src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx b/src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx new file mode 100644 index 00000000000..fa03f02fa00 --- /dev/null +++ b/src/renderer/src/components/editor/large-markdown-render-freeze.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' + +const store = vi.hoisted(() => { + const initialOverride: Record = {} + const state = { + markdownRichModeSizeOverride: initialOverride, + setMarkdownRichModeSizeOverride: (fileId: string, enabled: boolean): void => { + state.markdownRichModeSizeOverride = { + ...state.markdownRichModeSizeOverride, + [fileId]: enabled + } + }, + reloadOpenCheckRunDetailsTab: () => {} + } + return state +}) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign((selector: (storeState: typeof store) => unknown) => selector(store), { + getState: () => store + }) +})) + +vi.mock('./editor-lazy-views', () => { + const view = (name: string) => () =>
+ return { + MonacoEditor: view('source'), + CombinedDiffViewer: view('combined-diff'), + RichMarkdownEditor: view('rich-editor'), + MarkdownPreview: view('preview'), + DiffViewer: view('diff'), + ImageDiffViewer: view('image-diff') + } +}) + +vi.mock('./useMarkdownDocuments', () => ({ + useMarkdownDocuments: () => ({ + markdownDocuments: [], + onOpenDocLink: () => {}, + previewProps: { markdownDocuments: [], onOpenDocument: async () => {} }, + mdSave: async () => true + }) +})) + +vi.mock('./useEditorConflictNavigation', () => ({ + useEditorConflictNavigation: () => () => undefined +})) + +import { EditorContent } from './EditorContent' +import { EditorDiffFileSurface } from './EditorDiffFileSurface' +import type { useMarkdownDocuments } from './useMarkdownDocuments' +import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' +import { MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES } from './markdown-rich-size-limit' +import { getEditorPanelRenderModel } from './editor-panel-render-model' + +// Why: preview/rich surfaces build the whole document in one task; 2 MB blocked +// the renderer 7.8 s at 2.3 GB and 5 MB 38 s at 4 GB (scan29 crash reports). +const mediumDoc = 'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1024) +const hugeDoc = 'a'.repeat(MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + 1) +// Multibyte text whose UTF-16 length is under the cap but UTF-8 size is over it. +const hugeCjkDoc = '中'.repeat(Math.ceil((MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + 1) / 3)) + +const sourceFile: OpenFile = { + id: '/repo/BIG.md', + filePath: '/repo/BIG.md', + relativePath: 'BIG.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: false +} +const previewTab: OpenFile = { + ...sourceFile, + id: `markdown-preview::${sourceFile.id}`, + mode: 'markdown-preview', + markdownPreviewSourceFileId: sourceFile.id +} + +function richRenderMode(content: string, sizeOverridden: boolean) { + return getEditorPanelRenderModel({ + activeFile: sourceFile, + fileContents: { [sourceFile.id]: { content, isBinary: false } }, + editorDrafts: {}, + gitStatusEntries: undefined, + gitBranchEntries: undefined, + markdownViewMode: { [sourceFile.id]: 'rich' }, + markdownRichModeSizeOverridden: sizeOverridden, + isChangesMode: false, + canOpenWorkspaceFileBrowser: true + }).inlineMarkdownRenderState?.renderMode +} + +function renderPreviewTab(content: string) { + const fileContents = { [previewTab.id]: { content, isBinary: false } } + const props = { + activeFile: previewTab, + viewStateScopeId: previewTab.id, + fileContents, + diffContents: {}, + editBuffers: {}, + openFiles: [sourceFile, previewTab], + worktreeEntries: [], + resolvedLanguage: 'markdown', + isMarkdown: true, + isMermaid: false, + isCsv: false, + isNotebook: false, + mdViewMode: 'rich' as const, + inlineMarkdownRenderState: null, + isChangesMode: false, + sideBySide: false, + pendingEditorReveal: null, + handleContentChange: vi.fn(), + handleContentChangeForFile: vi.fn(), + handleDirtyStateHint: vi.fn(), + handleSave: vi.fn(), + handleSaveForFile: vi.fn(), + reloadContent: vi.fn() + } + const view = render() + return { + view, + rerender: () => view.rerender(), + isPreviewRendered: () => view.container.querySelector('[data-editor-view="preview"]') !== null + } +} + +const diffTab: OpenFile = { + ...sourceFile, + id: 'diff::unstaged::/repo/BIG.md', + mode: 'diff', + diffSource: 'unstaged' +} + +const markdownDocumentsStub: ReturnType = { + markdownDocuments: [], + openMarkdownDocument: async () => {}, + onOpenDocLink: () => {}, + previewProps: { markdownDocuments: [], onOpenDocument: async () => {} }, + mdSave: async () => true +} + +function renderDiffPreview(content: string) { + const diffContent: GitDiffResult = { + kind: 'text', + originalContent: '', + modifiedContent: content, + originalIsBinary: false, + modifiedIsBinary: false + } + const props = { + activeFile: diffTab, + diffContent, + editBuffer: undefined, + resolvedLanguage: 'markdown', + sideBySide: false, + viewStateScopeId: diffTab.id, + diffViewStateKey: diffTab.id, + mdViewMode: 'preview' as const, + isMarkdown: true, + showMarkdownTableOfContents: false, + onCloseMarkdownTableOfContents: vi.fn(), + markdownAnnotationsEnabled: false, + markdownDocuments: markdownDocumentsStub, + onContentChange: vi.fn(), + onSave: vi.fn(), + reloadContent: vi.fn() + } + const view = render() + return { + rerender: () => view.rerender(), + isPreviewRendered: () => view.container.querySelector('[data-editor-view="preview"]') !== null + } +} + +afterEach(() => { + cleanup() + store.markdownRichModeSizeOverride = {} +}) + +describe('large markdown render guard', () => { + it('"Open anyway" still reaches the rich editor below the hard cap', () => { + expect(richRenderMode(mediumDoc, false)).toBe('source') + expect(richRenderMode(mediumDoc, true)).toBe('rich-editor') + }) + + it('"Open anyway" cannot push a document past the hard cap into the rich editor', () => { + expect(richRenderMode(hugeDoc, true)).toBe('source') + expect(richRenderMode(hugeCjkDoc, true)).toBe('source') + }) + + it('preview tab renders small documents directly', () => { + expect(renderPreviewTab('# Small').isPreviewRendered()).toBe(true) + }) + + it('preview tab gates documents over the preview limit behind "Render anyway"', () => { + const tab = renderPreviewTab(mediumDoc) + expect(tab.isPreviewRendered()).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Render anyway' })) + tab.rerender() + expect(tab.isPreviewRendered()).toBe(true) + }) + + it('preview tab never renders documents over the hard cap', () => { + store.markdownRichModeSizeOverride = { [previewTab.id]: true } + const tab = renderPreviewTab(hugeDoc) + expect(tab.isPreviewRendered()).toBe(false) + expect(screen.queryByRole('button', { name: 'Render anyway' })).toBeNull() + }) + + it('diff preview toggle gates the modified side over the preview limit', () => { + expect(renderDiffPreview('# Small').isPreviewRendered()).toBe(true) + cleanup() + const diff = renderDiffPreview(mediumDoc) + expect(diff.isPreviewRendered()).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Render anyway' })) + diff.rerender() + expect(diff.isPreviewRendered()).toBe(true) + }) + + it('diff preview toggle never renders a modified side over the hard cap', () => { + store.markdownRichModeSizeOverride = { [diffTab.id]: true } + expect(renderDiffPreview(hugeDoc).isPreviewRendered()).toBe(false) + expect(screen.queryByRole('button', { name: 'Render anyway' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index a499acd02dd..cced3ef52c1 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -2,7 +2,7 @@ import { defaultSchema } from 'rehype-sanitize' import { normalizeDetailsOpeningTag } from './details-markdown-html' import { getRichMarkdownRoundTripOutput } from './markdown-round-trip' import { extractFrontMatter } from './markdown-frontmatter' -import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' +import { canRenderMarkdownAtSize } from './markdown-rich-size-limit' import { translate } from '@/i18n/i18n' export type MarkdownRichModeUnsupportedReason = @@ -141,7 +141,7 @@ export function getMarkdownRichModeEligibilityDecision({ sizeOverridden: boolean }): MarkdownRichModeEligibilityDecision { return { - exceedsSizeLimit: !sizeOverridden && exceedsMarkdownRichModeSizeLimit(content), + exceedsSizeLimit: !canRenderMarkdownAtSize(content, sizeOverridden), unsupportedReason: getMarkdownRichModeUnsupportedReason(content) } } diff --git a/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts b/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts index 3539ffc565f..47a9447ce56 100644 --- a/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts +++ b/src/renderer/src/components/editor/markdown-rich-size-limit.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' -import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' +import { + MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES, + canRenderMarkdownAtSize, + exceedsMarkdownRenderOverrideSizeLimit, + exceedsMarkdownRichModeSizeLimit +} from './markdown-rich-size-limit' describe('exceedsMarkdownRichModeSizeLimit', () => { it('uses a 600 KB default rich-mode ceiling', () => { @@ -23,3 +28,31 @@ describe('exceedsMarkdownRichModeSizeLimit', () => { ).toBe(true) }) }) + +describe('exceedsMarkdownRenderOverrideSizeLimit', () => { + it('allows exactly the override limit and rejects one byte over', () => { + const limit = MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + expect(exceedsMarkdownRenderOverrideSizeLimit('a'.repeat(limit))).toBe(false) + expect(exceedsMarkdownRenderOverrideSizeLimit('a'.repeat(limit + 1))).toBe(true) + }) + + it('counts UTF-8 bytes, not UTF-16 length', () => { + const limit = MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + // 3-byte CJK chars: floor(limit / 3) fit, one more does not. + expect(exceedsMarkdownRenderOverrideSizeLimit('中'.repeat(Math.floor(limit / 3)))).toBe(false) + expect(exceedsMarkdownRenderOverrideSizeLimit('中'.repeat(Math.floor(limit / 3) + 1))).toBe( + true + ) + // Surrogate pairs are 2 UTF-16 units but 4 bytes. + expect(exceedsMarkdownRenderOverrideSizeLimit('😀'.repeat(limit / 4))).toBe(false) + expect(exceedsMarkdownRenderOverrideSizeLimit(`${'😀'.repeat(limit / 4)}a`)).toBe(true) + }) + + it('lets the override lift only the rich limit, never the hard cap', () => { + const medium = 'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1) + const huge = 'a'.repeat(MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES + 1) + expect(canRenderMarkdownAtSize(medium, false)).toBe(false) + expect(canRenderMarkdownAtSize(medium, true)).toBe(true) + expect(canRenderMarkdownAtSize(huge, true)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-rich-size-limit.ts b/src/renderer/src/components/editor/markdown-rich-size-limit.ts index b6ec31ee8fb..934d6e20d93 100644 --- a/src/renderer/src/components/editor/markdown-rich-size-limit.ts +++ b/src/renderer/src/components/editor/markdown-rich-size-limit.ts @@ -1,14 +1,42 @@ import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' -const richMarkdownSizeEncoder = new TextEncoder() -// Why: rich-mode eligibility is checked during render-model work, so this -// avoids allocating a large Uint8Array every time markdown content changes. -const richMarkdownSizeBuffer = new Uint8Array(RICH_MARKDOWN_MAX_SIZE_BYTES + 1) +// Why: "Open anyway"/"Render anyway" still build the whole document in one task; +// in Electron 43 on M-series a ~1 MB preview blocks 3.8 s at 1.3 GB and 2 MB +// blocks 7.8 s at 2.3 GB, so no override renders past this. +export const MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES = 1024 * 1024 -export function exceedsMarkdownRichModeSizeLimit(markdownContent: string): boolean { - const probe = richMarkdownSizeEncoder.encodeInto(markdownContent, richMarkdownSizeBuffer) +const markdownSizeEncoder = new TextEncoder() +// Why: size checks run during render-model work, so this avoids allocating a +// large Uint8Array every time markdown content changes. +const markdownSizeBuffer = new Uint8Array( + Math.max(RICH_MARKDOWN_MAX_SIZE_BYTES, MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES) + 1 +) +function exceedsUtf8ByteLimit(content: string, limit: number): boolean { + // Why: each UTF-16 unit encodes to 1-3 bytes, so most sizes resolve without encoding. + if (content.length > limit) { + return true + } + if (content.length * 3 <= limit) { + return false + } + const probe = markdownSizeEncoder.encodeInto(content, markdownSizeBuffer.subarray(0, limit + 1)) // Why: encodeInto() never writes partial UTF-8 sequences. A multibyte // character can leave written at the exact limit while unread content remains. - return probe.written > RICH_MARKDOWN_MAX_SIZE_BYTES || probe.read < markdownContent.length + return probe.written > limit || probe.read < content.length +} + +export function exceedsMarkdownRichModeSizeLimit(markdownContent: string): boolean { + return exceedsUtf8ByteLimit(markdownContent, RICH_MARKDOWN_MAX_SIZE_BYTES) +} + +export function exceedsMarkdownRenderOverrideSizeLimit(markdownContent: string): boolean { + return exceedsUtf8ByteLimit(markdownContent, MARKDOWN_RENDER_OVERRIDE_MAX_SIZE_BYTES) +} + +/** Whether a rendered (rich or preview) markdown surface may build this document. */ +export function canRenderMarkdownAtSize(markdownContent: string, sizeOverridden: boolean): boolean { + return sizeOverridden + ? !exceedsMarkdownRenderOverrideSizeLimit(markdownContent) + : !exceedsMarkdownRichModeSizeLimit(markdownContent) } diff --git a/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx b/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx index f02cbf3d614..bade2bc7741 100644 --- a/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx +++ b/src/renderer/src/components/settings/TerminalMacKeyboardSection.tsx @@ -16,10 +16,19 @@ export function TerminalMacKeyboardSection({ const detectedLayout = useDetectedOptionAsAlt() const detectedLayoutLabel = detectedLayout === 'us' - ? 'US English — Option sends Alt/Esc sequences' + ? translate( + 'settings.terminal.optionLayoutAlt', + 'ABC or U.S. — Option sends Alt/Esc sequences' + ) : detectedLayout === 'non-us' - ? 'non-US layout — Option composes characters like @, €, [, ]' - : 'unknown layout — Option composes characters (safe default)' + ? translate( + 'settings.terminal.optionLayoutCompose', + 'layout uses Option to compose characters like @, €, [, ]' + ) + : translate( + 'settings.terminal.optionLayoutUnknown', + 'unknown layout — Option composes characters (safe default)' + ) return ( <> @@ -49,29 +58,35 @@ export function TerminalMacKeyboardSection({ alignTop label={translate('auto.components.settings.TerminalPane.0a10420e1a', 'Option as Alt')} description={ - settings.terminalMacOptionAsAlt === 'auto' - ? translate( - 'auto.components.settings.TerminalPane.d21c493808', - 'Auto — detected: {{value0}}.', - { - value0: detectedLayoutLabel - } - ) - : settings.terminalMacOptionAsAlt === 'false' + <> + {settings.terminalMacOptionAsAlt === 'auto' ? translate( - 'auto.components.settings.TerminalPane.d8998bb328', - 'Option composes special characters for your keyboard layout.' + 'auto.components.settings.TerminalPane.d21c493808', + 'Auto — detected: {{value0}}.', + { + value0: detectedLayoutLabel + } ) - : settings.terminalMacOptionAsAlt === 'true' + : settings.terminalMacOptionAsAlt === 'false' ? translate( - 'auto.components.settings.TerminalPane.b62373091a', - 'Both Option keys send Alt/Esc sequences.' - ) - : translate( - 'auto.components.settings.TerminalPane.ce3aadf0b2', - 'The {{value0}} Option key sends Alt/Esc; the other composes special characters.', - { value0: settings.terminalMacOptionAsAlt } + 'auto.components.settings.TerminalPane.d8998bb328', + 'Option composes special characters for your keyboard layout.' ) + : settings.terminalMacOptionAsAlt === 'true' + ? translate( + 'auto.components.settings.TerminalPane.b62373091a', + 'Both Option keys send Alt/Esc sequences.' + ) + : translate( + 'auto.components.settings.TerminalPane.ce3aadf0b2', + 'The {{value0}} Option key sends Alt/Esc; the other composes special characters.', + { value0: settings.terminalMacOptionAsAlt } + )}{' '} + {translate( + 'settings.terminal.optionShortcutHint', + 'Choose Both for Option shortcuts, Off for accents and symbols, or Left/Right to use one Option key for each.' + )} + } control={ { expect(onError).toHaveBeenCalledWith(createTerminalSessionStateSaveFailureMessage()) }) + + it('keeps every line of a multi-line spawn failure for the pane toast (#20386)', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const diagnosis = + 'Remote terminals are unavailable: make and a C++ compiler are not installed. Install them on the remote host, then reconnect:\n' + + ' sudo apt-get install -y build-essential python3\n' + + 'Host: linux/x64, glibc 2.36, Node v22.12.0 (ABI 127), prebuild slot linux-x64-glibc.' + vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( + new Error(`Error invoking remote method 'pty:spawn': Error: ${diagnosis}`) + ) + + const onError = vi.fn() + await createIpcPtyTransport({ connectionId: 'ssh-1' }).connect({ + url: '', + callbacks: { onError } + }) + + expect(onError).toHaveBeenCalledWith(diagnosis) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-option-punctuation.test.ts b/src/renderer/src/components/terminal-pane/terminal-option-punctuation.test.ts new file mode 100644 index 00000000000..7f740c25940 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-option-punctuation.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveTerminalOptionShortcutAction } from './terminal-option-shortcut-policy' +import { createTerminalOptionKittyReleaseTracker } from './terminal-option-kitty-release' + +type OptionEvent = Parameters[0] +type OptionContext = Parameters[1] + +const punctuation = [ + { code: 'Semicolon', key: '…', base: ';', codePoint: 59 }, + { code: 'Period', key: '≥', base: '.', codePoint: 46 }, + { code: 'Comma', key: '≤', base: ',', codePoint: 44 } +] as const + +const altModes = [ + { macOptionAsAlt: 'true', optionKeyLocations: 0 }, + { macOptionAsAlt: 'left', optionKeyLocations: 1 }, + { macOptionAsAlt: 'right', optionKeyLocations: 2 } +] as const + +const composeModes = [ + { macOptionAsAlt: 'false', optionKeyLocations: 0 }, + { macOptionAsAlt: 'left', optionKeyLocations: 2 }, + { macOptionAsAlt: 'right', optionKeyLocations: 1 } +] as const + +function optionEvent(overrides: Partial): OptionEvent { + return { + key: '', + code: '', + altKey: true, + shiftKey: false, + metaKey: false, + ctrlKey: false, + repeat: false, + ...overrides + } +} + +function optionContext(overrides: Partial = {}): OptionContext { + return { + isMac: true, + macOptionAsAlt: 'true', + optionKeyLocations: 0, + getKittyKeyboardFlags: () => 1, + ...overrides + } +} + +describe.each(punctuation)('Option+$base punctuation ($code)', ({ code, key, base, codePoint }) => { + it.each([1, 7])('reports the configured Alt side under keyboard flags %i', (flags) => { + for (const mode of altModes) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => flags }) + ) + ).toEqual({ + type: 'sendInput', + data: `\x1b[${codePoint};3u`, + optionKittyRelease: flags === 7 ? { flags } : undefined + }) + } + }) + + it('reports repeats and one release for the original punctuation key', () => { + for (const mode of altModes) { + const sendInput = vi.fn() + const releases = createTerminalOptionKittyReleaseTracker() + const context = optionContext({ ...mode, getKittyKeyboardFlags: () => 7 }) + for (const repeat of [false, true]) { + const event = optionEvent({ code, key, repeat }) + const action = resolveTerminalOptionShortcutAction(event, context) + expect(action).toEqual({ + type: 'sendInput', + data: `\x1b[${codePoint};3${repeat ? ':2' : ''}u`, + optionKittyRelease: { flags: 7 } + }) + if (action?.type === 'sendInput' && action.optionKittyRelease) { + sendInput(action.data) + releases.arm(event, action.optionKittyRelease, sendInput, context.getKittyKeyboardFlags) + } + } + const release = optionEvent({ code, key: base, altKey: false }) + expect(releases.settle(release)).toBe(true) + expect(releases.settle(release)).toBe(false) + expect(sendInput.mock.calls.map(([data]) => data)).toEqual([ + `\x1b[${codePoint};3u`, + `\x1b[${codePoint};3:2u`, + `\x1b[${codePoint};1:3u` + ]) + } + }) + + it.each([1, 7])('preserves the composed symbol on the text side under flags %i', (flags) => { + for (const mode of composeModes) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => flags }) + ) + ).toEqual({ + type: 'sendInput', + data: key, + optionKittyRelease: flags === 7 ? { flags } : undefined + }) + } + }) + + it('uses legacy escape bytes for a selected Alt side in an ordinary shell', () => { + for (const mode of altModes.slice(1)) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => 0 }) + ) + ).toEqual({ type: 'sendInput', data: `\x1b${base}` }) + } + for (const mode of [altModes[0], ...composeModes]) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key }), + optionContext({ ...mode, getKittyKeyboardFlags: () => 0 }) + ) + ).toBeNull() + } + }) + + it.each([{ isComposing: true }, { keyCode: 229 }, { key: 'Process' }, { key: 'Unidentified' }])( + 'leaves IME-owned punctuation to native input (%j)', + (imeState) => { + for (const mode of [...altModes, ...composeModes]) { + expect( + resolveTerminalOptionShortcutAction( + optionEvent({ code, key, ...imeState }), + optionContext({ ...mode, getKittyKeyboardFlags: () => 7 }) + ) + ).toBeNull() + } + } + ) + + it('leaves non-Mac input and additional command modifiers untouched', () => { + for (const overrides of [{ isMac: false }, { isMac: true }]) { + const context = optionContext(overrides) + const events = + overrides.isMac === false + ? [optionEvent({ code, key })] + : [optionEvent({ code, key, ctrlKey: true }), optionEvent({ code, key, metaKey: true })] + for (const event of events) { + expect(resolveTerminalOptionShortcutAction(event, context)).toBeNull() + } + } + }) +}) + +it('uses the active layout character rather than the US punctuation position', () => { + const event = optionEvent({ code: 'Semicolon', key: 'µ' }) + const layout = (code: string): string | undefined => (code === 'Semicolon' ? 'm' : undefined) + for (const mode of altModes) { + expect( + resolveTerminalOptionShortcutAction( + event, + optionContext({ ...mode, layoutCharacterForCode: layout, getKittyKeyboardFlags: () => 7 }) + ) + ).toEqual({ + type: 'sendInput', + data: '\x1b[109::59;3u', + optionKittyRelease: { flags: 7 } + }) + } + for (const mode of altModes.slice(1)) { + expect( + resolveTerminalOptionShortcutAction( + event, + optionContext({ ...mode, layoutCharacterForCode: layout, getKittyKeyboardFlags: () => 0 }) + ) + ).toEqual({ type: 'sendInput', data: '\x1bm' }) + } +}) diff --git a/src/renderer/src/components/use-terminal-parking-pass.test.tsx b/src/renderer/src/components/use-terminal-parking-pass.test.tsx new file mode 100644 index 00000000000..d6ed94e73b3 --- /dev/null +++ b/src/renderer/src/components/use-terminal-parking-pass.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { StrictMode, useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' +import { useTerminalParkingPass } from './use-terminal-parking-pass' + +const parkingPass = vi.hoisted(() => ({ parkedIds: ['wt-parked'] })) + +vi.mock('./terminal-parking-pass-candidates', () => ({ + canOrdinarilyParkRetentionCandidate: () => true, + collectTerminalParkingPassCandidates: () => ({ + retentionCandidates: [], + // Fresh Set each pass, like the real selector. + nextParkedTerminalWorktreeIds: new Set(parkingPass.parkedIds), + nowMs: 0, + overrides: {}, + parkingTimers: new Map() + }) +})) +vi.mock('./terminal-pane/parked-terminal-buffer-capture', () => ({ + captureParkedTerminalBuffers: () => true +})) + +function useParkingPassHost() { + // Why own state: like the workbench's store subscriptions, an update on this fiber leaves lanes on + // its alternate, which defeats React's eager same-state bailout for the setters below. + const [revision, setRevision] = useState(0) + const [parked, setParked] = useState>(() => new Set()) + const [forceParked, setForceParked] = useState>(() => new Set()) + const [exempt, setExempt] = useState>(() => new Set()) + const controller = { + activeView: 'terminal', + activityTerminalPortals: [], + backgroundMountRevision: 0, + parkedCaptureDoneRef: { current: new Set() }, + pairedRuntimeParkingEnvironmentIds: [], + pendingStartupByTabId: {}, + renderedActiveWorktreeId: 'wt-active', + setEvictionExemptTerminalTabIds: setExempt, + setForceParkedTerminalWorktreeIds: setForceParked, + setParkedTerminalWorktreeIds: setParked, + setTerminalParkingRevision: () => {}, + tabsByWorktree: {}, + terminalParkingEnabled: true, + terminalParkingRevision: revision, + terminalProviderSnapshotCapabilityRevision: 0, + terminalRetentionBudgetEnabled: false, + terminalSshParkingEnabled: false, + workspaceSurfaceIds: [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only the fields stubbed above. + useTerminalParkingPass(controller as unknown as TerminalParkingFoundation) + return { parked, forceParked, exempt, bump: () => setRevision((r) => r + 1) } +} + +describe('useTerminalParkingPass', () => { + beforeEach(() => { + parkingPass.parkedIds = ['wt-parked'] + }) + + it('does not queue a render when a pass produces the same parking sets', () => { + let renders = 0 + const { result } = renderHook(() => { + renders += 1 + return useParkingPassHost() + }) + expect([...result.current.parked]).toEqual(['wt-parked']) + + // Why: during a worktree removal every pty-exit commit re-runs this pass; each no-op render it + // queued kept React's nested-update counter climbing until #185. + const settled = renders + for (let i = 0; i < 5; i++) { + act(() => result.current.bump()) + } + expect(renders - settled).toBe(5) + expect([...result.current.parked]).toEqual(['wt-parked']) + }) + + it('applies changed parking decisions after unchanged passes', () => { + const { result } = renderHook(() => useParkingPassHost()) + act(() => result.current.bump()) + parkingPass.parkedIds = ['wt-other'] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual(['wt-other']) + parkingPass.parkedIds = [] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual([]) + }) + + it('keeps parking transitions working under StrictMode', () => { + const { result } = renderHook(() => useParkingPassHost(), { wrapper: StrictMode }) + expect([...result.current.parked]).toEqual(['wt-parked']) + parkingPass.parkedIds = [] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual([]) + parkingPass.parkedIds = ['wt-parked'] + act(() => result.current.bump()) + expect([...result.current.parked]).toEqual(['wt-parked']) + }) +}) diff --git a/src/renderer/src/components/use-terminal-parking-pass.ts b/src/renderer/src/components/use-terminal-parking-pass.ts index 7dde35d68cb..3bb09d67d54 100644 --- a/src/renderer/src/components/use-terminal-parking-pass.ts +++ b/src/renderer/src/components/use-terminal-parking-pass.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useAppStore } from '../store' import { TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS, @@ -43,6 +43,12 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v terminalSshParkingEnabled, workspaceSurfaceIds } = controller + // Why: this effect is the sole writer, so these mirror the queued state without a render. + const dispatchedIdSetsRef = useRef<{ + parked: ReadonlySet + forceParked: ReadonlySet + evictionExempt: ReadonlySet + } | null>(null) useEffect(() => { const pass = collectTerminalParkingPassCandidates(controller) @@ -143,17 +149,26 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v } pass.nextParkedTerminalWorktreeIds.add(worktreeId) } - setParkedTerminalWorktreeIds((current) => - haveSameIdSet(current, pass.nextParkedTerminalWorktreeIds) - ? current - : pass.nextParkedTerminalWorktreeIds - ) - setForceParkedTerminalWorktreeIds((current) => - haveSameIdSet(current, forceParkedWorktreeIds) ? current : forceParkedWorktreeIds - ) - setEvictionExemptTerminalTabIds((current) => - haveSameIdSet(current, nextEvictionExemptTabIds) ? current : nextEvictionExemptTabIds - ) + // Why: a functional updater that returns `current` still queues a render. This pass runs in the + // synchronously flushed effects of every pty-exit commit, so those no-op renders chained a + // worktree removal's pane-close burst past React's nested-update limit (#185). + const dispatched = (dispatchedIdSetsRef.current ??= { + parked: new Set(), + forceParked: new Set(), + evictionExempt: new Set() + }) + if (!haveSameIdSet(dispatched.parked, pass.nextParkedTerminalWorktreeIds)) { + dispatched.parked = pass.nextParkedTerminalWorktreeIds + setParkedTerminalWorktreeIds(pass.nextParkedTerminalWorktreeIds) + } + if (!haveSameIdSet(dispatched.forceParked, forceParkedWorktreeIds)) { + dispatched.forceParked = forceParkedWorktreeIds + setForceParkedTerminalWorktreeIds(forceParkedWorktreeIds) + } + if (!haveSameIdSet(dispatched.evictionExempt, nextEvictionExemptTabIds)) { + dispatched.evictionExempt = nextEvictionExemptTabIds + setEvictionExemptTerminalTabIds(nextEvictionExemptTabIds) + } const retentionTtlEligibleIds = new Set( retentionBudgetCandidates .filter((candidate) => !candidate.ordinaryParkingCovers && !candidate.hasPendingSpawnWork) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 48f6eec3cb1..2f29e560803 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -119,6 +119,11 @@ "tooLarge": "File is larger than the {{limit}} rich editing limit. Showing source mode instead.", "openAnyway": "Open anyway" }, + "markdownPreview": { + "tooLarge": "File is larger than the {{limit}} preview limit. Open the file to view its source.", + "tooLargeInDiff": "File is larger than the {{limit}} preview limit. Switch to source mode to view the diff.", + "renderAnyway": "Render anyway" + }, "fileLoad": { "hostUnresolved": "The host couldn't find this file's workspace. It may have been removed, or the host may not know about it yet. Retry, or close this tab from the tab strip." } @@ -127,6 +132,12 @@ "retrying": "Retrying…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC or U.S. — Option sends Alt/Esc sequences", + "optionLayoutCompose": "layout uses Option to compose characters like @, €, [, ]", + "optionLayoutUnknown": "unknown layout — Option composes characters (safe default)", + "optionShortcutHint": "Choose Both for Option shortcuts, Off for accents and symbols, or Left/Right to use one Option key for each." + }, "appearance": { "language": { "title": "Language", @@ -18528,7 +18539,15 @@ "genericDetail": "This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.", "title": "Orca keeps failing to load", "stalledMessage": "The app window stopped responding while reloading after a crash.", - "crashLoopMessage": "The app window crashed repeatedly and stopped reloading automatically." + "crashLoopMessage": "The app window crashed repeatedly and stopped reloading automatically.", + "lowCommitMessage": "Windows is out of memory.", + "lowCommitDetail": "Windows has only {{availableMB}} MB of memory left for apps, so the window ran out of memory again after reloading.", + "lowCommitAdvice": "Free memory by closing unused apps or Orca workspaces, or increase the Windows page file size, then click Reload.", + "tryAgain": "Try Again", + "launchFailedMessage": "Orca couldn't start the process that draws its window.", + "launchFailedDetail": "Orca retried {{recoveryCount}} times without success.", + "processLimitDetail": "The system refused to create a new process because your user account reached its process limit. This is often caused by runaway terminals, dev servers, or agents. Close some of them, then click Try Again.", + "launchFailedGenericDetail": "The system refused to start it. Free up memory or close other apps, then click Try Again. If this keeps happening, reinstall Orca." }, "notifications": { "agentStatus": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 84dffe050fd..45afe6f04a0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11,6 +11,12 @@ "retrying": "Reintentando…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC o EE. UU. — Option envía secuencias Alt/Esc", + "optionLayoutCompose": "la distribución usa Option para escribir caracteres como @, €, [, ]", + "optionLayoutUnknown": "distribución desconocida — Option escribe caracteres (opción predeterminada segura)", + "optionShortcutHint": "Elige Ambos para los atajos con Option, Desactivado para acentos y símbolos, o Izquierda/Derecha para dedicar una tecla Option a cada uso." + }, "appearance": { "language": { "title": "Idioma", @@ -15537,5 +15543,12 @@ "addTypedPath": "Add", "noDirectories": "No folders added yet.", "removePath": "Remove {{name}}" + }, + "editor": { + "markdownPreview": { + "tooLarge": "El archivo supera el límite de vista previa de {{limit}}. Abre el archivo para ver su código fuente.", + "tooLargeInDiff": "El archivo supera el límite de vista previa de {{limit}}. Cambia al modo fuente para ver las diferencias.", + "renderAnyway": "Renderizar de todos modos" + } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d8086183f21..85a1b64c154 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -110,6 +110,12 @@ "retrying": "Nouvelle tentative…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC ou U.S. — Option envoie des séquences Alt/Esc", + "optionLayoutCompose": "la disposition utilise Option pour composer des caractères comme @, €, [, ]", + "optionLayoutUnknown": "disposition inconnue — Option compose des caractères (choix sûr par défaut)", + "optionShortcutHint": "Choisissez Les deux pour les raccourcis Option, Désactivé pour les accents et les symboles, ou Gauche/Droite pour attribuer un usage à chaque touche Option." + }, "appearance": { "language": { "title": "Langue", @@ -18458,6 +18464,11 @@ }, "fileLoad": { "hostUnresolved": "L'hôte n'a pas pu trouver l'espace de travail de ce fichier. Il a peut-être été supprimé ou l'hôte n'en est peut-être pas encore au courant. Réessayez ou fermez cet onglet à partir de la bande d'onglets." + }, + "markdownPreview": { + "tooLarge": "Le fichier dépasse la limite d'aperçu de {{limit}}. Ouvrez le fichier pour afficher sa source.", + "tooLargeInDiff": "Le fichier dépasse la limite d'aperçu de {{limit}}. Passez en mode source pour afficher les différences.", + "renderAnyway": "Afficher quand même" } }, "checksPanel": { @@ -18497,7 +18508,12 @@ "genericDetail": "Il s'agit souvent d'un problème de pilote graphique ou d'installation. Rechargez pour réessayer, ou quittez et relancez Orca.", "title": "Orca ne parvient toujours pas à charger", "stalledMessage": "La fenêtre de l'application a cessé de répondre lors du rechargement après un crash.", - "crashLoopMessage": "La fenêtre de l'application s'est écrasée à plusieurs reprises et a cessé de se recharger automatiquement." + "crashLoopMessage": "La fenêtre de l'application s'est écrasée à plusieurs reprises et a cessé de se recharger automatiquement.", + "tryAgain": "Réessayer", + "launchFailedMessage": "Orca n'a pas pu démarrer le processus qui affiche sa fenêtre.", + "launchFailedDetail": "Orca a réessayé {{recoveryCount}} fois sans succès.", + "processLimitDetail": "Le système a refusé de créer un nouveau processus, car votre compte utilisateur a atteint sa limite de processus. Cela vient souvent de terminaux, de serveurs de développement ou d'agents emballés. Fermez-en quelques-uns, puis cliquez sur Réessayer.", + "launchFailedGenericDetail": "Le système a refusé de le démarrer. Libérez de la mémoire ou fermez d'autres applications, puis cliquez sur Réessayer. Si le problème persiste, réinstallez Orca." }, "sessionSearch": { "panel": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0997b8b616b..0bed9bab422 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11,6 +11,12 @@ "retrying": "再試行中…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABCまたはU.S. — OptionはAlt/Escシーケンスを送信します", + "optionLayoutCompose": "この配列ではOptionで@、€、[、]などの文字を入力します", + "optionLayoutUnknown": "不明な配列 — Optionで文字を入力します(安全な既定値)", + "optionShortcutHint": "Optionショートカットには「両方」、アクセント付き文字や記号には「オフ」、用途を左右のOptionキーに分けるには「左」または「右」を選んでください。" + }, "appearance": { "language": { "title": "言語", @@ -18410,6 +18416,11 @@ }, "fileLoad": { "hostUnresolved": "ホストはこのファイルのワークスペースを見つけることができませんでした。削除されたか、ホストがまだそれを認識していない可能性があります。再試行するか、タブストリップからこのタブを閉じてください。" + }, + "markdownPreview": { + "tooLarge": "ファイルがプレビュー上限の {{limit}} を超えています。ソースを表示するにはファイルを開いてください。", + "tooLargeInDiff": "ファイルがプレビュー上限の {{limit}} を超えています。差分を表示するにはソースモードに切り替えてください。", + "renderAnyway": "それでも表示" } }, "featureTips": { @@ -18497,7 +18508,12 @@ "genericDetail": "これは多くの場合、グラフィックスドライバーまたはインストールの問題です。リロードして再試行するか、Orca を終了して再起動してください。", "title": "Orca がロードに失敗し続ける", "stalledMessage": "クラッシュ後のリロード中にアプリウィンドウが応答を停止しました。", - "crashLoopMessage": "アプリウィンドウが繰り返しクラッシュし、自動的にリロードされなくなりました。" + "crashLoopMessage": "アプリウィンドウが繰り返しクラッシュし、自動的にリロードされなくなりました。", + "tryAgain": "再試行", + "launchFailedMessage": "Orca はウィンドウを描画するプロセスを起動できませんでした。", + "launchFailedDetail": "Orca は {{recoveryCount}} 回再試行しましたが、成功しませんでした。", + "processLimitDetail": "ユーザーアカウントのプロセス数が上限に達したため、システムが新しいプロセスの作成を拒否しました。暴走したターミナル、開発サーバー、Agent が原因であることがよくあります。いくつか終了してから「再試行」をクリックしてください。", + "launchFailedGenericDetail": "システムが起動を拒否しました。メモリを空けるか他のアプリを終了してから「再試行」をクリックしてください。繰り返し発生する場合は Orca を再インストールしてください。" }, "sessionSearch": { "panel": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 29149cd5a65..a503c3347d8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11,6 +11,12 @@ "retrying": "재시도 중…" }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC 또는 U.S. — Option 키가 Alt/Esc 시퀀스를 전송합니다", + "optionLayoutCompose": "이 배열에서는 Option 키로 @, €, [, ] 같은 문자를 입력합니다", + "optionLayoutUnknown": "알 수 없는 배열 — Option 키로 문자를 입력합니다(안전한 기본값)", + "optionShortcutHint": "Option 단축키에는 둘 다, 악센트 문자와 기호에는 끄기, 각 Option 키를 다른 용도로 쓰려면 왼쪽 또는 오른쪽을 선택하세요." + }, "appearance": { "language": { "title": "언어", @@ -18410,6 +18416,11 @@ }, "fileLoad": { "hostUnresolved": "호스트가 이 파일의 워크스페이스를 찾을 수 없습니다. 삭제되었거나 호스트가 아직 이에 대해 알지 못할 수도 있습니다. 다시 시도하거나 탭 표시줄에서 이 탭을 닫으세요." + }, + "markdownPreview": { + "tooLarge": "파일이 미리보기 한도 {{limit}}보다 큽니다. 소스를 보려면 파일을 여세요.", + "tooLargeInDiff": "파일이 미리보기 한도 {{limit}}보다 큽니다. 차이를 보려면 소스 모드로 전환하세요.", + "renderAnyway": "그래도 렌더링" } }, "featureTips": { @@ -18497,7 +18508,12 @@ "genericDetail": "이는 종종 그래픽 드라이버 또는 설치 문제입니다. 새로고침하여 다시 시도하거나 Orca를 종료하고 다시 시작하세요.", "title": "Orca가 계속 로드되지 않습니다.", "stalledMessage": "충돌 후 다시 로드하는 동안 앱 창이 응답을 멈췄습니다.", - "crashLoopMessage": "앱 창이 반복적으로 충돌하고 자동으로 다시 로드되지 않았습니다." + "crashLoopMessage": "앱 창이 반복적으로 충돌하고 자동으로 다시 로드되지 않았습니다.", + "tryAgain": "다시 시도", + "launchFailedMessage": "Orca가 창을 그리는 프로세스를 시작하지 못했습니다.", + "launchFailedDetail": "Orca가 {{recoveryCount}}번 다시 시도했지만 성공하지 못했습니다.", + "processLimitDetail": "사용자 계정이 프로세스 수 한도에 도달하여 시스템이 새 프로세스 생성을 거부했습니다. 폭주한 터미널, 개발 서버 또는 에이전트가 원인인 경우가 많습니다. 일부를 종료한 다음 다시 시도를 클릭하세요.", + "launchFailedGenericDetail": "시스템이 시작을 거부했습니다. 메모리를 확보하거나 다른 앱을 종료한 다음 다시 시도를 클릭하세요. 계속 발생하면 Orca를 다시 설치하세요." }, "sessionSearch": { "panel": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d907237dea8..63fc2b87de5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11,6 +11,12 @@ "retrying": "正在重试..." }, "settings": { + "terminal": { + "optionLayoutAlt": "ABC 或 U.S. — Option 键发送 Alt/Esc 序列", + "optionLayoutCompose": "此布局使用 Option 键输入 @、€、[、] 等字符", + "optionLayoutUnknown": "未知布局 — Option 键输入字符(安全的默认设置)", + "optionShortcutHint": "选择“两个都”使用 Option 快捷键,选择“关”输入重音字符和符号,或选择“左边”/“右边”让两个 Option 键各司其职。" + }, "appearance": { "language": { "title": "语言", @@ -8549,7 +8555,7 @@ "19f4935159": "JIS 日元 (¥) 至 反斜杠 (\\\\)", "1c337bef4a": "控制按 JIS 日元 (¥) 键是否发送反斜杠 (\\\\)。", "3fe1c5bfe0": "关", - "c73d510938": "正确的", + "c73d510938": "右边", "e7aec1fd60": "左边", "badb1219fc": "两个都", "43c2ff7b0e": "自动", @@ -18410,6 +18416,11 @@ }, "fileLoad": { "hostUnresolved": "主机找不到该文件的工作区。它可能已被删除,或者主机可能还不知道。重试,或从选项卡栏中关闭此选项卡。" + }, + "markdownPreview": { + "tooLarge": "文件超过 {{limit}} 预览限制。请打开文件查看源代码。", + "tooLargeInDiff": "文件超过 {{limit}} 预览限制。请切换到源代码模式查看差异。", + "renderAnyway": "仍然渲染" } }, "featureTips": { @@ -18497,7 +18508,12 @@ "genericDetail": "这通常是图形驱动程序或安装问题。重新加载以重试,或退出并重新启动 Orca。", "title": "Orca 一直无法加载", "stalledMessage": "崩溃后重新加载时应用程序窗口停止响应。", - "crashLoopMessage": "应用程序窗口反复崩溃并自动停止重新加载。" + "crashLoopMessage": "应用程序窗口反复崩溃并自动停止重新加载。", + "tryAgain": "重试", + "launchFailedMessage": "Orca 无法启动绘制其窗口的进程。", + "launchFailedDetail": "Orca 重试了 {{recoveryCount}} 次,但未成功。", + "processLimitDetail": "由于你的用户帐户已达到进程数上限,系统拒绝创建新进程。这通常由失控的终端、开发服务器或代理导致。请关闭其中一些,然后点击“重试”。", + "launchFailedGenericDetail": "系统拒绝启动它。请释放内存或关闭其他应用,然后点击“重试”。如果问题持续出现,请重新安装 Orca。" }, "sessionSearch": { "panel": { diff --git a/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts b/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts index da681a78954..e45faa0ed36 100644 --- a/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts +++ b/src/renderer/src/lib/keyboard-layout/detect-option-as-alt.ts @@ -10,11 +10,12 @@ * The only defensible default is the one that varies per layout. This * module fingerprints the active layout from Chromium's * navigator.keyboard.getLayoutMap() (ships in Chrome 69+, so every Electron - * we could run). The base layer cannot separate US from US-International or - * ABC, so every US-shaped layout maps to `true` here and `input-source-id.ts` - * narrows that to plain US whenever macOS gives us the real input source ID. + * we could run). The base layer cannot separate standard ABC/US from + * US-International, so every US-shaped layout maps to `true` here and + * `input-source-id.ts` narrows that to ABC and US using the native input source ID. * Everything else — Dvorak, Colemak, UK, every international layout — maps - * to `false`. + * to `false`. Missing native identity on macOS also stays conservative: + * an IME can expose a US-shaped backing layout without identifying itself. * */ @@ -59,7 +60,7 @@ export type DetectedLayoutCategory = * Semicolon (`o` vs `;`). Dvorak fails KeyQ immediately. Both get classified * as `non-us` and default to `'false'`; users who want `'true'` flip the * explicit override. The native input-source classifier distinguishes - * plain US from US-shaped composition layouts. + * standard ABC/US from US-shaped international composition layouts. */ const US_FINGERPRINT: Record = { KeyQ: 'q', diff --git a/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts b/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts index 215d19666a7..4ba160a6632 100644 --- a/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts +++ b/src/renderer/src/lib/keyboard-layout/input-source-id.test.ts @@ -8,8 +8,8 @@ describe('classifyInputSourceId', () => { expect(classifyInputSourceId('')).toBe('unknown') }) - it('allowlists plain US Standard as meta', () => { - expect(classifyInputSourceId('com.apple.keylayout.US')).toBe('meta') + it.each(['US', 'ABC'])('allowlists standard %s as meta', (name) => { + expect(classifyInputSourceId(`com.apple.keylayout.${name}`)).toBe('meta') }) it('classifies US International PC as compose (Option+C → ç repro)', () => { @@ -19,13 +19,8 @@ describe('classifyInputSourceId', () => { it('is case-insensitive on the allowlist (defaults differ between macOS versions)', () => { expect(classifyInputSourceId('COM.APPLE.KEYLAYOUT.US')).toBe('meta') expect(classifyInputSourceId('com.apple.keylayout.us')).toBe('meta') - }) - - it('classifies ABC as compose (the user-reported Option+A → å repro)', () => { - // ABC looks US on the base layer but composes Option+A → å. Pre-fix, - // the fingerprint alone drove the decision and flipped - // macOptionIsMeta=true, silently swallowing the composition. - expect(classifyInputSourceId('com.apple.keylayout.ABC')).toBe('compose') + expect(classifyInputSourceId('COM.APPLE.KEYLAYOUT.ABC')).toBe('meta') + expect(classifyInputSourceId('com.apple.keylayout.abc')).toBe('meta') }) it('classifies Polish Pro as compose (#1205)', () => { @@ -38,9 +33,7 @@ describe('classifyInputSourceId', () => { }) it('classifies every other Apple-shipped layout as compose (default-deny)', () => { - // Only plain US is allowlisted; everything else (Dvorak, Colemak, - // German, French, Turkish, Spanish, Swedish, every CJK Roman IME) - // falls back to compose. + // Only standard ABC/US are allowlisted; other layouts retain composition. expect(classifyInputSourceId('com.apple.keylayout.Dvorak')).toBe('compose') expect(classifyInputSourceId('com.apple.keylayout.Colemak')).toBe('compose') expect(classifyInputSourceId('com.apple.keylayout.German')).toBe('compose') @@ -51,10 +44,13 @@ describe('classifyInputSourceId', () => { expect(classifyInputSourceId('com.apple.inputmethod.Korean.2SetKorean')).toBe('compose') }) - it('does not prefix-leak the US allowlist into extended variants', () => { - // `com.apple.keylayout.US` must not silently allowlist `USExtended`. - // The matcher is full-ID equality (case-insensitive), not prefix. + it('does not prefix-leak the standard allowlist into extended or custom variants', () => { expect(classifyInputSourceId('com.apple.keylayout.USExtended')).toBe('compose') expect(classifyInputSourceId('com.apple.keylayout.US.variant')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.ABCExtended')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.ABC.variant')).toBe('compose') + expect(classifyInputSourceId('com.apple.keylayout.ABCInternational')).toBe('compose') + expect(classifyInputSourceId('org.custom.keylayout.ABC')).toBe('compose') + expect(classifyInputSourceId('unknown')).toBe('compose') }) }) diff --git a/src/renderer/src/lib/keyboard-layout/input-source-id.ts b/src/renderer/src/lib/keyboard-layout/input-source-id.ts index 4f5022fb739..1eb2906ffeb 100644 --- a/src/renderer/src/lib/keyboard-layout/input-source-id.ts +++ b/src/renderer/src/lib/keyboard-layout/input-source-id.ts @@ -1,17 +1,19 @@ -// The base-layer probe cannot distinguish US from layouts whose Option layer composes text. -const META_INPUT_SOURCE_IDS: readonly string[] = ['com.apple.keylayout.us'] +// ABC and US share the standard layout; international variants retain Option composition. +const META_INPUT_SOURCE_IDS: readonly string[] = [ + 'com.apple.keylayout.us', + 'com.apple.keylayout.abc' +] export type InputSourceOverride = - /** Option-as-Meta is safe on this input source. Resolves to `'us'` + /** Auto uses Option-as-Meta on this standard input source. Resolves to `'us'` * for `effectiveMacOptionAsAlt`. */ | 'meta' /** Option composes layout characters on this input source. Resolves * to `'non-us'` so `macOptionIsMeta` stays off and compositions like - * Option+A → å / ą reach the shell. */ + * Option+A → ą reach the shell. */ | 'compose' - /** No macOS input source ID available (non-Darwin, IPC failure, - * sandboxed defaults). The caller should fall back to the layout - * fingerprint. */ + /** No input source ID available. macOS stays conservative; other + * platforms may use the layout fingerprint. */ | 'unknown' export function classifyInputSourceId(id: string | null | undefined): InputSourceOverride { diff --git a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts index 3af50308e8c..05b777b2254 100644 --- a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts +++ b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createOptionAsAltProbe } from './option-as-alt-probe' -import type { LayoutMapLike } from './detect-option-as-alt' +import { effectiveMacOptionAsAlt, type LayoutMapLike } from './detect-option-as-alt' import type { KeyboardLayoutChangeEvent } from '../../../../shared/keyboard-layout-events' const US_MAP: LayoutMapLike = { @@ -37,6 +37,7 @@ const TURKISH_MAP: LayoutMapLike = { type MockWindow = { navigator: { + userAgent: string keyboard?: { getLayoutMap: () => Promise } } addEventListener: (type: string, fn: EventListener) => void @@ -44,11 +45,12 @@ type MockWindow = { fireFocus: () => void } -function makeMockWindow(initial: LayoutMapLike | null): MockWindow { +function makeMockWindow(initial: LayoutMapLike | null, userAgent = 'Linux'): MockWindow { const focusListeners = new Set() let current = initial return { navigator: { + userAgent, keyboard: current ? { getLayoutMap: vi.fn(async () => current!) @@ -82,24 +84,29 @@ describe('createOptionAsAltProbe', () => { vi.unstubAllGlobals() }) - it('uses the native snapshot identity before the preference fallback', async () => { - const getKeyboardLayoutSnapshot = vi.fn(async () => ({ - inputSourceId: 'com.apple.keylayout.ABC', - keyCharacters: {} - })) - const getKeyboardInputSourceId = vi.fn(async () => 'com.apple.keylayout.US') - vi.stubGlobal('window', { - api: { app: { getKeyboardLayoutSnapshot, getKeyboardInputSourceId } } - }) - const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window) + it.each(['com.apple.keylayout.ABCExtended', 'com.apple.inputmethod.SCIM.ITABC'])( + 'uses the native input source %s before its backing layout or preference', + async (inputSourceId) => { + const getKeyboardLayoutSnapshot = vi.fn(async () => ({ + inputSourceId, + layoutSourceId: 'com.apple.keylayout.ABC', + keyCharacters: {} + })) + const getKeyboardInputSourceId = vi.fn(async () => 'com.apple.keylayout.US') + vi.stubGlobal('window', { + api: { app: { getKeyboardLayoutSnapshot, getKeyboardInputSourceId } } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(makeMockWindow(US_MAP, 'Macintosh') as unknown as Window) - await probe.refresh() + await probe.refresh() - expect(probe.getCurrent()).toBe('non-us') - expect(getKeyboardLayoutSnapshot).toHaveBeenCalled() - expect(getKeyboardInputSourceId).not.toHaveBeenCalled() - probe.dispose() - }) + expect(probe.getCurrent()).toBe('non-us') + expect(getKeyboardLayoutSnapshot).toHaveBeenCalled() + expect(getKeyboardInputSourceId).not.toHaveBeenCalled() + probe.dispose() + } + ) it('starts as unknown, upgrades after first probe resolves', async () => { const win = makeMockWindow(US_MAP) @@ -158,8 +165,8 @@ describe('createOptionAsAltProbe', () => { probe.dispose() }) - it('invalidates immediately and refreshes on a native layout-change notification', async () => { - let activeInputSourceId = 'com.apple.keylayout.US' + it('updates Auto on ABC/international switches while preserving every explicit mode', async () => { + let activeInputSourceId = 'com.apple.keylayout.ABC' let notifyLayoutChanged: (() => void) | undefined const unsubscribe = vi.fn() const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window, { @@ -169,15 +176,29 @@ describe('createOptionAsAltProbe', () => { return unsubscribe } }) + const expectEffectiveModes = (automatic: 'true' | 'false') => { + expect(effectiveMacOptionAsAlt('auto', probe.getCurrent())).toBe(automatic) + for (const mode of ['true', 'false', 'left', 'right'] as const) { + expect(effectiveMacOptionAsAlt(mode, probe.getCurrent())).toBe(mode) + } + } await probe.refresh() expect(probe.getCurrent()).toBe('us') + expectEffectiveModes('true') - activeInputSourceId = 'com.apple.keylayout.ABC' - notifyLayoutChanged?.() - expect(probe.getCurrent()).toBe('unknown') - await Promise.resolve() - await Promise.resolve() - expect(probe.getCurrent()).toBe('non-us') + for (const [id, category, automatic] of [ + ['USInternational-PC', 'non-us', 'false'], + ['ABC', 'us', 'true'] + ] as const) { + activeInputSourceId = `com.apple.keylayout.${id}` + notifyLayoutChanged?.() + expect(probe.getCurrent()).toBe('unknown') + expectEffectiveModes('false') + await Promise.resolve() + await Promise.resolve() + expect(probe.getCurrent()).toBe(category) + expectEffectiveModes(automatic) + } probe.dispose() expect(unsubscribe).toHaveBeenCalledOnce() @@ -192,7 +213,7 @@ describe('createOptionAsAltProbe', () => { const readInputSourceId = vi .fn<() => Promise>() .mockReturnValueOnce(oldRead) - .mockResolvedValue('com.apple.keylayout.ABC') + .mockResolvedValue('com.apple.keylayout.PolishPro') const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window, { readInputSourceId, subscribeKeyboardLayoutChanged: (callback) => { @@ -202,7 +223,7 @@ describe('createOptionAsAltProbe', () => { }) notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) - finishOldRead('com.apple.keylayout.US') + finishOldRead('com.apple.keylayout.ABC') await Promise.resolve() await Promise.resolve() expect(probe.getCurrent()).toBe('unknown') @@ -281,11 +302,13 @@ describe('createOptionAsAltProbe', () => { }) it('forces non-us when the input source ID is not on the Option-as-Meta allowlist (#1205)', async () => { - // ABC and Polish Pro both report a US-identical base layer to - // getLayoutMap(); without the input-source override they would classify - // as 'us' → macOptionIsMeta=true and swallow every Option+letter - // composition (Option+A → å on ABC, ą on Polish Pro). - for (const id of ['com.apple.keylayout.ABC', 'com.apple.keylayout.PolishPro']) { + // The native ID protects composition even when the base layer matches US. + for (const id of [ + 'com.apple.keylayout.USInternational-PC', + 'com.apple.keylayout.USExtended', + 'com.apple.keylayout.ABCExtended', + 'com.apple.keylayout.PolishPro' + ]) { const win = makeMockWindow(US_MAP) const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId: async () => id @@ -296,27 +319,98 @@ describe('createOptionAsAltProbe', () => { } }) - it('resolves to us when the input source ID is plain US (allowlist match)', async () => { - const win = makeMockWindow(US_MAP) - const probe = createOptionAsAltProbe(win as unknown as Window, { - readInputSourceId: async () => 'com.apple.keylayout.US' - }) + it.each(['US', 'ABC'])( + 'resolves to us for standard %s without a browser layout map', + async (id) => { + const win = makeMockWindow(null, 'Macintosh') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => `com.apple.keylayout.${id}` + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + probe.dispose() + } + ) + + it.each(['Linux', 'Windows'])( + 'uses the fingerprint without native identity on %s', + async (userAgent) => { + const win = makeMockWindow(US_MAP, userAgent) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window, { + readInputSourceId: async () => null + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + probe.dispose() + } + ) + + it.each(['unavailable', 'rejected'] as const)( + 'stays conservative on macOS when current-source identity is %s', + async (result) => { + const win = makeMockWindow(US_MAP, 'Macintosh') + const readInputSourceId = vi.fn(async () => { + if (result === 'rejected') { + throw new Error('identity unavailable') + } + return null + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId }) + await probe.refresh() + expect(probe.getCurrent()).toBe('unknown') + expect(effectiveMacOptionAsAlt('auto', probe.getCurrent())).toBe('false') + for (const mode of ['true', 'false', 'left', 'right'] as const) { + expect(effectiveMacOptionAsAlt(mode, probe.getCurrent())).toBe(mode) + } + expect(win.navigator.keyboard?.getLayoutMap).not.toHaveBeenCalled() + probe.dispose() + } + ) + + it('stays conservative on macOS without either native identity API', async () => { + vi.stubGlobal('window', { api: { app: {} } }) + const win = makeMockWindow(US_MAP, 'Macintosh') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(win as unknown as Window) await probe.refresh() - expect(probe.getCurrent()).toBe('us') + expect(probe.getCurrent()).toBe('unknown') + expect(win.navigator.keyboard?.getLayoutMap).not.toHaveBeenCalled() probe.dispose() }) - it('falls back to the fingerprint when the input-source reader returns null (non-Darwin)', async () => { - const win = makeMockWindow(US_MAP) + it('recovers macOS source identity after losing it without trusting the backing map', async () => { + let activeInputSourceId: string | null = 'com.apple.keylayout.ABC' + const win = makeMockWindow(US_MAP, 'Macintosh') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. const probe = createOptionAsAltProbe(win as unknown as Window, { - readInputSourceId: async () => null + readInputSourceId: async () => activeInputSourceId }) - await probe.refresh() - expect(probe.getCurrent()).toBe('us') + const listener = vi.fn() + probe.subscribe(listener) + for (const [id, category] of [ + ['com.apple.keylayout.ABC', 'us'], + [null, 'unknown'], + ['com.apple.inputmethod.SCIM.ITABC', 'non-us'], + ['com.apple.keylayout.ABC', 'us'] + ] as const) { + activeInputSourceId = id + await probe.refresh() + expect(probe.getCurrent()).toBe(category) + } + expect(listener.mock.calls.map(([category]) => category)).toEqual([ + 'us', + 'unknown', + 'non-us', + 'us' + ]) + expect(win.navigator.keyboard?.getLayoutMap).not.toHaveBeenCalled() probe.dispose() }) - it('falls back to the fingerprint when the input-source reader throws', async () => { + it('falls back to the fingerprint off macOS when the input-source reader throws', async () => { const win = makeMockWindow(TURKISH_MAP) const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId: async () => { @@ -329,10 +423,8 @@ describe('createOptionAsAltProbe', () => { }) it('re-probes the input source ID on focus-in so mid-session layout switches are picked up', async () => { - // Simulate: user boots on US, flips to ABC via the Input Source menu, - // Orca regains focus. Fingerprint stays US the whole time; the - // input-source override is what notices the switch. - let activeInputSourceId: string | null = 'com.apple.keylayout.US' + // The browser fingerprint stays US while the native identity changes. + let activeInputSourceId: string | null = 'com.apple.keylayout.ABC' const win = makeMockWindow(US_MAP) const probe = createOptionAsAltProbe(win as unknown as Window, { readInputSourceId: async () => activeInputSourceId @@ -340,7 +432,7 @@ describe('createOptionAsAltProbe', () => { await probe.refresh() expect(probe.getCurrent()).toBe('us') - activeInputSourceId = 'com.apple.keylayout.ABC' + activeInputSourceId = 'com.apple.keylayout.USInternational-PC' win.fireFocus() // Let the focus-triggered probe resolve. await Promise.resolve() @@ -368,13 +460,64 @@ describe('createOptionAsAltProbe', () => { }) const newestProbe = probe.refresh() - resolveNew('com.apple.keylayout.ABC') + resolveNew('com.apple.keylayout.PolishPro') await newestProbe expect(probe.getCurrent()).toBe('non-us') - resolveOld('com.apple.keylayout.US') + resolveOld('com.apple.keylayout.ABC') await Promise.resolve() await Promise.resolve() expect(probe.getCurrent()).toBe('non-us') probe.dispose() }) + + it.each(['before', 'after'] as const)( + 'fences superseded layout generations when the stale read resolves %s the newest', + async (order) => { + let activeRead: Promise = Promise.resolve('com.apple.keylayout.ABC') + let notifyLayoutChanged: ((event: KeyboardLayoutChangeEvent) => void) | undefined + const readInputSourceId = vi.fn(() => activeRead) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mock supplies every Window member the probe reads. + const probe = createOptionAsAltProbe(makeMockWindow(US_MAP) as unknown as Window, { + readInputSourceId, + subscribeKeyboardLayoutChanged: (callback) => { + notifyLayoutChanged = callback + return vi.fn() + } + }) + await probe.refresh() + expect(probe.getCurrent()).toBe('us') + const listener = vi.fn() + probe.subscribe(listener) + + const staleRead = Promise.withResolvers() + const newestRead = Promise.withResolvers() + activeRead = staleRead.promise + notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) + notifyLayoutChanged?.({ phase: 'refresh', generation: 1 }) + notifyLayoutChanged?.({ phase: 'invalidated', generation: 2 }) + const readsBeforeStaleNotifications = readInputSourceId.mock.calls.length + notifyLayoutChanged?.({ phase: 'refresh', generation: 1 }) + notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) + expect(readInputSourceId).toHaveBeenCalledTimes(readsBeforeStaleNotifications) + + activeRead = newestRead.promise + notifyLayoutChanged?.({ phase: 'refresh', generation: 2 }) + if (order === 'before') { + staleRead.resolve('com.apple.keylayout.PolishPro') + await Promise.resolve() + expect(probe.getCurrent()).toBe('unknown') + } + newestRead.resolve('com.apple.keylayout.ABC') + await Promise.resolve() + expect(probe.getCurrent()).toBe('us') + if (order === 'after') { + staleRead.resolve('com.apple.keylayout.PolishPro') + await Promise.resolve() + } + notifyLayoutChanged?.({ phase: 'invalidated', generation: 1 }) + expect(probe.getCurrent()).toBe('us') + expect(listener.mock.calls.map(([category]) => category)).toEqual(['unknown', 'us']) + probe.dispose() + } + ) }) diff --git a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts index 2ae80dff636..5694969a91a 100644 --- a/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts +++ b/src/renderer/src/lib/keyboard-layout/option-as-alt-probe.ts @@ -4,16 +4,12 @@ * Runs at boot, on native macOS input-source notifications, and on focus as a * fallback. The browser Keyboard API has no usable layout-change event. * - * Why two signals (input source ID + fingerprint): the fingerprint can - * only see the base (unshifted) layer, which is identical to US QWERTY - * on a large set of Apple-shipped layouts — ABC, Polish Pro, US - * Extended, ABC Extended, and every CJK Roman IME all trap on it. They - * repurpose Option for dead-key composition (Option+A → å / ą), so - * trusting the fingerprint alone makes macOptionIsMeta=true and - * silently swallows those characters (issue #1205). On macOS we treat - * the input source ID as authoritative and only fall back to the - * fingerprint when the ID is unavailable (non-Darwin, sandboxed - * defaults, IPC failure). See ./input-source-id.ts for the allowlist. + * The base-layer fingerprint cannot distinguish standard ABC/US from + * composition layouts such as Polish Pro, US Extended, ABC Extended, + * and CJK Roman IMEs. Native identity protects their Option text (#1205); + * macOS stays conservative without native identity; other platforms use + * the browser fingerprint as a fallback. + * See ./input-source-id.ts for the exact standard-layout allowlist. */ import { detectOptionAsAltFromLayoutMap, @@ -100,9 +96,7 @@ function defaultInputSourceIdReader(): InputSourceIdReader { try { return await reader() } catch { - // Why: the IPC can transiently reject during main-process teardown - // (e.g. app quitting mid-probe). Treat as no signal so the - // fingerprint remains the sole input. + // Missing identity stays conservative on macOS, including during teardown. return null } } @@ -118,6 +112,7 @@ export function createOptionAsAltProbe( let probeGeneration = 0 let layoutChangeGeneration = 0 let layoutRefreshBlocked = false + const isMac = win.navigator.userAgent.includes('Mac') const readInputSourceId = options.readInputSourceId ?? defaultInputSourceIdReader() const subscribeKeyboardLayoutChanged = options.subscribeKeyboardLayoutChanged ?? defaultKeyboardLayoutChangeSubscriber() @@ -144,14 +139,12 @@ export function createOptionAsAltProbe( const nav = win.navigator as NavigatorWithKeyboard const keyboard = nav?.keyboard - // Why: read the input-source ID first. On macOS this resolves to a - // concrete ID (e.g. com.apple.keylayout.ABC); on every other platform - // it resolves to null and we fall through to the fingerprint. + // Read current-source identity before trusting a potentially IME-backed base layer. let inputSourceId: string | null = null try { inputSourceId = await readInputSourceId() } catch { - // Treat errors as no signal — the fingerprint still runs below. + // Missing identity stays conservative on macOS. inputSourceId = null } @@ -169,6 +162,10 @@ export function createOptionAsAltProbe( notify('non-us') return } + if (isMac) { + notify('unknown') + return + } if (!keyboard?.getLayoutMap) { // Non-Chromium or Electron stripped of the Keyboard API. Stay at diff --git a/src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts b/src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts deleted file mode 100644 index ccc239a97be..00000000000 --- a/src/renderer/src/lib/session-write-subscriber-acknowledgment.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useAppStore, type AppState } from '@/store' -import { - createSessionWriteSubscriber, - type WorkspaceSessionWrite -} from './session-write-subscriber' - -function acknowledgment() { - let resolve!: () => void - let reject!: (error: unknown) => void - const promise = new Promise((yes, no) => { - resolve = yes - reject = no - }) - return { promise, resolve, reject } -} - -describe('session write acknowledgments', () => { - let initial: AppState - let dispose: (() => void) | undefined - beforeEach(() => { - initial = useAppStore.getState() - vi.useFakeTimers() - }) - afterEach(() => { - dispose?.() - dispose = undefined - vi.useRealTimers() - useAppStore.setState(initial, true) - }) - - function setup() { - const first = acknowledgment() - const persist = vi - .fn<(write: WorkspaceSessionWrite) => void | Promise>() - .mockImplementationOnce(() => first.promise) - const onPersistError = vi.fn() - let wake: (() => void) | undefined - dispose = createSessionWriteSubscriber({ - store: useAppStore, - persist, - onPersistError, - shouldSchedulePersist: () => true, - subscribeToPersistGateOpen: (listener) => { - wake = listener - return () => { - wake = undefined - } - } - }) - useAppStore.setState({ - workspaceSessionReady: true, - hydrationSucceeded: true, - activeTabId: 'old' - }) - vi.advanceTimersByTime(200) - return { first, persist, onPersistError, wake: () => wake?.() } - } - - it('serializes writes and preserves a newer edit to the in-flight field', async () => { - const { first, persist } = setup() - useAppStore.setState({ activeTabId: 'new' }) - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(1) - first.resolve() - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist.mock.calls[1][0].patch.activeTabId).toBe('new') - }) - - it.each(['gate', 'store'] as const)( - 'retains rejected intent until a %s wake and rebuilds fresh state', - async (source) => { - const { first, persist, onPersistError, wake } = setup() - useAppStore.setState({ activeTabId: 'new' }) - first.reject(new Error('retirement publication deferred')) - await vi.advanceTimersByTimeAsync(10_000) - expect(persist).toHaveBeenCalledTimes(1) - expect(onPersistError).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(0) - if (source === 'gate') { - wake() - } else { - useAppStore.getState().setCacheTimerStartedAt('tab:pane', Date.now()) - } - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist.mock.calls[1][0].patch.activeTabId).toBe('new') - expect(persist.mock.calls[1][0].patch).toHaveProperty('activeRepoId') - } - ) - - it.each(['resolve', 'reject'] as const)( - 'does not resume after disposal and late %s', - async (outcome) => { - const { first, persist, onPersistError, wake } = setup() - useAppStore.setState({ activeTabId: 'new' }) - dispose?.() - if (outcome === 'resolve') { - first.resolve() - } else { - first.reject(new Error('late failure')) - } - wake() - await vi.advanceTimersByTimeAsync(10_000) - expect(persist).toHaveBeenCalledTimes(1) - expect(onPersistError).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - } - ) - - it('retains a synchronous throw for a later wake', async () => { - const persist = vi.fn<(write: WorkspaceSessionWrite) => void>().mockImplementationOnce(() => { - throw new Error('write refused') - }) - dispose = createSessionWriteSubscriber({ store: useAppStore, persist }) - useAppStore.setState({ - workspaceSessionReady: true, - hydrationSucceeded: true, - activeTabId: 'retained' - }) - await vi.advanceTimersByTimeAsync(200) - expect(vi.getTimerCount()).toBe(0) - useAppStore.getState().setCacheTimerStartedAt('tab:pane', Date.now()) - await vi.advanceTimersByTimeAsync(200) - expect(persist).toHaveBeenCalledTimes(2) - expect(persist.mock.calls[1][0].patch.activeTabId).toBe('retained') - }) -}) diff --git a/src/renderer/src/lib/session-write-subscriber-allocation.test.ts b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts index 2532002f9e7..3f2c5ce284c 100644 --- a/src/renderer/src/lib/session-write-subscriber-allocation.test.ts +++ b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts @@ -50,9 +50,7 @@ function createHarness() { }, getState: () => state }, - persist: (payload) => { - persisted.push(payload) - } + persist: (payload) => persisted.push(payload) }) return { dispose, @@ -176,9 +174,7 @@ describe('session write subscriber allocation', () => { }, getState: () => state }, - persist: (payload) => { - persisted.push(payload) - }, + persist: (payload) => persisted.push(payload), shouldSchedulePersist: () => gateOpen, subscribeToPersistGateOpen: () => () => {} }) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index 581d9e875c6..7e7e9cf7360 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -101,8 +101,7 @@ export type SessionWriteSubscriberDeps = { subscribe: (listener: (state: AppState) => void) => () => void getState: () => AppState } - persist: (payload: WorkspaceSessionWrite) => void | Promise - onPersistError?: (error: unknown) => void + persist: (payload: WorkspaceSessionWrite) => void debounceMs?: number } & SessionWritePersistGate @@ -115,14 +114,11 @@ export type SessionWriteSubscriberDeps = { export function createSessionWriteSubscriber({ store, persist, - onPersistError, shouldSchedulePersist, subscribeToPersistGateOpen, debounceMs = 150 }: SessionWriteSubscriberDeps): () => void { let timer: ReturnType | null = null - let disposed = false - let writing = false // Why: the subscriber fires on every store update (agent status, usage // refreshes, runtime title ticks, …). Without this gate each fire reset // the debounce, and when it finally expired buildWorkspaceSessionPayload @@ -137,17 +133,14 @@ export function createSessionWriteSubscriber({ let prevUnifiedTabsSource: UnifiedTabsByWorktree | null = null // Why: this set is the only record that a mutation still owes a write — `prev` has already // advanced past it, and change detection is identity-based, so a field dropped from here can - // never be re-detected. In-flight fields remain owned by that write until acknowledgment; - // failures merge them back without overwriting newer same-field edits. + // never be re-detected. It is retired only by a flush that reached `persist` (or found nothing + // left to write), and by unsubscribe. A closed gate never retires it. const pendingChangedFields = new Set() const terminalTabsProjection = createTerminalSessionTabsProjection() const unifiedTabsProjection = createUnifiedSessionTabsProjection() const flushPendingWrite = (): void => { timer = null - if (disposed || writing || pendingChangedFields.size === 0) { - return - } // Why: rebuild from the freshest store state rather than the snapshot // captured when this timer was scheduled. Today this is equivalent // because buildWorkspaceSessionPayload reads only SESSION_RELEVANT_FIELDS @@ -172,41 +165,10 @@ export function createSessionWriteSubscriber({ if (Object.keys(patch).length === 0) { return } - writing = true - const settle = (failed: boolean, error?: unknown): void => { - writing = false - if (disposed) { - return - } - if (failed) { - for (const field of changed) { - pendingChangedFields.add(field) - } - // A rejection waits for the next store/gate wake, not a retry loop. - onPersistError?.(error) - } else if (pendingChangedFields.size > 0) { - armFlushTimer() - } - } - try { - const result = persist({ patch }) - if (result && typeof result.then === 'function') { - void result.then( - () => settle(false), - (error: unknown) => settle(true, error) - ) - } else { - settle(false) - } - } catch (error) { - settle(true, error) - } + persist({ patch }) } const armFlushTimer = (): void => { - if (disposed || writing) { - return - } if (timer !== null) { clearTimeout(timer) } @@ -310,7 +272,6 @@ export function createSessionWriteSubscriber({ }) return () => { - disposed = true unsub() unsubGateOpen?.() if (timer !== null) { diff --git a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts b/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts deleted file mode 100644 index 345a0db60d4..00000000000 --- a/src/renderer/src/runtime/web-session-split-pane-retirement-fences.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import { collectLeafIds } from '../components/terminal-pane/terminal-pane-layout-tree' -import { - planTerminalLiveLayoutRemovals, - selectRetiredPaneIds, - trackRetiredLeafIds -} from '../components/terminal-pane/terminal-live-layout-reconciliation' -import { applyFreshWebSessionTabsSnapshot } from './web-session-tabs-sync' -import { - clearWebSessionTerminalOrphanRecoveryForTests, - recoverWebSessionTerminalOrphansBeforeApply -} from './web-session-terminal-orphan-recovery' -import { - ENV, - LEAF_ID, - SECOND_LEAF_ID, - makeSnapshot, - makeState, - resetWebSessionTabsSyncTestState -} from './web-session-tabs-sync-test-harness' - -vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } })) -vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ - observeAgentHookCompletionForNotification: vi.fn() -})) - -const TAB_ID = 'web-terminal-host-tab-1' -const mountedLeaves = [LEAF_ID, SECOND_LEAF_ID] - -function snapshot(version: number, leaves = mountedLeaves): RuntimeMobileSessionTabsResult { - return makeSnapshot( - leaves.map((leafId) => ({ - type: 'terminal' as const, - id: `host-tab-1::${leafId}`, - parentTabId: 'host-tab-1', - leafId, - title: 'shell', - isActive: leafId === LEAF_ID, - status: 'ready' as const, - terminal: `terminal-${leafId}` - })), - { snapshotVersion: version } - ) -} - -function retiredSnapshot(): RuntimeMobileSessionTabsResult { - return { - ...snapshot(3, [LEAF_ID]), - retiredTerminalSurfaces: [ - { - parentTabId: 'host-tab-1', - leafId: SECOND_LEAF_ID, - terminal: `terminal-${SECOND_LEAF_ID}`, - ptyId: 'native-second', - incarnationId: 'inc-second' - } - ] - } -} - -function createReconciliation() { - let state = makeState() - let previousLayoutLeafIds: ReadonlySet = new Set() - let retiredLeafIds: ReadonlySet = new Set() - const mounted = new Set(mountedLeaves) - const call = vi.fn(async () => { - throw new Error('execution host unavailable') - }) - - function plan(secondPtyId: string | null): number[] { - const root = state.terminalLayoutsByTabId[TAB_ID]?.root - expect(root).toBeDefined() - const layoutLeafIds = new Set(root ? collectLeafIds(root) : []) - retiredLeafIds = trackRetiredLeafIds({ - retiredLeafIds, - previousLayoutLeafIds, - layoutLeafIds, - mountedLeafIds: mounted - }) - previousLayoutLeafIds = layoutLeafIds - return selectRetiredPaneIds(planTerminalLiveLayoutRemovals(root, mounted, retiredLeafIds), { - paneCount: mounted.size, - paneIdForLeaf: (leaf) => (leaf === LEAF_ID ? 1 : 2), - ptyIdForPane: (pane) => (pane === 1 ? 'remote:first' : secondPtyId) - }) - } - - return { - call, - plan, - removeSecond: () => mounted.delete(SECOND_LEAF_ID), - leaves: () => collectLeafIds(state.terminalLayoutsByTabId[TAB_ID].root!), - async receive(incoming: RuntimeMobileSessionTabsResult) { - const recovered = await recoverWebSessionTerminalOrphansBeforeApply(state, incoming, ENV, { - call - }) - expect(recovered).not.toBeNull() - if (recovered) { - state = { ...state, ...applyFreshWebSessionTabsSnapshot(state, recovered, ENV) } - } - } - } -} - -describe('host snapshot fences before split-pane retirement', () => { - beforeEach(() => { - resetWebSessionTabsSyncTestState() - clearWebSessionTerminalOrphanRecoveryForTests() - }) - - it('keeps a null-transport pane when an older layout arrives', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - expect(view.plan('remote:second')).toEqual([]) - await view.receive(retiredSnapshotWithVersion(1)) - expect(view.leaves()).toEqual(mountedLeaves) - expect(view.plan(null)).toEqual([]) - }) - - it('retains a missing pane when a newer layout cannot verify the host inventory', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - view.plan('remote:second') - await view.receive(snapshot(3, [LEAF_ID])) - expect(view.call).toHaveBeenCalled() - expect(view.leaves()).toContain(SECOND_LEAF_ID) - expect(view.plan(null)).toEqual([]) - }) - - it('defers proven retirement until the transport clears and does not close twice', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - view.plan('remote:second') - await view.receive(retiredSnapshot()) - expect(view.leaves()).toEqual([LEAF_ID]) - expect(view.plan('remote:second')).toEqual([]) - expect(view.plan(null)).toEqual([2]) - view.removeSecond() - expect(view.plan(null)).toEqual([]) - }) - - it('clears deferred retirement when the host reintroduces the leaf before detach', async () => { - const view = createReconciliation() - await view.receive(snapshot(2)) - view.plan('remote:second') - await view.receive(retiredSnapshot()) - expect(view.plan('remote:second')).toEqual([]) - await view.receive(snapshot(4)) - expect(view.leaves()).toEqual(mountedLeaves) - expect(view.plan(null)).toEqual([]) - }) -}) - -function retiredSnapshotWithVersion(snapshotVersion: number): RuntimeMobileSessionTabsResult { - return { ...retiredSnapshot(), snapshotVersion } -} diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index cd290606fb2..787e130821d 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -16,6 +16,7 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -94,6 +95,7 @@ describe('WebRuntimeClient', () => { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/renderer/src/web/web-runtime-connection-frame-router.ts b/src/renderer/src/web/web-runtime-connection-frame-router.ts index 626b49f5612..78dbf6f0553 100644 --- a/src/renderer/src/web/web-runtime-connection-frame-router.ts +++ b/src/renderer/src/web/web-runtime-connection-frame-router.ts @@ -5,6 +5,7 @@ import { AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, @@ -66,6 +67,7 @@ export async function routeWebRuntimeConnectionFrame( AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, diff --git a/src/shared/agent-hook-listener-omp-approval-ownership.test.ts b/src/shared/agent-hook-listener-omp-approval-ownership.test.ts index 1e8850404ed..d8de670b224 100644 --- a/src/shared/agent-hook-listener-omp-approval-ownership.test.ts +++ b/src/shared/agent-hook-listener-omp-approval-ownership.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { createHookListenerState, + seedLegacyAgentStatusForTests, type HookListenerState } from './agent-hook-listener/listener-state' import { normalizeHookPayload } from './agent-hook-listener' @@ -89,6 +90,43 @@ describe('OMP approval ownership', () => { ).toMatchObject({ state: 'working', toolName: 'bash' }) }) + /** Normalizes the post and admits its row, as the hook server does, so the next post sees it. */ + function deliver(payload: Record): string { + const event = post('omp', payload) + if (event) { + seedLegacyAgentStatusForTests(state, event) + } + return state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state ?? 'none' + } + + // The order omp 17 posted for a denied bash call, captured off a live run's extension events. + it('keeps the approval wait through the tool_execution_start omp posts right after it', () => { + expect(deliver({ hook_event_name: 'tool_call', tool_name: 'bash' })).toBe('working') + expect( + deliver({ + hook_event_name: 'tool_approval_requested', + tool_name: 'bash', + approval_mode: 'always-ask' + }) + ).toBe('blocked') + expect(deliver({ hook_event_name: 'tool_execution_start', tool_name: 'bash' })).toBe('blocked') + expect( + deliver({ hook_event_name: 'tool_approval_resolved', tool_name: 'bash', approved: false }) + ).toBe('working') + expect(deliver({ hook_event_name: 'tool_execution_end', tool_name: 'bash' })).toBe('working') + expect(deliver({ hook_event_name: 'agent_end' })).toBe('done') + }) + + it('ends a held approval wait when a new turn starts', () => { + deliver({ hook_event_name: 'tool_approval_requested', tool_name: 'bash' }) + expect(deliver({ hook_event_name: 'agent_start' })).toBe('working') + }) + + it('lets an ask wait end on its own tool_execution_end', () => { + expect(deliver({ hook_event_name: 'tool_execution_start', tool_name: 'ask' })).toBe('blocked') + expect(deliver({ hook_event_name: 'tool_execution_end', tool_name: 'ask' })).toBe('working') + }) + // Pi and prime-agent share this normalizer but have no approval lifecycle, so the field must not // give their panes a status row. it.each(['always-ask', 'write', 'yolo'])( diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family-events.ts index c8704419163..9786f09fb56 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-events.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -81,7 +81,10 @@ export function normalizePiCompatibleEvent( stateName = hookPayload.is_idle === true ? 'done' : 'working' } - if (!stateName) { + if ( + !stateName || + (stateName === 'working' && holdsOmpApproval(state, agentType, eventName, paneKey)) + ) { return null } @@ -107,3 +110,30 @@ export function normalizePiCompatibleEvent( lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput }) } + +const OMP_APPROVAL_RELEASE_EVENTS: ReadonlySet = new Set([ + 'tool_approval_resolved', + 'before_agent_start', + 'agent_start' +]) + +/** + * Whether an OMP approval wait must survive this working event. omp posts `tool_execution_start` + * right after `tool_approval_requested`, while its Approve/Deny select still holds the human, so + * only the resolution (or a new turn) ends the wait. An `ask` row is its own tool's wait, which + * `tool_execution_end` ends. + */ +function holdsOmpApproval( + state: HookListenerState, + agentType: 'pi' | 'omp' | 'prime-agent', + eventName: unknown, + paneKey: string +): boolean { + if (agentType !== 'omp' || OMP_APPROVAL_RELEASE_EVENTS.has(eventName)) { + return false + } + const previous = state.lastStatusByPaneKey.get(paneKey)?.payload + return ( + previous?.agentType === 'omp' && previous.state === 'blocked' && previous.toolName !== 'ask' + ) +} diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 548b263bbfe..e5f8125a5c0 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -4,8 +4,9 @@ // so no class instances, Maps, or Dates. // // Rows are append-only. `schemaVersion` is upcast at read time and never -// rewritten in place, so a host that cannot read a row refuses to write the -// journal rather than skipping or compacting past it. +// rewritten in place, so a host that cannot read a row (a newer version, or a +// newer kind) refuses to write the journal rather than skipping or compacting +// past it. import type { UnreadAgentSessionFailureFact } from './agent-session-failure' import type { AgentSessionFailureRowWords } from './agent-session-failure-words' diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 8575f0fddd2..a692c2890c0 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { promisify } from 'node:util' @@ -13,6 +13,7 @@ import { isForEachRefExcludeUnsupportedError } from './git-ref-command-capabilit import { isNoWriteFetchHeadUnsupportedError } from './git-fetch-head-capability' import { hasUnsupportedRevParsePathFormatEcho, + isUnsupportedWorktreeAddLockReasonError, isUnsupportedWorktreeListZError } from './git-worktree-command-capabilities' import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env' @@ -206,15 +207,36 @@ describeBinaryCompatibility('real Git binary compatibility', () => { it('supports prepared worktree creation and finalization', async () => { const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() - await runGit(['worktree', 'add', '--detach', '--no-checkout', 'compat-prepared', 'HEAD']) + const lockPath = join(repoPath, '.git', 'worktrees', 'compat-prepared', 'locked') + const lockReason = 'orca-create-preparation:v1:compat\n' + try { + await runGit([ + 'worktree', + 'add', + '--detach', + '--no-checkout', + '--lock', + '--reason', + lockReason.slice(0, -1), + 'compat-prepared', + 'HEAD' + ]) + expect(supports(2, 33)).toBe(true) + } catch (error) { + expect(supports(2, 33)).toBe(false) + expect(isUnsupportedWorktreeAddLockReasonError(error)).toBe(true) + await runGit(['worktree', 'add', '--detach', '--no-checkout', 'compat-prepared', 'HEAD']) + await writeFile(lockPath, lockReason, { flag: 'wx' }) + } + await expect(readFile(lockPath, 'utf8')).resolves.toBe(lockReason) + await expect(readFile(join(repoPath, 'compat-prepared', 'tracked.txt'))).rejects.toThrow() await runGit(['-C', 'compat-prepared', 'reset', '--hard', 'HEAD']) - await runGit([ - 'worktree', - 'lock', - '--reason', - 'orca-create-preparation:v1:compat', - 'compat-prepared' - ]) + expect( + (await runGit(['-C', 'compat-prepared', 'rev-parse', '--git-path', 'locked'])).stdout.trim() + ).toContain('worktrees/compat-prepared/locked') + expect( + (await runGit(['-C', 'compat-prepared', 'rev-parse', '--git-common-dir'])).stdout.trim() + ).toContain('.git') // Why: `-f -f` moves a locked preparation while preserving its lock reason (Git >=2.25). await runGit(['worktree', 'move', '-f', '-f', 'compat-prepared', 'compat-final']) await runGit([ @@ -233,7 +255,8 @@ describeBinaryCompatibility('real Git binary compatibility', () => { await expect(runGit(['-C', 'compat-final', 'rev-parse', 'HEAD'])).resolves.toMatchObject({ stdout: `${head}\n` }) - await runGit(['worktree', 'unlock', 'compat-final']) + await expect(readFile(lockPath, 'utf8')).resolves.toBe(lockReason) + await unlink(lockPath) await runGit(['worktree', 'remove', '--force', 'compat-final']) await runGit(['branch', '-D', 'compat-prepared-final']) }) @@ -433,6 +456,23 @@ describeBinaryCompatibility('real Git binary compatibility', () => { await runGit(['config', '--unset', 'maintenance.auto']) }) + it('writes a multi-pack-index and reads packed objects at the baseline', async () => { + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() + await runGit(['-c', 'gc.auto=0', 'repack', '-d']) + await expect(runGit(['multi-pack-index', 'write'])).resolves.toBeDefined() + const midx = await readFile(join(repoPath, '.git', 'objects', 'pack', 'multi-pack-index')) + expect(midx.subarray(0, 4).toString()).toBe('MIDX') + await expect(runGit(['multi-pack-index', 'verify'])).resolves.toMatchObject({ stderr: '' }) + await expect( + runGit(['-c', 'core.multiPackIndex=true', 'cat-file', '-t', head]) + ).resolves.toMatchObject({ stdout: 'commit\n' }) + await runGit(['config', 'core.multiPackIndex', 'false']) + await expect( + runGit(['config', '--bool', '--get', 'core.multiPackIndex']) + ).resolves.toMatchObject({ stdout: 'false\n' }) + await runGit(['config', '--unset', 'core.multiPackIndex']) + }) + it('fetches hosted review heads into dedicated refs', async () => { const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() await runGit(['update-ref', 'refs/pull/42/head', head]) diff --git a/src/shared/git-capability-cache.ts b/src/shared/git-capability-cache.ts index 06d0e957c74..5b77e01d1fb 100644 --- a/src/shared/git-capability-cache.ts +++ b/src/shared/git-capability-cache.ts @@ -10,6 +10,7 @@ export type GitCapability = | 'merge-tree-merge-base' | 'merge-tree-write-tree' | 'rev-parse-path-format' + | 'worktree-add-lock-reason' | 'worktree-list-z' export class GitCapabilityCache extends CapabilityProbeCache { diff --git a/src/shared/git-worktree-command-capabilities.ts b/src/shared/git-worktree-command-capabilities.ts index 76bd705432c..fa3f38dab0f 100644 --- a/src/shared/git-worktree-command-capabilities.ts +++ b/src/shared/git-worktree-command-capabilities.ts @@ -29,6 +29,12 @@ export function isUnsupportedRevParsePathFormatError(error: unknown): boolean { ) } +export function isUnsupportedWorktreeAddLockReasonError(error: unknown): boolean { + return /\b(?:unknown|invalid|unrecognized) (?:switch|option)[^\r\n]*(?:--)?reason\b/i.test( + getGitErrorText(error) + ) +} + export function hasUnsupportedRevParsePathFormatEcho(output: string): boolean { return output.split(/\r?\n/).some((line) => line.startsWith('--path-format')) } diff --git a/src/shared/managed-orcad-ssh-owner.test.ts b/src/shared/managed-orcad-ssh-owner.test.ts deleted file mode 100644 index 5d410d81acc..00000000000 --- a/src/shared/managed-orcad-ssh-owner.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createManagedOrcadSshOwner, - getManagedOrcadOwnerEnvironmentId, - isEphemeralRuntimeSshOwner -} from './managed-orcad-ssh-owner' - -describe('managed orcad SSH ownership', () => { - it('writes a marker that older clients keep hidden as an internal target', () => { - const owner = createManagedOrcadSshOwner('environment-1') - - expect(owner).toEqual({ - type: 'on-demand-runtime', - runtimeId: 'managed-orcad:environment-1' - }) - expect(getManagedOrcadOwnerEnvironmentId(owner)).toBe('environment-1') - expect(isEphemeralRuntimeSshOwner(owner)).toBe(false) - }) - - it('keeps ordinary on-demand runtime targets ephemeral', () => { - const owner = { type: 'on-demand-runtime' as const, runtimeId: 'runtime-1' } - - expect(getManagedOrcadOwnerEnvironmentId(owner)).toBeNull() - expect(isEphemeralRuntimeSshOwner(owner)).toBe(true) - }) -}) diff --git a/src/shared/managed-orcad-ssh-owner.ts b/src/shared/managed-orcad-ssh-owner.ts deleted file mode 100644 index f3c554d715b..00000000000 --- a/src/shared/managed-orcad-ssh-owner.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { SshTarget } from './ssh-types' - -// Why on-demand-runtime: shipped builds already hide targets with that owner, so a downgrade keeps it fenced. -const MANAGED_ORCAD_RUNTIME_ID_PREFIX = 'managed-orcad:' - -type SshTargetOwner = NonNullable - -export function createManagedOrcadSshOwner(environmentId: string): SshTargetOwner { - return { - type: 'on-demand-runtime', - runtimeId: `${MANAGED_ORCAD_RUNTIME_ID_PREFIX}${environmentId}` - } -} - -export function getManagedOrcadOwnerEnvironmentId( - owner: SshTargetOwner | undefined -): string | null { - if (!owner) { - return null - } - const environmentId = owner.runtimeId.startsWith(MANAGED_ORCAD_RUNTIME_ID_PREFIX) - ? owner.runtimeId.slice(MANAGED_ORCAD_RUNTIME_ID_PREFIX.length) - : '' - return environmentId || null -} - -export function isEphemeralRuntimeSshOwner(owner: SshTargetOwner | undefined): boolean { - return owner?.type === 'on-demand-runtime' && getManagedOrcadOwnerEnvironmentId(owner) === null -} diff --git a/src/shared/orcad-decommission.ts b/src/shared/orcad-decommission.ts deleted file mode 100644 index 9d1c9c3f789..00000000000 --- a/src/shared/orcad-decommission.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** What a client learns from decommissioning a managed orcad. */ -import { z } from 'zod' - -export const OrcadDecommissionResultSchema = z.discriminatedUnion('outcome', [ - z.object({ - outcome: z.literal('decommissioned'), - version: z.string().min(1).max(255), - /** The daemon's fate: `retired`, or kept with its terminals (`live`/`unverifiable`). */ - retirement: z.enum(['retired', 'live', 'unverifiable']) - }), - z.object({ - outcome: z.literal('refused'), - verdict: z.enum(['live', 'unverifiable']), - code: z.string(), - reason: z.string() - }) -]) - -export type OrcadDecommissionResult = z.infer diff --git a/src/shared/orcad-managed-runtime.ts b/src/shared/orcad-managed-runtime.ts deleted file mode 100644 index 85a13fb2dde..00000000000 --- a/src/shared/orcad-managed-runtime.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { PublicKnownRuntimeEnvironment } from './runtime-environments' -import type { OrcadTerminalCensus } from './orcad-terminal-census' - -export const ORCAD_MANAGED_REMOTE_PORT = 6_768 - -export type OrcadManagedDeferral = { - outcome: 'deferred' - candidateVersion: string - code: string - reason: string - /** False when force cannot make the rejected lifecycle transition safe. */ - forceable?: boolean -} - -export type OrcadManagedDeployResult = - | { - outcome: 'created' | 'updated' | 'already-current' - environment: PublicKnownRuntimeEnvironment - activeVersion: string - } - | OrcadManagedDeferral - -export type OrcadManagedRollbackResult = - | { - outcome: 'rolled-back' - environment: PublicKnownRuntimeEnvironment - activeVersion: string - discarded: string[] - } - | { outcome: 'refused' | 'failed'; code: string; reason: string } - -export type OrcadManagedRecoveryResult = - | { outcome: 'none' } - | { outcome: 'pending'; code: string; reason: string } - | { - outcome: 'recovered' - resolution: 'committed' | 'restored-incumbent' - activeVersion: string | null - environment: PublicKnownRuntimeEnvironment - } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -/** Only a proven `exited` unlinks the server locally; anything less keeps it linked. */ -export type OrcadManagedStopResult = - | { - outcome: 'unlinked' - verdict: 'exited' - environmentId: string - sshTargetId: string - stoppedVersion: string | null - retirement: 'retired' | 'live' | 'unverifiable' | null - } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export type OrcadManagedCancelStopResult = - | { outcome: 'none' } - /** The stop was withdrawn before orcad acted on it; the server keeps serving. */ - | { outcome: 'canceled'; activeVersion: string } - /** orcad had already exited; finish with stop to unlink the server. */ - | { outcome: 'already-stopped' } - | { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string } - -export type OrcadManagedRuntimeStatus = { - environmentId: string - sshTargetId: string - activeVersion: string | null - previousVersion: string | null - activatedAt: string | null - rollbackAvailable: boolean - recovery: - | { - operation: 'activate' - phase: 'prepared' | 'incumbent-stopped' | 'snapshot-captured' | 'candidate-ready' - version: string - startedAt: string - } - | { - operation: 'rollback' - phase: - | 'prepared' - | 'incumbent-stopped' - | 'rescue-captured' - | 'rollback-state-restored' - | 'target-ready' - version: string - startedAt: string - } - | { - operation: 'decommission' - phase: 'prepared' | 'stop-dispatched' | 'process-exited' - version: string - startedAt: string - } - | null - /** Terminals the daemon runs; `null` counts are unverifiable and block updates and stops. */ - terminals: OrcadTerminalCensus - /** The last update this client deferred for this server, cleared once one goes through. */ - deferredUpdate: (OrcadManagedDeferral & { deferredAt: string }) | null -} diff --git a/src/shared/orcad-migration-catalog-state.ts b/src/shared/orcad-migration-catalog-state.ts deleted file mode 100644 index 9878978cd24..00000000000 --- a/src/shared/orcad-migration-catalog-state.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - normalizeOrcadMigrationImportReceipts, - type OrcadMigrationCatalogAbortResult, - type OrcadMigrationCatalogState, - type OrcadMigrationManifest -} from './orcad-migration-manifest' -import { parseOrcadMigrationSnapshotUploadStates } from './orcad-migration-scrollback' - -export function parseOrcadMigrationCatalogState( - value: unknown, - manifest: OrcadMigrationManifest -): OrcadMigrationCatalogState { - const record = requireRecord(value) - if ( - record.migrationId !== manifest.migrationId || - record.manifestSha256 !== manifest.manifestSha256 - ) { - throw new Error('orcad_migration_catalog_state_identity_mismatch') - } - if (record.state === 'absent') { - return { state: 'absent', ...migrationIdentity(manifest) } - } - if (record.state === 'staged') { - const stagedAt = requireDate(record.stagedAt) - const snapshotUploads = parseOrcadMigrationSnapshotUploadStates( - record.snapshotUploads, - manifest.payload.dormantState?.terminalScrollbackSnapshots ?? [] - ) - return { - state: 'staged', - ...migrationIdentity(manifest), - stagedAt, - ...(snapshotUploads ? { snapshotUploads } : {}) - } - } - if (record.state === 'committed') { - const receipt = normalizeOrcadMigrationImportReceipts([record.receipt])[0] - if ( - !receipt || - receipt.migrationId !== manifest.migrationId || - receipt.manifestSha256 !== manifest.manifestSha256 - ) { - throw new Error('orcad_migration_catalog_state_receipt_invalid') - } - return { state: 'committed', ...migrationIdentity(manifest), receipt } - } - throw new Error('orcad_migration_catalog_state_invalid') -} - -export function parseOrcadMigrationCatalogAbortResult( - value: unknown, - manifest: OrcadMigrationManifest -): OrcadMigrationCatalogAbortResult { - const state = parseOrcadMigrationCatalogState(value, manifest) - const record = requireRecord(value) - if (typeof record.aborted !== 'boolean') { - throw new Error('orcad_migration_catalog_abort_result_invalid') - } - if ( - record.durableAbsent !== undefined && - (record.durableAbsent !== true || state.state !== 'absent') - ) { - throw new Error('orcad_migration_catalog_abort_durability_invalid') - } - return { - ...state, - aborted: record.aborted, - ...(record.durableAbsent === true ? { durableAbsent: true as const } : {}) - } -} - -function migrationIdentity(manifest: OrcadMigrationManifest) { - return { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } -} - -function requireRecord(value: unknown): Record { - if (!isRecord(value)) { - throw new Error('orcad_migration_catalog_state_invalid') - } - return value -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function requireDate(value: unknown): string { - if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) { - throw new Error('orcad_migration_catalog_state_staged_at_invalid') - } - return value -} diff --git a/src/shared/orcad-migration-client-state-parsing.ts b/src/shared/orcad-migration-client-state-parsing.ts deleted file mode 100644 index 708e4e78e30..00000000000 --- a/src/shared/orcad-migration-client-state-parsing.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { parseHostStableKey } from './automation-owner-key' -import { - MAX_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS, - type ClientHostedBrowserCloseIntent -} from './client-hosted-browser-close-intent' -import type { - OrcadMigrationClientHostedBrowserCloseIntent, - OrcadMigrationUiRoutingState -} from './orcad-migration-client-state' -import type { PersistedMobileClientTabSelections } from './persisted-state-types' -import type { ManualRepoOrderEntry, WorkspaceHostOrder } from './ui-chrome-types' -import type { SavedPortForward } from './ssh-types' -import { - isRecord, - isWorkspaceHostId, - isWorkspaceHostScope, - nullableString, - parseSavedPortForwards as parseSavedPortForwardsValue, - parseStringArray, - requiredRecord -} from './orcad-migration-client-state-value-validation' - -export const MAX_ORCAD_MIGRATION_CLIENT_SELECTIONS = 4_096 -export const MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES = 16_384 -export const MAX_ORCAD_MIGRATION_SAVED_PORT_FORWARDS = 256 -export const MAX_ORCAD_MIGRATION_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS = - MAX_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS - -export function parseClientHostedBrowserCloseIntents( - value: unknown -): OrcadMigrationClientHostedBrowserCloseIntent[] { - if ( - !Array.isArray(value) || - value.length > MAX_ORCAD_MIGRATION_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS - ) { - throw new Error('orcad_migration_dormant_client_close_intents_invalid') - } - const seen = new Set() - return value.map((entry) => { - const record = requiredRecord(entry, 'orcad_migration_dormant_client_close_intent_invalid') - if ( - typeof record.sourceEnvironmentId !== 'string' || - !record.sourceEnvironmentId || - record.sourceEnvironmentId.length > 256 - ) { - throw new Error('orcad_migration_dormant_client_close_intent_environment_invalid') - } - if (typeof record.browserPageId !== 'string' || !record.browserPageId) { - throw new Error('orcad_migration_dormant_client_close_intent_page_invalid') - } - if (typeof record.worktreeId !== 'string' || !record.worktreeId) { - throw new Error('orcad_migration_dormant_client_close_intent_worktree_invalid') - } - if (!Number.isSafeInteger(record.closedAt) || Number(record.closedAt) < 0) { - throw new Error('orcad_migration_dormant_client_close_intent_timestamp_invalid') - } - const parsed = { - sourceEnvironmentId: record.sourceEnvironmentId, - browserPageId: record.browserPageId, - worktreeId: record.worktreeId, - closedAt: Number(record.closedAt) - } satisfies OrcadMigrationClientHostedBrowserCloseIntent - const key = `${parsed.sourceEnvironmentId}\0${parsed.browserPageId}\0${parsed.worktreeId}` - if (seen.has(key)) { - throw new Error('orcad_migration_dormant_client_close_intent_duplicate') - } - seen.add(key) - return parsed - }) -} - -export function parseMobileSelections(value: unknown): PersistedMobileClientTabSelections { - const record = requiredRecord(value, 'orcad_migration_dormant_mobile_selections_invalid') - const result: PersistedMobileClientTabSelections = {} - let count = 0 - for (const [deviceId, byWorktree] of Object.entries(record)) { - if (!deviceId || !isRecord(byWorktree)) { - throw new Error('orcad_migration_dormant_mobile_selections_invalid') - } - const entries: PersistedMobileClientTabSelections[string] = {} - for (const [worktreeId, rawSelection] of Object.entries(byWorktree)) { - if (!worktreeId || !isRecord(rawSelection)) { - throw new Error('orcad_migration_dormant_mobile_selection_invalid') - } - const activeTabId = nullableString(rawSelection.activeTabId) - const activeGroupId = nullableString(rawSelection.activeGroupId) - const activeTabIdByGroupId = requiredRecord( - rawSelection.activeTabIdByGroupId, - 'orcad_migration_dormant_mobile_selection_tabs_invalid' - ) - const tabs: Record = {} - for (const [groupId, tabId] of Object.entries(activeTabIdByGroupId)) { - if (!groupId || typeof tabId !== 'string' || !tabId) { - throw new Error('orcad_migration_dormant_mobile_selection_tabs_invalid') - } - tabs[groupId] = tabId - } - if (!activeTabId && !activeGroupId && Object.keys(tabs).length === 0) { - continue - } - entries[worktreeId] = { activeTabId, activeGroupId, activeTabIdByGroupId: tabs } - count += 1 - if (count > MAX_ORCAD_MIGRATION_CLIENT_SELECTIONS) { - throw new Error('orcad_migration_dormant_mobile_selections_too_many') - } - } - if (Object.keys(entries).length > 0) { - result[deviceId] = entries - } - } - return result -} - -export function parseUiRouting(value: unknown): OrcadMigrationUiRoutingState { - const record = requiredRecord(value, 'orcad_migration_dormant_ui_routing_invalid') - const result: OrcadMigrationUiRoutingState = {} - if ('lastActiveRepoId' in record) { - result.lastActiveRepoId = nullableString(record.lastActiveRepoId) - } - if ('lastActiveWorktreeId' in record) { - result.lastActiveWorktreeId = nullableString(record.lastActiveWorktreeId) - } - if ('filterRepoIds' in record) { - result.filterRepoIds = parseStringArray( - record.filterRepoIds, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - } - if ('showDotfilesByWorktree' in record) { - const entries = requiredRecord( - record.showDotfilesByWorktree, - 'orcad_migration_dormant_ui_dotfiles_invalid' - ) - if (Object.keys(entries).length > MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES) { - throw new Error('orcad_migration_dormant_ui_routing_too_many') - } - result.showDotfilesByWorktree = {} - for (const [key, enabled] of Object.entries(entries)) { - if (!key || typeof enabled !== 'boolean') { - throw new Error('orcad_migration_dormant_ui_dotfiles_invalid') - } - result.showDotfilesByWorktree[key] = enabled - } - } - if ('setupScriptPromptDismissedRepoIds' in record) { - result.setupScriptPromptDismissedRepoIds = parseStringArray( - record.setupScriptPromptDismissedRepoIds, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - } - if ('manualRepoOrder' in record) { - const entries = record.manualRepoOrder - if (!Array.isArray(entries) || entries.length > MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES) { - throw new Error('orcad_migration_dormant_ui_routing_invalid') - } - result.manualRepoOrder = entries.map((entry) => { - const parsed = requiredRecord(entry, 'orcad_migration_dormant_ui_manual_order_invalid') - const hostId = parsed.hostId - if (!isWorkspaceHostId(hostId) || typeof parsed.repoId !== 'string' || !parsed.repoId) { - throw new Error('orcad_migration_dormant_ui_manual_order_invalid') - } - const orderEntry: ManualRepoOrderEntry = { hostId, repoId: parsed.repoId } - return orderEntry - }) - } - if ('workspaceHostScope' in record) { - if (!isWorkspaceHostScope(record.workspaceHostScope)) { - throw new Error('orcad_migration_dormant_ui_host_scope_invalid') - } - result.workspaceHostScope = record.workspaceHostScope - } - if ('visibleWorkspaceHostIds' in record) { - if (record.visibleWorkspaceHostIds !== null) { - const ids = parseStringArray( - record.visibleWorkspaceHostIds, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - const hostIds: WorkspaceHostOrder = ids.filter(isWorkspaceHostId) - if (hostIds.length !== ids.length) { - throw new Error('orcad_migration_dormant_ui_visible_hosts_invalid') - } - result.visibleWorkspaceHostIds = hostIds - } else { - result.visibleWorkspaceHostIds = null - } - } - if ('workspaceHostOrder' in record) { - const ids = parseStringArray( - record.workspaceHostOrder, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES - ) - const hostIds: WorkspaceHostOrder = ids.filter(isWorkspaceHostId) - if (hostIds.length !== ids.length) { - throw new Error('orcad_migration_dormant_ui_host_order_invalid') - } - result.workspaceHostOrder = hostIds - } - if ('automationHostFilter' in record) { - const filter = requiredRecord( - record.automationHostFilter, - 'orcad_migration_dormant_ui_automation_filter_invalid' - ) - if (filter.kind === 'all') { - result.automationHostFilter = { kind: 'all' } - } else if ( - filter.kind === 'host' && - typeof filter.hostKey === 'string' && - filter.hostKey && - parseHostStableKey(filter.hostKey) - ) { - result.automationHostFilter = { kind: 'host', hostKey: filter.hostKey } - } else { - throw new Error('orcad_migration_dormant_ui_automation_filter_invalid') - } - } - if ('acknowledgedAgentsByPaneKey' in record) { - const entries = requiredRecord( - record.acknowledgedAgentsByPaneKey, - 'orcad_migration_dormant_ui_acknowledgements_invalid' - ) - if (Object.keys(entries).length > MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES) { - throw new Error('orcad_migration_dormant_ui_routing_too_many') - } - result.acknowledgedAgentsByPaneKey = {} - for (const [key, timestamp] of Object.entries(entries)) { - if (!key || typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) { - throw new Error('orcad_migration_dormant_ui_acknowledgements_invalid') - } - result.acknowledgedAgentsByPaneKey[key] = timestamp - } - } - return result -} - -export function parseSavedPortForwards(value: unknown): SavedPortForward[] { - return parseSavedPortForwardsValue(value, MAX_ORCAD_MIGRATION_SAVED_PORT_FORWARDS) -} - -export type { ClientHostedBrowserCloseIntent } diff --git a/src/shared/orcad-migration-client-state-value-validation.ts b/src/shared/orcad-migration-client-state-value-validation.ts deleted file mode 100644 index f099f247af6..00000000000 --- a/src/shared/orcad-migration-client-state-value-validation.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { WorkspaceHostScope } from './ui-chrome-types' -import type { SavedPortForward } from './ssh-types' - -export function parseStringArray(value: unknown, max: number): string[] { - if ( - !Array.isArray(value) || - value.length > max || - !value.every((entry) => typeof entry === 'string' && entry) - ) { - throw new Error('orcad_migration_dormant_ui_routing_invalid') - } - if (new Set(value).size !== value.length) { - throw new Error('orcad_migration_dormant_ui_routing_duplicate') - } - return [...value] -} - -export function positivePort(value: unknown, label: string): number { - if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 65_535) { - throw new Error(`orcad_migration_dormant_saved_port_forward_${label}_invalid`) - } - return Number(value) -} - -export function parseSavedPortForwards(value: unknown, max: number): SavedPortForward[] { - if (!Array.isArray(value) || value.length > max) { - throw new Error('orcad_migration_dormant_saved_port_forwards_invalid') - } - const usedPorts = new Set() - return value.map((entry) => { - const record = requiredRecord(entry, 'orcad_migration_dormant_saved_port_forward_invalid') - const localPort = positivePort(record.localPort, 'local_port') - if (usedPorts.has(localPort)) { - throw new Error('orcad_migration_dormant_saved_port_forwards_duplicate') - } - usedPorts.add(localPort) - const remotePort = positivePort(record.remotePort, 'remote_port') - if (typeof record.remoteHost !== 'string' || !record.remoteHost.trim()) { - throw new Error('orcad_migration_dormant_saved_port_forward_invalid') - } - if (record.label !== undefined && typeof record.label !== 'string') { - throw new Error('orcad_migration_dormant_saved_port_forward_invalid') - } - return { - localPort, - remoteHost: record.remoteHost, - remotePort, - ...(record.label !== undefined ? { label: record.label } : {}) - } - }) -} - -export function nullableString(value: unknown): string | null { - if (value !== null && value !== undefined && (typeof value !== 'string' || value.length === 0)) { - throw new Error('orcad_migration_dormant_ui_routing_invalid') - } - return value == null ? null : value -} - -export function isWorkspaceHostScope(value: unknown): value is WorkspaceHostScope { - return value === 'all' || isWorkspaceHostId(value) -} - -export function isWorkspaceHostId(value: unknown): value is Exclude { - return ( - value === 'local' || - (typeof value === 'string' && (value.startsWith('ssh:') || value.startsWith('runtime:'))) - ) -} - -export function requiredRecord(value: unknown, error: string): Record { - if (!isRecord(value)) { - throw new Error(error) - } - return value -} - -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-migration-client-state.ts b/src/shared/orcad-migration-client-state.ts deleted file mode 100644 index 8bdde396dc7..00000000000 --- a/src/shared/orcad-migration-client-state.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { PersistedAutomationHostFilter } from './automation-host-filter' -import type { PersistedMobileClientTabSelections } from './persisted-state-types' -import type { - ManualRepoOrderEntry, - VisibleWorkspaceHostIds, - WorkspaceHostOrder, - WorkspaceHostScope -} from './ui-chrome-types' -import type { SavedPortForward } from './ssh-types' -import { - parseClientHostedBrowserCloseIntents, - parseMobileSelections, - parseSavedPortForwards, - parseUiRouting -} from './orcad-migration-client-state-parsing' -import { isWorkspaceHostId, isRecord } from './orcad-migration-client-state-value-validation' -import type { ClientHostedBrowserCloseIntent } from './client-hosted-browser-close-intent' - -export { - MAX_ORCAD_MIGRATION_CLIENT_HOSTED_BROWSER_CLOSE_INTENTS, - MAX_ORCAD_MIGRATION_CLIENT_ROUTING_ENTRIES, - MAX_ORCAD_MIGRATION_CLIENT_SELECTIONS, - MAX_ORCAD_MIGRATION_SAVED_PORT_FORWARDS, - parseClientHostedBrowserCloseIntents, - parseMobileSelections, - parseSavedPortForwards, - parseUiRouting -} from './orcad-migration-client-state-parsing' - -export type OrcadMigrationUiRoutingState = { - lastActiveRepoId?: string | null - lastActiveWorktreeId?: string | null - filterRepoIds?: string[] - showDotfilesByWorktree?: Record - setupScriptPromptDismissedRepoIds?: string[] - manualRepoOrder?: ManualRepoOrderEntry[] - workspaceHostScope?: WorkspaceHostScope - visibleWorkspaceHostIds?: VisibleWorkspaceHostIds - workspaceHostOrder?: WorkspaceHostOrder - automationHostFilter?: PersistedAutomationHostFilter - acknowledgedAgentsByPaneKey?: Record -} - -export type OrcadMigrationClientHostedBrowserCloseIntent = ClientHostedBrowserCloseIntent & { - sourceEnvironmentId: string -} - -export type OrcadMigrationClientStatePayload = { - mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections - uiRouting?: OrcadMigrationUiRoutingState - savedPortForwards?: SavedPortForward[] - clientHostedBrowserCloseIntents?: OrcadMigrationClientHostedBrowserCloseIntent[] -} - -export function parseOrcadMigrationClientState(value: unknown): OrcadMigrationClientStatePayload { - if (!isRecord(value)) { - throw new Error('orcad_migration_dormant_client_state_invalid') - } - const mobile = - value.mobileClientTabSelectionsByDeviceId === undefined - ? undefined - : parseMobileSelections(value.mobileClientTabSelectionsByDeviceId) - const uiRouting = value.uiRouting === undefined ? undefined : parseUiRouting(value.uiRouting) - const savedPortForwards = - value.savedPortForwards === undefined - ? undefined - : parseSavedPortForwards(value.savedPortForwards) - const closeIntents = - value.clientHostedBrowserCloseIntents === undefined - ? undefined - : parseClientHostedBrowserCloseIntents(value.clientHostedBrowserCloseIntents) - return { - ...(mobile && Object.keys(mobile).length > 0 - ? { mobileClientTabSelectionsByDeviceId: mobile } - : {}), - ...(uiRouting && Object.keys(uiRouting).length > 0 ? { uiRouting } : {}), - ...(savedPortForwards && savedPortForwards.length > 0 ? { savedPortForwards } : {}), - ...(closeIntents && closeIntents.length > 0 - ? { clientHostedBrowserCloseIntents: closeIntents } - : {}) - } -} - -export function parseOrcadMigrationUiRoutingState(value: unknown): OrcadMigrationUiRoutingState { - return parseUiRouting(value) -} - -export function parseOrcadMigrationSavedPortForwards(value: unknown): SavedPortForward[] { - return parseSavedPortForwards(value) -} - -export function parseOrcadMigrationClientHostedBrowserCloseIntents( - value: unknown -): OrcadMigrationClientHostedBrowserCloseIntent[] { - return parseClientHostedBrowserCloseIntents(value) -} - -export function assertOrcadMigrationClientStateReferences(args: { - clientState: OrcadMigrationClientStatePayload | undefined - repositoryIds: ReadonlySet - owns: (ownerKey: string) => boolean -}): void { - const clientState = args.clientState - if (!clientState) { - return - } - for (const selections of Object.values(clientState.mobileClientTabSelectionsByDeviceId ?? {})) { - for (const worktreeId of Object.keys(selections)) { - if (!args.owns(worktreeId)) { - throw new Error('orcad_migration_dormant_mobile_selection_scope_invalid') - } - } - } - for (const intent of clientState.clientHostedBrowserCloseIntents ?? []) { - if (!intent.sourceEnvironmentId || !args.owns(intent.worktreeId)) { - throw new Error('orcad_migration_dormant_client_close_intent_scope_invalid') - } - } - const ui = clientState.uiRouting - if (!ui) { - return - } - if (ui.lastActiveRepoId && !args.repositoryIds.has(ui.lastActiveRepoId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - if (ui.lastActiveWorktreeId && !args.owns(ui.lastActiveWorktreeId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - for (const repoId of ui.filterRepoIds ?? []) { - if (!args.repositoryIds.has(repoId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } - for (const ownerKey of Object.keys(ui.showDotfilesByWorktree ?? {})) { - if (!args.owns(ownerKey)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } - for (const repoId of ui.setupScriptPromptDismissedRepoIds ?? []) { - if (!args.repositoryIds.has(repoId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } - for (const entry of ui.manualRepoOrder ?? []) { - if (!args.repositoryIds.has(entry.repoId) || !isWorkspaceHostId(entry.hostId)) { - throw new Error('orcad_migration_dormant_ui_routing_scope_invalid') - } - } -} diff --git a/src/shared/orcad-migration-dormant-automation-fields.ts b/src/shared/orcad-migration-dormant-automation-fields.ts deleted file mode 100644 index 684e7b1fd3e..00000000000 --- a/src/shared/orcad-migration-dormant-automation-fields.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - optionalFinite, - optionalNullableString, - requiredBoolean, - requiredFinite, - requiredRecord, - requiredString, - requiredStringOrEmpty -} from './orcad-migration-dormant-value-validation' - -// Field checks for dormant automations and their final runs; each throws its specific error. - -export function parsePrecheck(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_precheck_invalid') - requiredString(record.command, 'orcad_migration_dormant_automation_precheck_command_invalid') - requiredFinite( - record.timeoutSeconds, - 'orcad_migration_dormant_automation_precheck_timeout_invalid' - ) -} - -export function parseOutputSnapshot(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_output_invalid') - if (record.format !== 'plain_text') { - throw new Error('orcad_migration_dormant_automation_output_format_invalid') - } - requiredStringOrEmpty(record.content, 'orcad_migration_dormant_automation_output_content_invalid') - requiredFinite(record.capturedAt, 'orcad_migration_dormant_automation_output_time_invalid') - requiredBoolean(record.truncated, 'orcad_migration_dormant_automation_output_truncated_invalid') -} - -export function parsePrecheckResult(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_precheck_result_invalid') - requiredString( - record.command, - 'orcad_migration_dormant_automation_precheck_result_command_invalid' - ) - optionalFinite( - record.exitCode, - 'orcad_migration_dormant_automation_precheck_result_exit_invalid', - true - ) - requiredBoolean( - record.timedOut, - 'orcad_migration_dormant_automation_precheck_result_timeout_invalid' - ) - requiredFinite( - record.durationMs, - 'orcad_migration_dormant_automation_precheck_result_duration_invalid' - ) - for (const field of ['stdout', 'stderr'] as const) { - requiredStringOrEmpty(record[field], `orcad_migration_dormant_automation_${field}_invalid`) - requiredBoolean( - record[`${field}Truncated`], - `orcad_migration_dormant_automation_${field}_truncated_invalid` - ) - } - optionalNullableString( - record.error, - 'orcad_migration_dormant_automation_precheck_result_error_invalid' - ) - requiredFinite( - record.startedAt, - 'orcad_migration_dormant_automation_precheck_result_started_invalid' - ) - requiredFinite( - record.completedAt, - 'orcad_migration_dormant_automation_precheck_result_completed_invalid' - ) -} - -export function parseUsage(value: unknown): void { - if (value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_usage_invalid') - if (record.status !== 'known' && record.status !== 'unavailable') { - throw new Error('orcad_migration_dormant_automation_usage_status_invalid') - } - for (const field of [ - 'inputTokens', - 'outputTokens', - 'cacheReadTokens', - 'cacheWriteTokens', - 'reasoningOutputTokens', - 'totalTokens', - 'estimatedCostUsd' - ] as const) { - optionalFinite(record[field], `orcad_migration_dormant_automation_usage_${field}_invalid`, true) - } - requiredFinite(record.collectedAt, 'orcad_migration_dormant_automation_usage_collected_invalid') -} diff --git a/src/shared/orcad-migration-dormant-automation-validation.ts b/src/shared/orcad-migration-dormant-automation-validation.ts deleted file mode 100644 index 61787127ba9..00000000000 --- a/src/shared/orcad-migration-dormant-automation-validation.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { isTuiAgent } from './tui-agent-config' -import type { Automation, AutomationRun } from './automations-types' -import { getAutomationRunRepoId } from './automation-run-identity' -import { - assertUnique, - boundedArray, - optionalFinite, - optionalNullableString, - optionalPositiveInteger, - requiredBoolean, - requiredFinite, - requiredRecord, - requiredString, - requiredStringOrEmpty -} from './orcad-migration-dormant-value-validation' -import { - parseOutputSnapshot, - parsePrecheck, - parsePrecheckResult, - parseUsage -} from './orcad-migration-dormant-automation-fields' - -const FINAL_RUN_STATUSES = new Set([ - 'completed', - 'dispatch_failed', - 'skipped_precheck', - 'skipped_missed', - 'skipped_unavailable', - 'skipped_needs_interactive_auth' -]) - -export function parseOrcadMigrationDormantAutomations(value: unknown): Automation[] { - const automations = boundedArray(value, parseAutomation, 'automations') - assertUnique(automations, (entry) => entry.id, 'automation') - return automations -} - -export function parseOrcadMigrationDormantAutomationRuns(value: unknown): AutomationRun[] { - const runs = boundedArray(value, parseAutomationRun, 'automation_runs') - assertUnique(runs, (entry) => entry.id, 'automation_run') - return runs -} - -export function assertOrcadMigrationDormantAutomationReferences(args: { - automations: readonly Automation[] - automationRuns: readonly AutomationRun[] - repositoryIds: ReadonlySet - owns: (value: string) => boolean -}): void { - const automationIds = new Set(args.automations.map((entry) => entry.id)) - for (const automation of args.automations) { - const repoId = getAutomationRunRepoId(automation) - if ( - !args.repositoryIds.has(repoId) || - !contextBelongsToDestination(automation.runContext, args.repositoryIds) || - !contextBelongsToDestination(automation.sourceContext, args.repositoryIds) || - (automation.workspaceId !== null && !args.owns(automation.workspaceId)) - ) { - throw new Error('orcad_migration_dormant_automation_scope_invalid') - } - } - for (const run of args.automationRuns) { - if ( - !automationIds.has(run.automationId) || - !contextBelongsToDestination(run.runContext, args.repositoryIds) || - !contextBelongsToDestination(run.sourceContext, args.repositoryIds) || - (run.workspaceId !== null && !args.owns(run.workspaceId)) - ) { - throw new Error('orcad_migration_dormant_automation_run_scope_invalid') - } - } -} - -function parseAutomation(value: unknown): Automation { - const copy = structuredClone(value) - if (!isMigratedAutomation(copy)) { - throw new Error('orcad_migration_dormant_automation_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedAutomation(value: unknown): value is Automation { - const record = requiredRecord(value, 'orcad_migration_dormant_automation_invalid') - requiredString(record.id, 'orcad_migration_dormant_automation_id_invalid') - requiredString(record.name, 'orcad_migration_dormant_automation_name_invalid') - requiredStringOrEmpty(record.prompt, 'orcad_migration_dormant_automation_prompt_invalid') - parsePrecheck(record.precheck) - if (!isTuiAgent(record.agentId)) { - throw new Error('orcad_migration_dormant_automation_agent_invalid') - } - parseContext(record.runContext, 'workspace-run') - parseContext(record.sourceContext, 'task-source') - requiredString(record.projectId, 'orcad_migration_dormant_automation_project_invalid') - if ( - record.executionTargetType !== 'local' || - record.executionTargetId !== 'local' || - record.executionTargetGeneration !== undefined || - record.schedulerOwner !== 'remote_host_service' || - record.enabled !== false - ) { - throw new Error('orcad_migration_dormant_automation_owner_invalid') - } - if (!['existing', 'new_per_run'].includes(String(record.workspaceMode))) { - throw new Error('orcad_migration_dormant_automation_workspace_mode_invalid') - } - optionalNullableString(record.workspaceId, 'orcad_migration_dormant_automation_workspace_invalid') - optionalNullableString(record.baseBranch, 'orcad_migration_dormant_automation_branch_invalid') - if ( - record.setupDecision !== undefined && - record.setupDecision !== 'run' && - record.setupDecision !== 'skip' - ) { - throw new Error('orcad_migration_dormant_automation_setup_decision_invalid') - } - requiredBoolean(record.reuseSession, 'orcad_migration_dormant_automation_reuse_invalid') - requiredString(record.timezone, 'orcad_migration_dormant_automation_timezone_invalid') - requiredString(record.rrule, 'orcad_migration_dormant_automation_rrule_invalid') - for (const field of [ - 'dtstart', - 'nextRunAt', - 'missedRunGraceMinutes', - 'createdAt', - 'updatedAt' - ] as const) { - requiredFinite(record[field], `orcad_migration_dormant_automation_${field}_invalid`) - } - optionalFinite(record.lastRunAt, 'orcad_migration_dormant_automation_last_run_invalid') - if (record.missedRunPolicy !== 'run_once_within_grace') { - throw new Error('orcad_migration_dormant_automation_missed_policy_invalid') - } - return true -} - -function parseAutomationRun(value: unknown): AutomationRun { - const copy = structuredClone(value) - if (!isMigratedAutomationRun(copy)) { - throw new Error('orcad_migration_dormant_automation_run_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedAutomationRun(value: unknown): value is AutomationRun { - const record = requiredRecord(value, 'orcad_migration_dormant_automation_run_invalid') - requiredString(record.id, 'orcad_migration_dormant_automation_run_id_invalid') - requiredString(record.automationId, 'orcad_migration_dormant_automation_run_owner_invalid') - parseContext(record.runContext, 'workspace-run') - parseContext(record.sourceContext, 'task-source') - requiredString(record.title, 'orcad_migration_dormant_automation_run_title_invalid') - requiredFinite(record.scheduledFor, 'orcad_migration_dormant_automation_run_schedule_invalid') - if (!FINAL_RUN_STATUSES.has(record.status)) { - throw new Error('orcad_migration_dormant_automation_run_status_invalid') - } - if (record.trigger !== 'scheduled' && record.trigger !== 'manual') { - throw new Error('orcad_migration_dormant_automation_run_trigger_invalid') - } - optionalNullableString( - record.workspaceId, - 'orcad_migration_dormant_automation_run_workspace_invalid' - ) - optionalNullableString( - record.workspaceDisplayName, - 'orcad_migration_dormant_automation_run_workspace_name_invalid' - ) - if (record.sessionKind !== 'terminal') { - throw new Error('orcad_migration_dormant_automation_run_session_invalid') - } - for (const field of [ - 'chatSessionId', - 'terminalSessionId', - 'terminalPaneKey', - 'terminalPtyId', - 'error' - ] as const) { - optionalNullableString(record[field], `orcad_migration_dormant_automation_run_${field}_invalid`) - } - parseOutputSnapshot(record.outputSnapshot) - parsePrecheckResult(record.precheckResult) - parseUsage(record.usage) - optionalFinite(record.startedAt, 'orcad_migration_dormant_automation_run_started_invalid', true) - optionalFinite( - record.dispatchedAt, - 'orcad_migration_dormant_automation_run_dispatched_invalid', - true - ) - requiredFinite(record.createdAt, 'orcad_migration_dormant_automation_run_created_invalid') - optionalPositiveInteger(record.runNumber, 'orcad_migration_dormant_automation_run_number_invalid') - optionalPositiveInteger( - record.occurrenceCount, - 'orcad_migration_dormant_automation_occurrence_count_invalid' - ) - optionalFinite( - record.lastOccurrenceAt, - 'orcad_migration_dormant_automation_last_occurrence_invalid' - ) - return true -} - -function parseContext(value: unknown, kind: 'workspace-run' | 'task-source'): void { - if (value === undefined || value === null) { - return - } - const record = requiredRecord(value, 'orcad_migration_dormant_automation_context_invalid') - if (record.kind !== kind || record.hostId !== 'local') { - throw new Error('orcad_migration_dormant_automation_context_owner_invalid') - } - requiredString(record.projectId, 'orcad_migration_dormant_automation_context_project_invalid') - optionalNullableString( - record.projectHostSetupId, - 'orcad_migration_dormant_automation_context_setup_invalid' - ) - optionalNullableString(record.repoId, 'orcad_migration_dormant_automation_context_repo_invalid') - if (kind === 'workspace-run') { - requiredString( - record.projectHostSetupId, - 'orcad_migration_dormant_automation_context_setup_invalid' - ) - requiredString(record.repoId, 'orcad_migration_dormant_automation_context_repo_invalid') - requiredString(record.path, 'orcad_migration_dormant_automation_context_path_invalid') - } else if (!['github', 'gitlab', 'linear', 'jira'].includes(String(record.provider))) { - throw new Error('orcad_migration_dormant_automation_context_provider_invalid') - } -} - -function contextBelongsToDestination( - context: Automation['runContext'] | Automation['sourceContext'], - repositoryIds: ReadonlySet -): boolean { - if (!context) { - return true - } - if (context.hostId !== 'local') { - return false - } - if (context.repoId && !repositoryIds.has(context.repoId)) { - return false - } - return !context.projectHostSetupId || context.projectHostSetupId === context.repoId -} diff --git a/src/shared/orcad-migration-dormant-session-validation.test.ts b/src/shared/orcad-migration-dormant-session-validation.test.ts deleted file mode 100644 index 0c3e31937b4..00000000000 --- a/src/shared/orcad-migration-dormant-session-validation.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from './constants' -import { - CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, - type PersistedClientHostedBrowserPage -} from './client-hosted-browser-page-record' -import type { BrowserWorkspace } from './browser-workspace-types' -import { assertOrcadMigrationDormantWorkspaceSessionReferences } from './orcad-migration-dormant-session-validation' -import { parseOrcadMigrationClientState } from './orcad-migration-client-state' -import type { WorkspaceSessionState } from './workspace-session-state-types' - -const OWNER = 'repo-1::/srv/worktree' -const BROWSER_WORKSPACE_ID = 'browser-workspace-1' - -function browserWorkspace(): BrowserWorkspace { - return { - id: BROWSER_WORKSPACE_ID, - worktreeId: OWNER, - activePageId: null, - pageIds: [], - url: 'about:blank', - title: 'Browser', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } -} - -function clientPage( - browserPageId: string, - workspaceId = BROWSER_WORKSPACE_ID -): PersistedClientHostedBrowserPage { - return { - v: CLIENT_HOSTED_BROWSER_PAGE_RECORD_VERSION, - browserPageId, - workspaceId, - browserProfileId: 'profile-1', - url: 'https://example.test/', - title: 'Example', - pairedDeviceId: 'device-1', - savedAt: 1 - } -} - -function validate(session: WorkspaceSessionState): void { - assertOrcadMigrationDormantWorkspaceSessionReferences({ - session, - owns: (ownerKey) => ownerKey === OWNER, - repositoryIds: new Set(['repo-1']) - }) -} - -function sessionWithPages(pages: ReturnType[]): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - browserTabsByWorktree: { [OWNER]: [browserWorkspace()] }, - clientHostedBrowserPagesByWorktree: { [OWNER]: pages } - } -} - -describe('dormant client-hosted browser page migration validation', () => { - it('accepts held pages whose workspace id belongs to the transferred browser workspace', () => { - expect(() => validate(sessionWithPages([clientPage('page-1')]))).not.toThrow() - }) - - it('rejects a page that names a browser workspace outside its owner', () => { - expect(() => validate(sessionWithPages([clientPage('page-1', 'browser-missing')]))).toThrow( - 'orcad_migration_dormant_workspace_session_client_page_scope_invalid' - ) - }) - - it('rejects duplicate client-hosted page identities', () => { - expect(() => validate(sessionWithPages([clientPage('page-1'), clientPage('page-1')]))).toThrow( - 'orcad_migration_dormant_workspace_session_identity_duplicate' - ) - }) - - it('accepts scoped client-owned close intents alongside transferred pages', () => { - const session = sessionWithPages([clientPage('page-1')]) - session.clientHostedBrowserCloseIntentsByEnvironment = { - 'old-environment': [{ browserPageId: 'page-1', worktreeId: OWNER, closedAt: 10 }] - } - expect(() => validate(session)).not.toThrow() - }) - - it('parses migration close-intent envelopes with source environment identity', () => { - expect( - parseOrcadMigrationClientState({ - clientHostedBrowserCloseIntents: [ - { - sourceEnvironmentId: 'old-environment', - browserPageId: 'page-1', - worktreeId: OWNER, - closedAt: 10 - } - ] - }) - ).toEqual({ - clientHostedBrowserCloseIntents: [ - { - sourceEnvironmentId: 'old-environment', - browserPageId: 'page-1', - worktreeId: OWNER, - closedAt: 10 - } - ] - }) - }) - - it('allows global browser URL history to remain client-owned', () => { - const session = sessionWithPages([]) - session.browserUrlHistory = [ - { - url: 'https://example.test/', - normalizedUrl: 'https://example.test/', - title: 'Example', - lastVisitedAt: 10, - visitCount: 1 - } - ] - expect(() => validate(session)).not.toThrow() - }) - - it('accepts markdown visibility only when it names a persisted open file', () => { - const session = sessionWithPages([]) - session.openFilesByWorktree = { - [OWNER]: [ - { - filePath: '/srv/worktree/README.md', - relativePath: 'README.md', - worktreeId: OWNER, - language: 'markdown' - } - ] - } - session.markdownFrontmatterVisible = { '/srv/worktree/README.md': false } - expect(() => validate(session)).not.toThrow() - session.markdownFrontmatterVisible = { '/srv/worktree/missing.md': false } - expect(() => validate(session)).toThrow( - 'orcad_migration_dormant_workspace_session_markdown_scope_invalid' - ) - }) - - it('accepts a terminal layout referenced only by the unified tab model', () => { - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: {}, - unifiedTabs: { - [OWNER]: [ - { - id: 'terminal-tab-1', - entityId: 'terminal-tab-1', - groupId: 'group-1', - worktreeId: OWNER, - contentType: 'terminal' as const, - label: 'Terminal', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'terminal-tab-1': { - root: { type: 'leaf' as const, leafId: 'leaf-1' }, - activeLeafId: 'leaf-1', - expandedLeafId: null - } - } - } - expect(() => validate(session)).not.toThrow() - }) -}) diff --git a/src/shared/orcad-migration-dormant-session-validation.ts b/src/shared/orcad-migration-dormant-session-validation.ts deleted file mode 100644 index 138e9b41fab..00000000000 --- a/src/shared/orcad-migration-dormant-session-validation.ts +++ /dev/null @@ -1,271 +0,0 @@ -import type { WorkspaceSessionState } from './workspace-session-state-types' -import { parseWorkspaceSession } from './workspace-session-schema' - -export function parseOrcadMigrationDormantWorkspaceSession(value: unknown): WorkspaceSessionState { - const parsed = parseWorkspaceSession(value) - if (!parsed.ok) { - throw new Error(`orcad_migration_dormant_workspace_session_invalid:${parsed.error}`) - } - return parsed.value -} - -export function assertOrcadMigrationDormantWorkspaceSessionReferences(args: { - session: WorkspaceSessionState - owns: (ownerKey: string) => boolean - repositoryIds: ReadonlySet -}): void { - const { session } = args - const terminalTabIds = new Set() - const browserWorkspaceIds = new Set() - const browserWorkspaceOwnerById = new Map() - const clientHostedBrowserPageIds = new Set() - const clientHostedBrowserPageOwnerById = new Map() - const unifiedTabIds = new Set() - const tabGroupIds = new Set() - for (const [ownerKey, tabs] of Object.entries(session.tabsByWorktree)) { - assertOwner(args.owns, ownerKey) - for (const tab of tabs) { - assertOwner(args.owns, tab.worktreeId) - if (tab.ptyId) { - throw new Error('orcad_migration_dormant_workspace_session_pty_invalid') - } - addUniqueSessionId(terminalTabIds, tab.id) - } - } - for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { - assertOwner(args.owns, ownerKey) - for (const file of files) { - assertOwner(args.owns, file.worktreeId) - if (file.externalSshTargetId) { - throw new Error('orcad_migration_dormant_workspace_session_external_file_invalid') - } - if (file.runtimeEnvironmentId) { - throw new Error('orcad_migration_dormant_workspace_session_file_runtime_invalid') - } - } - } - const markdownFileIdCounts = new Map() - for (const [ownerKey, files] of Object.entries(session.openFilesByWorktree ?? {})) { - if (!args.owns(ownerKey)) { - continue - } - for (const file of files) { - for (const fileId of [ - file.filePath, - ownedEditorFileId(file.filePath, ownerKey, file.runtimeEnvironmentId) - ]) { - markdownFileIdCounts.set(fileId, (markdownFileIdCounts.get(fileId) ?? 0) + 1) - } - } - } - for (const fileId of Object.keys(session.markdownFrontmatterVisible ?? {})) { - if (markdownFileIdCounts.get(fileId) !== 1) { - throw new Error('orcad_migration_dormant_workspace_session_markdown_scope_invalid') - } - } - for (const [ownerKey, workspaces] of Object.entries(session.browserTabsByWorktree ?? {})) { - assertOwner(args.owns, ownerKey) - for (const workspace of workspaces) { - assertOwner(args.owns, workspace.worktreeId) - if (workspace.sessionProfileId || workspace.sessionPartition) { - throw new Error('orcad_migration_dormant_workspace_session_browser_profile_invalid') - } - addUniqueSessionId(browserWorkspaceIds, workspace.id) - browserWorkspaceOwnerById.set(workspace.id, ownerKey) - } - } - for (const [workspaceId, pages] of Object.entries(session.browserPagesByWorkspace ?? {})) { - if (!browserWorkspaceIds.has(workspaceId)) { - throw new Error('orcad_migration_dormant_workspace_session_browser_page_scope_invalid') - } - pages.forEach((page) => assertOwner(args.owns, page.worktreeId)) - } - for (const [ownerKey, pages] of Object.entries( - session.clientHostedBrowserPagesByWorktree ?? {} - )) { - assertOwner(args.owns, ownerKey) - pages.forEach((page) => { - if ( - !browserWorkspaceIds.has(page.workspaceId) || - browserWorkspaceOwnerById.get(page.workspaceId) !== ownerKey - ) { - throw new Error('orcad_migration_dormant_workspace_session_client_page_scope_invalid') - } - addUniqueSessionId(clientHostedBrowserPageIds, page.browserPageId) - clientHostedBrowserPageOwnerById.set(page.browserPageId, ownerKey) - }) - } - const closeIntentKeys = new Set() - for (const [environmentId, intents] of Object.entries( - session.clientHostedBrowserCloseIntentsByEnvironment ?? {} - )) { - if (!environmentId) { - throw new Error('orcad_migration_dormant_workspace_session_close_intent_scope_invalid') - } - for (const intent of intents) { - assertOwner(args.owns, intent.worktreeId) - const ownerKey = clientHostedBrowserPageOwnerById.get(intent.browserPageId) - if (!clientHostedBrowserPageIds.has(intent.browserPageId) || ownerKey !== intent.worktreeId) { - throw new Error('orcad_migration_dormant_workspace_session_close_intent_scope_invalid') - } - const key = `${environmentId}\0${intent.browserPageId}\0${intent.worktreeId}` - if (closeIntentKeys.has(key)) { - throw new Error('orcad_migration_dormant_workspace_session_identity_duplicate') - } - closeIntentKeys.add(key) - } - } - for (const [ownerKey, tabs] of Object.entries(session.unifiedTabs ?? {})) { - assertOwner(args.owns, ownerKey) - for (const tab of tabs) { - assertOwner(args.owns, tab.worktreeId) - if (tab.contentType === 'terminal') { - // Unified terminal tabs normally mirror tabsByWorktree, but a session written during - // model rollout can contain only this representation. Layouts still belong to it. - terminalTabIds.add(tab.id) - terminalTabIds.add(tab.entityId) - } - addUniqueSessionId(unifiedTabIds, tab.id) - if (tab.executionHostId !== undefined && tab.executionHostId !== 'local') { - throw new Error('orcad_migration_dormant_workspace_session_host_invalid') - } - } - } - for (const [ownerKey, groups] of Object.entries(session.tabGroups ?? {})) { - assertOwner(args.owns, ownerKey) - groups.forEach((group) => { - assertOwner(args.owns, group.worktreeId) - addUniqueSessionId(tabGroupIds, group.id) - }) - } - assertOwnerRecordKeys(args.owns, [ - session.activeFileIdByWorktree, - session.activeBrowserTabIdByWorktree, - session.activeTabTypeByWorktree, - session.activeTabIdByWorktree, - session.tabGroupLayouts, - session.activeGroupIdByWorktree, - session.lastVisitedAtByWorktreeId, - session.defaultTerminalTabsAppliedByWorktreeId - ]) - for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) { - if ( - !terminalTabIds.has(tabId) || - Object.keys(layout.ptyIdsByLeafId ?? {}).length > 0 || - Object.keys(layout.buffersByLeafId ?? {}).length > 0 - ) { - throw new Error('orcad_migration_dormant_workspace_session_layout_invalid') - } - } - if (session.activeRepoId !== null && !args.repositoryIds.has(session.activeRepoId)) { - throw new Error('orcad_migration_dormant_workspace_session_focus_scope_invalid') - } - if (session.activeWorktreeId !== null) { - assertOwner(args.owns, session.activeWorktreeId) - } - if (session.activeWorkspaceKey != null) { - assertOwner(args.owns, session.activeWorkspaceKey) - } - if ( - session.activeWorkspaceExecutionHostId !== undefined && - session.activeWorkspaceExecutionHostId !== null && - session.activeWorkspaceExecutionHostId !== 'local' - ) { - throw new Error('orcad_migration_dormant_workspace_session_host_invalid') - } - if (session.activeTabId !== null && !terminalTabIds.has(session.activeTabId)) { - throw new Error('orcad_migration_dormant_workspace_session_focus_scope_invalid') - } - for (const entry of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { - assertOwner(args.owns, entry.worktreeId) - } - for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { - assertOwner(args.owns, record.worktreeId) - if ( - record.connectionId != null || - ('automaticResumeBlockedBy' in record && record.automaticResumeBlockedBy !== undefined) - ) { - throw new Error('orcad_migration_dormant_workspace_session_agent_authority_invalid') - } - if ( - (record.origin === 'quit' || record.origin === 'live') && - !paneBelongsToTerminalLayout(record, session, terminalTabIds) - ) { - throw new Error('orcad_migration_dormant_workspace_session_agent_pane_invalid') - } - } - for (const repoId of Object.keys(session.terminalTopologyRevisionByRepoId ?? {})) { - if (!args.repositoryIds.has(repoId)) { - throw new Error('orcad_migration_dormant_workspace_session_topology_scope_invalid') - } - } - if ( - (session.activeWorktreeIdsOnShutdown?.length ?? 0) > 0 || - (session.activeConnectionIdsAtShutdown?.length ?? 0) > 0 || - Object.keys(session.remoteSessionIdsByTabId ?? {}).length > 0 || - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length > 0 - ) { - throw new Error('orcad_migration_dormant_workspace_session_live_or_client_state_invalid') - } -} - -function ownedEditorFileId( - filePath: string, - worktreeId: string, - runtimeEnvironmentId: string | null | undefined -): string { - const runtimeKey = runtimeEnvironmentId?.trim() || 'local' - return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` -} - -function paneBelongsToTerminalLayout( - record: NonNullable[string], - session: WorkspaceSessionState, - tabIds: ReadonlySet -): boolean { - const separator = record.paneKey.lastIndexOf(':') - if (separator < 1) { - return false - } - const tabId = record.paneKey.slice(0, separator) - const leafId = record.paneKey.slice(separator + 1) - if ((record.tabId !== undefined && record.tabId !== tabId) || !tabIds.has(tabId)) { - return false - } - return terminalLayoutContainsLeaf(session.terminalLayoutsByTabId[tabId]?.root, leafId) -} - -function terminalLayoutContainsLeaf( - node: WorkspaceSessionState['terminalLayoutsByTabId'][string]['root'] | undefined, - leafId: string -): boolean { - return Boolean( - node && - (node.type === 'leaf' - ? node.leafId === leafId - : terminalLayoutContainsLeaf(node.first, leafId) || - terminalLayoutContainsLeaf(node.second, leafId)) - ) -} - -function assertOwnerRecordKeys( - owns: (ownerKey: string) => boolean, - records: (Record | undefined)[] -): void { - for (const record of records) { - Object.keys(record ?? {}).forEach((ownerKey) => assertOwner(owns, ownerKey)) - } -} - -function assertOwner(owns: (ownerKey: string) => boolean, ownerKey: string): void { - if (!owns(ownerKey)) { - throw new Error('orcad_migration_dormant_workspace_session_scope_invalid') - } -} - -function addUniqueSessionId(ids: Set, id: string): void { - if (ids.has(id)) { - throw new Error('orcad_migration_dormant_workspace_session_identity_duplicate') - } - ids.add(id) -} diff --git a/src/shared/orcad-migration-dormant-state-entry-validation.ts b/src/shared/orcad-migration-dormant-state-entry-validation.ts deleted file mode 100644 index 5341b91b578..00000000000 --- a/src/shared/orcad-migration-dormant-state-entry-validation.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { - OrcadMigrationDormantRetiredNames, - OrcadMigrationDormantRetirementNamespace, - OrcadMigrationDormantWorktreeLineage, - OrcadMigrationDormantWorkspaceLineage, - OrcadMigrationDormantWorktreeMeta -} from './orcad-migration-manifest' -import type { SparsePreset } from './worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from './worktree/lineage-types' -import type { WorktreeMeta } from './worktree/meta-types' -import type { RetiredNameRegistry } from './worktree/retired-name-registry' -import { parseWorkspaceKey } from './workspace-scope' -import { - isString, - optionalNullableString, - requiredBoolean, - requiredFinite, - requiredRecord, - requiredString, - requiredStringOrEmpty, - stringArray -} from './orcad-migration-dormant-value-validation' - -const MAX_RETIRED_NAMES = 2_048 - -export function parseWorktreeMetaEntry(value: unknown): OrcadMigrationDormantWorktreeMeta { - const record = requiredRecord(value, 'orcad_migration_dormant_worktree_meta_invalid') - const meta = structuredClone(record.meta) - if (!isMigratedWorktreeMeta(meta)) { - throw new Error('orcad_migration_dormant_worktree_meta_value_invalid') - } - return { - sourceKey: requiredString(record.sourceKey, 'orcad_migration_dormant_source_key_invalid'), - worktreeId: requiredString(record.worktreeId, 'orcad_migration_dormant_worktree_id_invalid'), - meta - } -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedWorktreeMeta(value: unknown): value is WorktreeMeta { - const meta = requiredRecord(value, 'orcad_migration_dormant_worktree_meta_value_invalid') - requiredStringOrEmpty( - meta.displayName, - 'orcad_migration_dormant_worktree_meta_display_name_invalid' - ) - requiredStringOrEmpty(meta.comment, 'orcad_migration_dormant_worktree_meta_comment_invalid') - requiredBoolean(meta.isArchived, 'orcad_migration_dormant_worktree_meta_archived_invalid') - requiredBoolean(meta.isUnread, 'orcad_migration_dormant_worktree_meta_unread_invalid') - requiredBoolean(meta.isPinned, 'orcad_migration_dormant_worktree_meta_pinned_invalid') - requiredFinite(meta.sortOrder, 'orcad_migration_dormant_worktree_meta_sort_order_invalid') - requiredFinite(meta.lastActivityAt, 'orcad_migration_dormant_worktree_meta_activity_invalid') - return true -} - -export function parseWorktreeLineageEntry(value: unknown): OrcadMigrationDormantWorktreeLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_worktree_lineage_invalid') - const lineage = parseWorktreeLineage(record.lineage) - return { - sourceKey: requiredString(record.sourceKey, 'orcad_migration_dormant_source_key_invalid'), - worktreeId: requiredString(record.worktreeId, 'orcad_migration_dormant_worktree_id_invalid'), - lineage - } -} - -export function parseWorkspaceLineageEntry(value: unknown): OrcadMigrationDormantWorkspaceLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_workspace_lineage_invalid') - const lineage = parseWorkspaceLineage(record.lineage) - return { - sourceKey: requiredString(record.sourceKey, 'orcad_migration_dormant_source_key_invalid'), - childWorkspaceKey: requiredString( - record.childWorkspaceKey, - 'orcad_migration_dormant_workspace_key_invalid' - ), - lineage - } -} - -function parseWorktreeLineage(value: unknown): WorktreeLineage { - const copy = structuredClone(value) - if (!isMigratedWorktreeLineage(copy)) { - throw new Error('orcad_migration_dormant_worktree_lineage_value_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedWorktreeLineage(value: unknown): value is WorktreeLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_worktree_lineage_value_invalid') - requiredString(record.worktreeId, 'orcad_migration_dormant_lineage_worktree_invalid') - requiredString(record.worktreeInstanceId, 'orcad_migration_dormant_lineage_instance_invalid') - requiredString(record.parentWorktreeId, 'orcad_migration_dormant_lineage_parent_invalid') - requiredString( - record.parentWorktreeInstanceId, - 'orcad_migration_dormant_lineage_parent_instance_invalid' - ) - parseLineageBase(record) - return true -} - -function parseWorkspaceLineage(value: unknown): WorkspaceLineage { - const lineage = structuredClone(value) - if (!isMigratedWorkspaceLineage(lineage)) { - throw new Error('orcad_migration_dormant_workspace_lineage_value_invalid') - } - return { - ...lineage, - childInstanceId: lineage.childInstanceId ?? null, - parentInstanceId: lineage.parentInstanceId ?? null - } -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedWorkspaceLineage(value: unknown): value is WorkspaceLineage { - const record = requiredRecord(value, 'orcad_migration_dormant_workspace_lineage_value_invalid') - const child = requiredString( - record.childWorkspaceKey, - 'orcad_migration_dormant_lineage_child_invalid' - ) - const parent = requiredString( - record.parentWorkspaceKey, - 'orcad_migration_dormant_lineage_parent_invalid' - ) - if (!parseWorkspaceKey(child) || !parseWorkspaceKey(parent)) { - throw new Error('orcad_migration_dormant_lineage_workspace_key_invalid') - } - parseLineageBase(record) - optionalNullableString( - record.childInstanceId, - 'orcad_migration_dormant_lineage_child_instance_invalid' - ) - optionalNullableString( - record.parentInstanceId, - 'orcad_migration_dormant_lineage_parent_instance_invalid' - ) - return true -} - -function parseLineageBase(record: Record): void { - if (!['orchestration', 'cli', 'manual'].includes(String(record.origin))) { - throw new Error('orcad_migration_dormant_lineage_origin_invalid') - } - const capture = requiredRecord(record.capture, 'orcad_migration_dormant_lineage_capture_invalid') - if (!['explicit', 'inferred'].includes(String(capture.confidence))) { - throw new Error('orcad_migration_dormant_lineage_confidence_invalid') - } - requiredString(capture.source, 'orcad_migration_dormant_lineage_source_invalid') - requiredFinite(record.createdAt, 'orcad_migration_dormant_lineage_created_at_invalid') -} - -export function parseSparsePreset(value: unknown): SparsePreset { - const copy = structuredClone(value) - if (!isMigratedSparsePreset(copy)) { - throw new Error('orcad_migration_dormant_sparse_preset_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedSparsePreset(value: unknown): value is SparsePreset { - const record = requiredRecord(value, 'orcad_migration_dormant_sparse_preset_invalid') - requiredString(record.id, 'orcad_migration_dormant_sparse_preset_id_invalid') - requiredString(record.repoId, 'orcad_migration_dormant_sparse_preset_repo_invalid') - requiredString(record.name, 'orcad_migration_dormant_sparse_preset_name_invalid') - if (!Array.isArray(record.directories) || !record.directories.every(isString)) { - throw new Error('orcad_migration_dormant_sparse_preset_directories_invalid') - } - requiredFinite(record.createdAt, 'orcad_migration_dormant_sparse_preset_created_at_invalid') - requiredFinite(record.updatedAt, 'orcad_migration_dormant_sparse_preset_updated_at_invalid') - return true -} - -export function parseRetiredNames(value: unknown): OrcadMigrationDormantRetiredNames { - const record = requiredRecord(value, 'orcad_migration_dormant_retired_names_invalid') - return { - repoId: requiredString(record.repoId, 'orcad_migration_dormant_retired_names_repo_invalid'), - registry: parseRetiredNameRegistry(record.registry) - } -} - -export function parseRetirementNamespace(value: unknown): OrcadMigrationDormantRetirementNamespace { - const record = requiredRecord(value, 'orcad_migration_dormant_retirement_namespace_invalid') - const sourceNamespaceKeys = stringArray(record.sourceNamespaceKeys) - if (sourceNamespaceKeys.length === 0) { - throw new Error('orcad_migration_dormant_retirement_namespace_sources_invalid') - } - return { - sourceNamespaceKeys, - namespaceKey: requiredString( - record.namespaceKey, - 'orcad_migration_dormant_retirement_namespace_key_invalid' - ), - registry: parseRetiredNameRegistry(record.registry) - } -} - -function parseRetiredNameRegistry(value: unknown): RetiredNameRegistry { - const record = requiredRecord(value, 'orcad_migration_dormant_retired_registry_invalid') - if (!Number.isInteger(record.exhaustedTiers) || Number(record.exhaustedTiers) < 0) { - throw new Error('orcad_migration_dormant_retired_registry_watermark_invalid') - } - const names = stringArray(record.names, MAX_RETIRED_NAMES) - return { exhaustedTiers: Number(record.exhaustedTiers), names } -} diff --git a/src/shared/orcad-migration-dormant-state-validation.ts b/src/shared/orcad-migration-dormant-state-validation.ts deleted file mode 100644 index 87793ef1133..00000000000 --- a/src/shared/orcad-migration-dormant-state-validation.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { OrcadMigrationDormantStatePayload } from './orcad-migration-manifest' -import { getRepoIdFromWorktreeId } from './worktree/id' -import { parseWorkspaceKey } from './workspace-scope' -import { - assertOrcadMigrationDormantWorkspaceSessionReferences, - parseOrcadMigrationDormantWorkspaceSession -} from './orcad-migration-dormant-session-validation' -import { - assertOrcadMigrationDormantAutomationReferences, - parseOrcadMigrationDormantAutomationRuns, - parseOrcadMigrationDormantAutomations -} from './orcad-migration-dormant-automation-validation' -import { - assertOrcadMigrationScrollbackReferences, - parseOrcadMigrationTerminalScrollbackSnapshots -} from './orcad-migration-scrollback' -import { - assertOrcadMigrationClientStateReferences, - parseOrcadMigrationClientState -} from './orcad-migration-client-state' -import { - assertUnique, - boundedArray, - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - requiredRecord -} from './orcad-migration-dormant-value-validation' -import { - parseRetiredNames, - parseRetirementNamespace, - parseSparsePreset, - parseWorkspaceLineageEntry, - parseWorktreeLineageEntry, - parseWorktreeMetaEntry -} from './orcad-migration-dormant-state-entry-validation' - -export { - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - MAX_ORCAD_MIGRATION_DORMANT_ROWS -} from './orcad-migration-dormant-value-validation' - -export const ORCAD_MIGRATION_DORMANT_STATE_VERSION = 1 as const -export function parseOrcadMigrationDormantState(value: unknown): OrcadMigrationDormantStatePayload { - const record = requiredRecord(value, 'orcad_migration_dormant_state_invalid') - if (record.version !== ORCAD_MIGRATION_DORMANT_STATE_VERSION) { - throw new Error('orcad_migration_dormant_state_version_unsupported') - } - const payload: OrcadMigrationDormantStatePayload = { - version: ORCAD_MIGRATION_DORMANT_STATE_VERSION, - worktreeMeta: boundedArray(record.worktreeMeta, parseWorktreeMetaEntry, 'worktree_meta'), - worktreeLineage: boundedArray( - record.worktreeLineage, - parseWorktreeLineageEntry, - 'worktree_lineage' - ), - workspaceLineage: boundedArray( - record.workspaceLineage, - parseWorkspaceLineageEntry, - 'workspace_lineage' - ), - sparsePresets: boundedArray(record.sparsePresets, parseSparsePreset, 'sparse_presets'), - retiredWorktreeNames: boundedArray( - record.retiredWorktreeNames, - parseRetiredNames, - 'retired_names' - ), - retiredWorktreeNamespaces: boundedArray( - record.retiredWorktreeNamespaces, - parseRetirementNamespace, - 'retirement_namespaces', - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES - ), - ...(record.workspaceSession === undefined - ? {} - : { workspaceSession: parseOrcadMigrationDormantWorkspaceSession(record.workspaceSession) }), - ...(record.terminalScrollbackSnapshots === undefined - ? {} - : { - terminalScrollbackSnapshots: parseOrcadMigrationTerminalScrollbackSnapshots( - record.terminalScrollbackSnapshots - ) - }), - ...(record.automations === undefined - ? {} - : { automations: parseOrcadMigrationDormantAutomations(record.automations) }), - ...(record.automationRuns === undefined - ? {} - : { automationRuns: parseOrcadMigrationDormantAutomationRuns(record.automationRuns) }), - ...(record.clientState === undefined - ? {} - : { clientState: parseOrcadMigrationClientState(record.clientState) }) - } - assertUnique(payload.worktreeMeta, (entry) => entry.worktreeId, 'worktree_meta') - assertUnique(payload.worktreeMeta, (entry) => entry.sourceKey, 'worktree_meta_source') - assertUnique(payload.worktreeLineage, (entry) => entry.worktreeId, 'worktree_lineage') - assertUnique(payload.worktreeLineage, (entry) => entry.sourceKey, 'worktree_lineage_source') - assertUnique(payload.workspaceLineage, (entry) => entry.childWorkspaceKey, 'workspace_lineage') - assertUnique(payload.workspaceLineage, (entry) => entry.sourceKey, 'workspace_lineage_source') - assertUnique(payload.sparsePresets, (entry) => `${entry.repoId}\0${entry.id}`, 'sparse_preset') - assertUnique(payload.retiredWorktreeNames, (entry) => entry.repoId, 'retired_names') - assertUnique( - payload.retiredWorktreeNamespaces, - (entry) => entry.namespaceKey, - 'retirement_namespace' - ) - return payload -} - -export function assertOrcadMigrationDormantStateReferences(args: { - dormantState: OrcadMigrationDormantStatePayload - repositoryIds: ReadonlySet - folderWorkspaceIds: ReadonlySet -}): void { - const owns = (value: string): boolean => { - const parsed = parseWorkspaceKey(value) - if (parsed?.type === 'folder') { - return args.folderWorkspaceIds.has(parsed.folderWorkspaceId) - } - const worktreeId = parsed?.type === 'worktree' ? parsed.worktreeId : value - return args.repositoryIds.has(getRepoIdFromWorktreeId(worktreeId)) - } - for (const entry of args.dormantState.worktreeMeta) { - if (!owns(entry.worktreeId) || entry.meta.hostId !== 'local') { - throw new Error('orcad_migration_dormant_worktree_meta_scope_invalid') - } - } - for (const entry of args.dormantState.worktreeLineage) { - if ( - entry.worktreeId !== entry.lineage.worktreeId || - !owns(entry.worktreeId) || - !owns(entry.lineage.parentWorktreeId) - ) { - throw new Error('orcad_migration_dormant_worktree_lineage_scope_invalid') - } - } - for (const entry of args.dormantState.workspaceLineage) { - if ( - entry.childWorkspaceKey !== entry.lineage.childWorkspaceKey || - !owns(entry.childWorkspaceKey) || - !owns(entry.lineage.parentWorkspaceKey) - ) { - throw new Error('orcad_migration_dormant_workspace_lineage_scope_invalid') - } - } - for (const preset of args.dormantState.sparsePresets) { - if (!args.repositoryIds.has(preset.repoId)) { - throw new Error('orcad_migration_dormant_sparse_preset_scope_invalid') - } - } - for (const entry of args.dormantState.retiredWorktreeNames) { - if (!args.repositoryIds.has(entry.repoId)) { - throw new Error('orcad_migration_dormant_retired_names_scope_invalid') - } - } - for (const entry of args.dormantState.retiredWorktreeNamespaces) { - if ( - !entry.namespaceKey.startsWith('local:') || - entry.sourceNamespaceKeys.some((key) => !key.startsWith('ssh:')) - ) { - throw new Error('orcad_migration_dormant_retirement_namespace_scope_invalid') - } - } - if (args.dormantState.workspaceSession) { - assertOrcadMigrationDormantWorkspaceSessionReferences({ - session: args.dormantState.workspaceSession, - owns, - repositoryIds: args.repositoryIds - }) - } - assertOrcadMigrationScrollbackReferences( - args.dormantState.workspaceSession, - args.dormantState.terminalScrollbackSnapshots ?? [] - ) - assertOrcadMigrationDormantAutomationReferences({ - automations: args.dormantState.automations ?? [], - automationRuns: args.dormantState.automationRuns ?? [], - owns, - repositoryIds: args.repositoryIds - }) - assertOrcadMigrationClientStateReferences({ - clientState: args.dormantState.clientState, - repositoryIds: args.repositoryIds, - owns - }) -} diff --git a/src/shared/orcad-migration-dormant-value-validation.ts b/src/shared/orcad-migration-dormant-value-validation.ts deleted file mode 100644 index beb7ea136f4..00000000000 --- a/src/shared/orcad-migration-dormant-value-validation.ts +++ /dev/null @@ -1,100 +0,0 @@ -export const MAX_ORCAD_MIGRATION_DORMANT_ROWS = 16_384 -export const MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES = 256 - -export function boundedArray( - value: unknown, - parse: (entry: unknown) => T, - label: string, - maximum = MAX_ORCAD_MIGRATION_DORMANT_ROWS -): T[] { - if (!Array.isArray(value)) { - throw new Error(`orcad_migration_dormant_${label}_invalid`) - } - if (value.length > maximum) { - throw new Error(`orcad_migration_dormant_${label}_too_many`) - } - return value.map(parse) -} - -export function stringArray(value: unknown, maximum = MAX_ORCAD_MIGRATION_DORMANT_ROWS): string[] { - if (!Array.isArray(value) || value.length > maximum) { - throw new Error('orcad_migration_dormant_string_array_invalid') - } - const result = value.filter(isString) - if ( - result.length !== value.length || - result.some((entry) => !entry) || - new Set(result).size !== result.length - ) { - throw new Error('orcad_migration_dormant_string_array_invalid') - } - return result -} - -export function assertUnique(values: T[], key: (value: T) => string, label: string): void { - const keys = values.map(key) - if (new Set(keys).size !== keys.length) { - throw new Error(`orcad_migration_dormant_${label}_duplicate`) - } -} - -export function requiredRecord(value: unknown, error: string): Record { - if (!isRecordValue(value)) { - throw new Error(error) - } - return value -} - -function isRecordValue(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function requiredString(value: unknown, error: string): string { - if (typeof value !== 'string' || !value) { - throw new Error(error) - } - return value -} - -export function requiredStringOrEmpty(value: unknown, error: string): string { - if (typeof value !== 'string') { - throw new Error(error) - } - return value -} - -export function optionalNullableString(value: unknown, error: string): string | null | undefined { - if (value !== undefined && value !== null && typeof value !== 'string') { - throw new Error(error) - } - return value -} - -export function requiredBoolean(value: unknown, error: string): void { - if (typeof value !== 'boolean') { - throw new Error(error) - } -} - -export function requiredFinite(value: unknown, error: string): void { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new Error(error) - } -} - -export function optionalFinite(value: unknown, error: string, nullable = false): void { - if (value === undefined || (nullable && value === null)) { - return - } - requiredFinite(value, error) -} - -export function optionalPositiveInteger(value: unknown, error: string): void { - if (value !== undefined && (!Number.isSafeInteger(value) || Number(value) < 1)) { - throw new Error(error) - } -} - -export function isString(value: unknown): value is string { - return typeof value === 'string' -} diff --git a/src/shared/orcad-migration-manifest-contract.test.ts b/src/shared/orcad-migration-manifest-contract.test.ts deleted file mode 100644 index 0a2ea6d6bba..00000000000 --- a/src/shared/orcad-migration-manifest-contract.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest -} from './orcad-migration-manifest' - -const digest = 'a'.repeat(64) - -function manifest(overrides: { version?: unknown; dormantState?: unknown } = {}) { - return { - version: overrides.version ?? ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-contract', - createdAt: '2026-10-01T12:00:00.000Z', - source: { sshTargetId: 'target', sshTargetGeneration: 3, targetLabel: 'Host' }, - payload: { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1 - } - ], - projectGroups: [ - { - id: 'group-1', - name: 'Folders', - tabOrder: 0, - createdAt: 1, - updatedAt: 1, - parentPath: null, - parentGroupId: null, - isCollapsed: false, - color: null, - createdFrom: 'manual' - } - ], - folderWorkspaces: [ - { - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Notes', - folderPath: '/srv/notes', - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1, - isArchived: false, - isUnread: false, - isPinned: false - } - ], - ...(overrides.dormantState === undefined ? {} : { dormantState: overrides.dormantState }) - }, - manifestSha256: digest - } -} - -function dormantLineage(childWorkspaceKey: string, parentWorkspaceKey: string) { - const lineage = { - childWorkspaceKey, - parentWorkspaceKey, - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: 1 - } - return { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [{ sourceKey: childWorkspaceKey, childWorkspaceKey, lineage }], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [] - } -} - -describe('orcad migration manifest contract', () => { - it('is an explicit version 1 artifact', () => { - expect(ORCAD_MIGRATION_MANIFEST_VERSION).toBe(1) - expect(parseOrcadMigrationManifest(manifest()).version).toBe(1) - }) - - it.each([2, 10, 0, '1', undefined, null])( - 'rejects manifest version %j instead of guessing its shape', - (version) => { - expect(() => parseOrcadMigrationManifest({ ...manifest(), version })).toThrow( - 'orcad_migration_manifest_version_unsupported' - ) - } - ) - - it('validates folder workspace keys alongside worktree workspace keys', () => { - const parsed = parseOrcadMigrationManifest( - manifest({ dormantState: dormantLineage('folder:folder-1', 'worktree:repo-1::/srv/repo-1') }) - ) - expect(parsed.payload.dormantState?.workspaceLineage[0]?.lineage).toMatchObject({ - childWorkspaceKey: 'folder:folder-1', - parentWorkspaceKey: 'worktree:repo-1::/srv/repo-1', - childInstanceId: null, - parentInstanceId: null - }) - }) - - it.each([ - ['a folder workspace outside the catalog', 'folder:folder-2'], - ['a worktree outside the catalog', 'worktree:repo-2::/srv/repo-2'] - ])('refuses lineage that names %s', (_label, key) => { - expect(() => - parseOrcadMigrationManifest( - manifest({ dormantState: dormantLineage(key, 'folder:folder-1') }) - ) - ).toThrow('orcad_migration_dormant_workspace_lineage_scope_invalid') - }) - - it('rejects a malformed workspace key rather than treating it as a worktree id', () => { - expect(() => - parseOrcadMigrationManifest( - manifest({ dormantState: dormantLineage('folder:', 'folder:folder-1') }) - ) - ).toThrow('orcad_migration_dormant_lineage_workspace_key_invalid') - }) -}) diff --git a/src/shared/orcad-migration-manifest-fields.ts b/src/shared/orcad-migration-manifest-fields.ts deleted file mode 100644 index 3a038fa0bd8..00000000000 --- a/src/shared/orcad-migration-manifest-fields.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Field checks shared by the manifest and receipt parsers; each throws a labeled error. - -export function boundedArray( - value: unknown, - maximum: number, - parse: (entry: unknown) => T, - label: string -): T[] { - if (!Array.isArray(value)) { - throw new Error(`orcad_migration_manifest_${label}_invalid`) - } - if (value.length > maximum) { - throw new Error(`orcad_migration_manifest_${label}_too_many`) - } - return value.map(parse) -} - -export function assertUniqueIds(values: { id: string }[], label: string): void { - const ids = new Set() - for (const value of values) { - if (ids.has(value.id)) { - throw new Error(`orcad_migration_manifest_${label}_duplicate_id`) - } - ids.add(value.id) - } -} - -export function boundedStringArray(value: unknown, maximum: number, label: string): string[] { - if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string' && entry)) { - throw new Error(`${label}_invalid`) - } - if (value.length > maximum || new Set(value).size !== value.length) { - throw new Error(`${label}_invalid`) - } - return [...value] -} - -export function requiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.length === 0) { - throw new Error(`${label}_invalid`) - } - return value -} - -export function requiredFiniteNumber(value: unknown, label: string): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new Error(`${label}_invalid`) - } - return value -} - -export function requiredDate(value: unknown, label: string): string { - const result = requiredString(value, label) - if (!Number.isFinite(Date.parse(result))) { - throw new Error(`${label}_invalid`) - } - return result -} - -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-migration-manifest-validation.ts b/src/shared/orcad-migration-manifest-validation.ts deleted file mode 100644 index 8272463c5d0..00000000000 --- a/src/shared/orcad-migration-manifest-validation.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { FolderWorkspace } from './folder-workspace-types' -import type { - OrcadMigrationCatalogPayload, - OrcadMigrationImportReceipt, - OrcadMigrationManifest, - OrcadMigrationManifestSource -} from './orcad-migration-manifest' -import type { ProjectGroup } from './project-group-types' -import type { Repo } from './repo-types' -import { - assertUniqueIds, - boundedArray, - boundedStringArray, - isRecord, - requiredDate, - requiredFiniteNumber, - requiredString -} from './orcad-migration-manifest-fields' -import { - assertOrcadMigrationDormantStateReferences, - parseOrcadMigrationDormantState -} from './orcad-migration-dormant-state-validation' - -export const ORCAD_MIGRATION_MANIFEST_VERSION = 1 as const -export type OrcadMigrationManifestVersion = typeof ORCAD_MIGRATION_MANIFEST_VERSION -export const MAX_ORCAD_MIGRATION_MANIFEST_BYTES = 768 * 1024 -export const MAX_ORCAD_MIGRATION_REPOSITORIES = 1_024 -export const MAX_ORCAD_MIGRATION_PROJECT_GROUPS = 4_096 -export const MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES = 16_384 -export const MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS = 64 - -export function parseOrcadMigrationManifest(value: unknown): OrcadMigrationManifest { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_invalid') - } - if (value.version !== ORCAD_MIGRATION_MANIFEST_VERSION) { - throw new Error('orcad_migration_manifest_version_unsupported') - } - const migrationId = requiredString(value.migrationId, 'migrationId') - const createdAt = requiredDate(value.createdAt, 'createdAt') - const source = parseSource(value.source) - const payload = parsePayload(value.payload) - const manifestSha256 = requiredString(value.manifestSha256, 'manifestSha256') - if (!/^[a-f0-9]{64}$/.test(manifestSha256)) { - throw new Error('orcad_migration_manifest_digest_invalid') - } - const manifest: OrcadMigrationManifest = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId, - createdAt, - source, - payload, - ...(value.destinationEnvironmentId === undefined - ? {} - : { - destinationEnvironmentId: requiredString( - value.destinationEnvironmentId, - 'destinationEnvironmentId' - ) - }), - manifestSha256 - } - const bytes = new TextEncoder().encode(JSON.stringify(manifest)).byteLength - if (bytes > MAX_ORCAD_MIGRATION_MANIFEST_BYTES) { - throw new Error('orcad_migration_manifest_too_large') - } - return manifest -} - -export function normalizeOrcadMigrationImportReceipts( - value: unknown -): OrcadMigrationImportReceipt[] { - if (!Array.isArray(value)) { - return [] - } - const receipts: OrcadMigrationImportReceipt[] = [] - const seen = new Set() - for (let index = value.length - 1; index >= 0; index--) { - try { - const receipt = parseReceipt(value[index]) - if (!seen.has(receipt.migrationId)) { - seen.add(receipt.migrationId) - receipts.push(receipt) - if (receipts.length === MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS) { - break - } - } - } catch { - // Invalid receipts cannot prove an import and are dropped fail-closed. - } - } - // Keep persisted receipts readable in the documented Node 18 rollback slot. - return receipts.reduceRight((reversed, receipt) => { - reversed.push(receipt) - return reversed - }, []) -} - -function parsePayload(value: unknown): OrcadMigrationCatalogPayload { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_payload_invalid') - } - const repositories = boundedArray( - value.repositories, - MAX_ORCAD_MIGRATION_REPOSITORIES, - parseRepository, - 'repositories' - ) - const projectGroups = boundedArray( - value.projectGroups, - MAX_ORCAD_MIGRATION_PROJECT_GROUPS, - parseProjectGroup, - 'projectGroups' - ) - const folderWorkspaces = boundedArray( - value.folderWorkspaces, - MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, - parseFolderWorkspace, - 'folderWorkspaces' - ) - assertUniqueIds(repositories, 'repositories') - assertUniqueIds(projectGroups, 'projectGroups') - assertUniqueIds(folderWorkspaces, 'folderWorkspaces') - const dormantState = - value.dormantState === undefined - ? undefined - : parseOrcadMigrationDormantState(value.dormantState) - if (dormantState) { - assertOrcadMigrationDormantStateReferences({ - dormantState, - repositoryIds: new Set(repositories.map((repo) => repo.id)), - folderWorkspaceIds: new Set(folderWorkspaces.map((workspace) => workspace.id)) - }) - } - return { - repositories, - projectGroups, - folderWorkspaces, - ...(dormantState ? { dormantState } : {}) - } -} - -function parseSource(value: unknown): OrcadMigrationManifestSource { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_source_invalid') - } - const generation = value.sshTargetGeneration - if (generation !== null && (!Number.isSafeInteger(generation) || Number(generation) < 1)) { - throw new Error('orcad_migration_manifest_source_generation_invalid') - } - return { - sshTargetId: requiredString(value.sshTargetId, 'source.sshTargetId'), - sshTargetGeneration: generation === null ? null : Number(generation), - targetLabel: requiredString(value.targetLabel, 'source.targetLabel') - } -} - -function parseRepository(value: unknown): Repo { - const copy = structuredClone(value) - if (!isMigratedRepository(copy)) { - throw new Error('orcad_migration_manifest_repository_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedRepository(value: unknown): value is Repo { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_repository_invalid') - } - requiredString(value.id, 'repository.id') - requiredString(value.path, 'repository.path') - requiredString(value.displayName, 'repository.displayName') - requiredString(value.badgeColor, 'repository.badgeColor') - requiredFiniteNumber(value.addedAt, 'repository.addedAt') - if (value.kind !== undefined && value.kind !== 'git' && value.kind !== 'folder') { - throw new Error('orcad_migration_manifest_repository_kind_invalid') - } - return true -} - -function parseProjectGroup(value: unknown): ProjectGroup { - const copy = structuredClone(value) - if (!isMigratedProjectGroup(copy)) { - throw new Error('orcad_migration_manifest_project_group_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedProjectGroup(value: unknown): value is ProjectGroup { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_project_group_invalid') - } - requiredString(value.id, 'projectGroup.id') - requiredString(value.name, 'projectGroup.name') - requiredFiniteNumber(value.tabOrder, 'projectGroup.tabOrder') - requiredFiniteNumber(value.createdAt, 'projectGroup.createdAt') - requiredFiniteNumber(value.updatedAt, 'projectGroup.updatedAt') - if (value.parentPath !== null && typeof value.parentPath !== 'string') { - throw new Error('orcad_migration_manifest_project_group_parent_path_invalid') - } - if (value.parentGroupId !== null && typeof value.parentGroupId !== 'string') { - throw new Error('orcad_migration_manifest_project_group_parent_invalid') - } - if (typeof value.isCollapsed !== 'boolean') { - throw new Error('orcad_migration_manifest_project_group_collapsed_invalid') - } - if (value.color !== null && typeof value.color !== 'string') { - throw new Error('orcad_migration_manifest_project_group_color_invalid') - } - if (!['manual', 'folder-scan', 'migration'].includes(String(value.createdFrom))) { - throw new Error('orcad_migration_manifest_project_group_origin_invalid') - } - return true -} - -function parseFolderWorkspace(value: unknown): FolderWorkspace { - const copy = structuredClone(value) - if (!isMigratedFolderWorkspace(copy)) { - throw new Error('orcad_migration_manifest_folder_workspace_invalid') - } - return copy -} - -/** Throws the specific field error; narrowing only follows these checks. */ -function isMigratedFolderWorkspace(value: unknown): value is FolderWorkspace { - if (!isRecord(value)) { - throw new Error('orcad_migration_manifest_folder_workspace_invalid') - } - requiredString(value.id, 'folderWorkspace.id') - requiredString(value.projectGroupId, 'folderWorkspace.projectGroupId') - requiredString(value.name, 'folderWorkspace.name') - requiredString(value.folderPath, 'folderWorkspace.folderPath') - requiredFiniteNumber(value.sortOrder, 'folderWorkspace.sortOrder') - requiredFiniteNumber(value.lastActivityAt, 'folderWorkspace.lastActivityAt') - requiredFiniteNumber(value.createdAt, 'folderWorkspace.createdAt') - requiredFiniteNumber(value.updatedAt, 'folderWorkspace.updatedAt') - for (const key of ['isArchived', 'isUnread', 'isPinned'] as const) { - if (typeof value[key] !== 'boolean') { - throw new Error(`orcad_migration_manifest_folder_workspace_${key}_invalid`) - } - } - return true -} - -function parseReceipt(value: unknown): OrcadMigrationImportReceipt { - if (!isRecord(value) || value.version !== ORCAD_MIGRATION_MANIFEST_VERSION) { - throw new Error('orcad_migration_receipt_invalid') - } - const manifestSha256 = requiredString(value.manifestSha256, 'receipt.manifestSha256') - if (!/^[a-f0-9]{64}$/.test(manifestSha256)) { - throw new Error('orcad_migration_receipt_digest_invalid') - } - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: requiredString(value.migrationId, 'receipt.migrationId'), - manifestSha256, - source: parseSource(value.source), - importedAt: requiredDate(value.importedAt, 'receipt.importedAt'), - repositoryIds: boundedStringArray( - value.repositoryIds, - MAX_ORCAD_MIGRATION_REPOSITORIES, - 'receipt.repositoryIds' - ), - projectGroupIds: boundedStringArray( - value.projectGroupIds, - MAX_ORCAD_MIGRATION_PROJECT_GROUPS, - 'receipt.projectGroupIds' - ), - folderWorkspaceIds: boundedStringArray( - value.folderWorkspaceIds, - MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, - 'receipt.folderWorkspaceIds' - ) - } -} diff --git a/src/shared/orcad-migration-manifest.test.ts b/src/shared/orcad-migration-manifest.test.ts deleted file mode 100644 index f677d54ff22..00000000000 --- a/src/shared/orcad-migration-manifest.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from './constants' -import { - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - MAX_ORCAD_MIGRATION_DORMANT_ROWS, - MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS, - MAX_ORCAD_MIGRATION_MANIFEST_BYTES, - normalizeOrcadMigrationImportReceipts, - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest, - serializeOrcadMigrationValue, - type OrcadMigrationImportReceipt -} from './orcad-migration-manifest' - -function receipt(index: number, overrides: Partial = {}) { - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: `migration-${index}`, - manifestSha256: index.toString(16).padStart(64, '0'), - source: { - sshTargetId: 'ssh-prod', - sshTargetGeneration: 1, - targetLabel: 'Production' - }, - importedAt: new Date(index * 1_000).toISOString(), - repositoryIds: [], - projectGroupIds: [], - folderWorkspaceIds: [], - ...overrides - } -} - -describe('orcad migration manifest', () => { - it('serializes object keys deterministically without locale ordering', () => { - expect( - serializeOrcadMigrationValue({ z: 1, Z: 2, nested: { ä: 3, a: 4 }, omitted: undefined }) - ).toBe('{"Z":2,"nested":{"a":4,"ä":3},"z":1}') - }) - - it('keeps the newest bounded set of valid unique receipts', () => { - const candidates: unknown[] = Array.from( - { length: MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS + 2 }, - (_, index) => receipt(index) - ) - candidates.push(receipt(10, { source: { ...receipt(10).source, targetLabel: 'Newest' } })) - candidates.push({ invalid: true }) - - const normalized = normalizeOrcadMigrationImportReceipts(candidates) - - expect(normalized).toHaveLength(MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS) - expect(normalized.some((entry) => entry.migrationId === 'migration-0')).toBe(false) - expect( - normalized.find((entry) => entry.migrationId === 'migration-10')?.source.targetLabel - ).toBe('Newest') - }) - - it('normalizes receipts without Node 20-only array methods', () => { - const prototype: object = Array.prototype - const descriptor = Object.getOwnPropertyDescriptor(prototype, 'toReversed') - try { - Object.defineProperty(prototype, 'toReversed', { - configurable: true, - value: undefined, - writable: true - }) - expect(normalizeOrcadMigrationImportReceipts([receipt(1), receipt(2)])).toHaveLength(2) - } finally { - if (descriptor) { - Object.defineProperty(prototype, 'toReversed', descriptor) - } else { - Reflect.deleteProperty(prototype, 'toReversed') - } - } - }) - - it('drops a receipt with duplicate catalog identities', () => { - expect( - normalizeOrcadMigrationImportReceipts([receipt(1, { repositoryIds: ['repo-1', 'repo-1'] })]) - ).toEqual([]) - }) - - it('accepts bounded dormant state and rejects rows outside the catalog scope', () => { - const candidate = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-dormant', - createdAt: '2026-08-30T12:00:00.000Z', - source: receipt(1).source, - payload: { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1 - } - ], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [ - { - sourceKey: 'repo-1::/srv/worktree', - worktreeId: 'repo-1::/srv/worktree', - meta: { - displayName: '', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 1, - hostId: 'local' - } - } - ], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - workspaceSession: { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { - 'repo-1::/srv/worktree': [ - { - id: 'tab-dormant', - ptyId: null, - worktreeId: 'repo-1::/srv/worktree', - title: 'Dormant terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } - } - } - }, - manifestSha256: 'a'.repeat(64) - } - - expect(parseOrcadMigrationManifest(candidate).payload.dormantState?.worktreeMeta).toHaveLength( - 1 - ) - expect( - parseOrcadMigrationManifest(candidate).payload.dormantState?.workspaceSession?.tabsByWorktree - ).toHaveProperty('repo-1::/srv/worktree') - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - worktreeMeta: [ - { - ...candidate.payload.dormantState.worktreeMeta[0], - worktreeId: 'repo-other::/srv/worktree' - } - ] - } - } - }) - ).toThrow('orcad_migration_dormant_worktree_meta_scope_invalid') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - workspaceSession: { - ...candidate.payload.dormantState.workspaceSession, - tabsByWorktree: { - 'repo-1::/srv/worktree': [ - { - ...candidate.payload.dormantState.workspaceSession.tabsByWorktree[ - 'repo-1::/srv/worktree' - ][0], - ptyId: 'pty-live' - } - ] - } - } - } - } - }) - ).toThrow('orcad_migration_dormant_workspace_session_pty_invalid') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - worktreeMeta: Array.from( - { length: MAX_ORCAD_MIGRATION_DORMANT_ROWS + 1 }, - () => candidate.payload.dormantState.worktreeMeta[0] - ) - } - } - }) - ).toThrow('orcad_migration_dormant_worktree_meta_too_many') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - retiredWorktreeNamespaces: Array.from( - { length: MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES + 1 }, - () => ({ - sourceNamespaceKeys: ['ssh:source:/srv/orca-worktrees'], - namespaceKey: 'local:/srv/orca-worktrees', - registry: { exhaustedTiers: 0, names: ['nautilus'] } - }) - ) - } - } - }) - ).toThrow('orcad_migration_dormant_retirement_namespaces_too_many') - - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - worktreeMeta: [ - { - ...candidate.payload.dormantState.worktreeMeta[0], - meta: { - ...candidate.payload.dormantState.worktreeMeta[0].meta, - comment: 'x'.repeat(MAX_ORCAD_MIGRATION_MANIFEST_BYTES) - } - } - ] - } - } - }) - ).toThrow('orcad_migration_manifest_too_large') - }) - - it('accepts only destination-owned disabled automations with final run history', () => { - const automation = { - id: 'automation-1', - name: 'Nightly checks', - prompt: 'Run tests', - precheck: null, - agentId: 'codex', - runContext: { - kind: 'workspace-run', - projectId: 'repo:repo-1', - hostId: 'local', - projectHostSetupId: 'repo-1', - repoId: 'repo-1', - path: '/srv/repo-1' - }, - sourceContext: null, - projectId: 'repo-1', - executionTargetType: 'local', - executionTargetId: 'local', - schedulerOwner: 'remote_host_service', - workspaceMode: 'new_per_run', - workspaceId: null, - baseBranch: 'main', - reuseSession: false, - timezone: 'UTC', - rrule: 'FREQ=DAILY', - dtstart: 1, - enabled: false, - nextRunAt: 2, - missedRunPolicy: 'run_once_within_grace', - missedRunGraceMinutes: 60, - createdAt: 1, - updatedAt: 2 - } - const run = { - id: 'run-1', - automationId: automation.id, - runContext: automation.runContext, - sourceContext: null, - title: 'Nightly checks run 1', - scheduledFor: 1, - status: 'completed', - trigger: 'scheduled', - workspaceId: null, - sessionKind: 'terminal', - chatSessionId: null, - terminalSessionId: 'tab-history', - terminalPaneKey: null, - terminalPtyId: 'pty-history', - outputSnapshot: { - format: 'plain_text', - content: 'all green', - capturedAt: 2, - truncated: false - }, - precheckResult: null, - usage: null, - error: null, - startedAt: 1, - dispatchedAt: 1, - createdAt: 1 - } - const candidate = { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId: 'migration-automation', - createdAt: '2026-08-30T12:00:00.000Z', - source: receipt(1).source, - payload: { - repositories: [ - { - id: 'repo-1', - path: '/srv/repo-1', - displayName: 'Repo', - badgeColor: '#737373', - addedAt: 1 - } - ], - projectGroups: [], - folderWorkspaces: [], - dormantState: { - version: 1, - worktreeMeta: [], - worktreeLineage: [], - workspaceLineage: [], - sparsePresets: [], - retiredWorktreeNames: [], - retiredWorktreeNamespaces: [], - automations: [automation], - automationRuns: [run] - } - }, - manifestSha256: 'a'.repeat(64) - } - - expect( - parseOrcadMigrationManifest(candidate).payload.dormantState?.automationRuns?.[0] - ?.outputSnapshot?.content - ).toBe('all green') - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - automations: [{ ...automation, enabled: true }] - } - } - }) - ).toThrow('orcad_migration_dormant_automation_owner_invalid') - expect(() => - parseOrcadMigrationManifest({ - ...candidate, - payload: { - ...candidate.payload, - dormantState: { - ...candidate.payload.dormantState, - automationRuns: [{ ...run, status: 'dispatching' }] - } - } - }) - ).toThrow('orcad_migration_dormant_automation_run_status_invalid') - }) -}) diff --git a/src/shared/orcad-migration-manifest.ts b/src/shared/orcad-migration-manifest.ts deleted file mode 100644 index 9a0d1eab1ec..00000000000 --- a/src/shared/orcad-migration-manifest.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { FolderWorkspace } from './folder-workspace-types' -import type { Automation, AutomationRun } from './automations-types' -import type { OrcadMigrationManifestVersion } from './orcad-migration-manifest-validation' -import type { ProjectGroup } from './project-group-types' -import type { Repo } from './repo-types' -import type { SparsePreset } from './worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from './worktree/lineage-types' -import type { WorktreeMeta } from './worktree/meta-types' -import type { RetiredNameRegistry } from './worktree/retired-name-registry' -import type { WorkspaceSessionState } from './workspace-session-state-types' -import type { OrcadMigrationClientStatePayload } from './orcad-migration-client-state' -import type { - OrcadMigrationSnapshotUploadState, - OrcadMigrationTerminalScrollbackSnapshot -} from './orcad-migration-scrollback' - -export { - MAX_ORCAD_MIGRATION_FOLDER_WORKSPACES, - MAX_ORCAD_MIGRATION_IMPORT_RECEIPTS, - MAX_ORCAD_MIGRATION_MANIFEST_BYTES, - MAX_ORCAD_MIGRATION_PROJECT_GROUPS, - MAX_ORCAD_MIGRATION_REPOSITORIES, - normalizeOrcadMigrationImportReceipts, - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest -} from './orcad-migration-manifest-validation' -export { - MAX_ORCAD_MIGRATION_DORMANT_NAMESPACES, - MAX_ORCAD_MIGRATION_DORMANT_ROWS, - ORCAD_MIGRATION_DORMANT_STATE_VERSION -} from './orcad-migration-dormant-state-validation' -export { - MAX_ORCAD_MIGRATION_STAGED_CATALOGS, - normalizeOrcadMigrationStagedCatalogs -} from './orcad-migration-staged-catalog-validation' - -export type OrcadMigrationManifestSource = { - sshTargetId: string - sshTargetGeneration: number | null - targetLabel: string -} - -export type OrcadMigrationCatalogPayload = { - repositories: Repo[] - projectGroups: ProjectGroup[] - folderWorkspaces: FolderWorkspace[] - dormantState?: OrcadMigrationDormantStatePayload -} - -export type OrcadMigrationDormantWorktreeMeta = { - sourceKey: string - worktreeId: string - meta: WorktreeMeta -} - -export type OrcadMigrationDormantWorktreeLineage = { - sourceKey: string - worktreeId: string - lineage: WorktreeLineage -} - -export type OrcadMigrationDormantWorkspaceLineage = { - sourceKey: string - childWorkspaceKey: string - lineage: WorkspaceLineage -} - -export type OrcadMigrationDormantRetiredNames = { - repoId: string - registry: RetiredNameRegistry -} - -export type OrcadMigrationDormantRetirementNamespace = { - sourceNamespaceKeys: string[] - namespaceKey: string - registry: RetiredNameRegistry -} - -export type OrcadMigrationDormantStatePayload = { - version: 1 - worktreeMeta: OrcadMigrationDormantWorktreeMeta[] - worktreeLineage: OrcadMigrationDormantWorktreeLineage[] - workspaceLineage: OrcadMigrationDormantWorkspaceLineage[] - sparsePresets: SparsePreset[] - retiredWorktreeNames: OrcadMigrationDormantRetiredNames[] - retiredWorktreeNamespaces: OrcadMigrationDormantRetirementNamespace[] - workspaceSession?: WorkspaceSessionState - terminalScrollbackSnapshots?: OrcadMigrationTerminalScrollbackSnapshot[] - automations?: Automation[] - automationRuns?: AutomationRun[] - /** Client-owned durable intent projected to the destination authority. */ - clientState?: OrcadMigrationClientStatePayload -} - -export type OrcadMigrationManifest = { - version: OrcadMigrationManifestVersion - migrationId: string - createdAt: string - source: OrcadMigrationManifestSource - payload: OrcadMigrationCatalogPayload - /** Destination runtime identity used to re-key desktop routing after commit. */ - destinationEnvironmentId?: string - manifestSha256: string -} - -export type OrcadMigrationImportReceipt = { - version: OrcadMigrationManifestVersion - migrationId: string - manifestSha256: string - source: OrcadMigrationManifestSource - importedAt: string - repositoryIds: string[] - projectGroupIds: string[] - folderWorkspaceIds: string[] -} - -export type OrcadMigrationImportResult = { - status: 'imported' | 'already-imported' - receipt: OrcadMigrationImportReceipt -} - -export type OrcadMigrationStagedCatalog = { - version: OrcadMigrationManifestVersion - manifest: OrcadMigrationManifest - stagedAt: string -} - -export type OrcadMigrationCatalogState = - | { - state: 'absent' - migrationId: string - manifestSha256: string - } - | { - state: 'staged' - migrationId: string - manifestSha256: string - stagedAt: string - snapshotUploads?: OrcadMigrationSnapshotUploadState[] - } - | { - state: 'committed' - migrationId: string - manifestSha256: string - receipt: OrcadMigrationImportReceipt - } - -export type OrcadMigrationCatalogAbortResult = OrcadMigrationCatalogState & { - aborted: boolean - durableAbsent?: true -} - -export function orcadMigrationManifestHashInput( - manifest: Omit -): string { - return serializeOrcadMigrationValue(manifest) -} - -export function serializeOrcadMigrationValue(value: unknown): string { - return JSON.stringify(canonicalizeJsonValue(value)) -} - -function canonicalizeJsonValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalizeJsonValue) - } - if (!isRecord(value)) { - return value - } - return Object.fromEntries( - Object.entries(value) - .filter(([, entry]) => entry !== undefined) - .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) - .map(([key, entry]) => [key, canonicalizeJsonValue(entry)]) - ) -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-migration-preflight.ts b/src/shared/orcad-migration-preflight.ts deleted file mode 100644 index 31b1687329c..00000000000 --- a/src/shared/orcad-migration-preflight.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { FolderWorkspace } from './folder-workspace-types' -import type { Repo } from './repo-types' -import type { SavedPortForward, SshRemotePtyLease, SshTarget } from './ssh-types' - -export type OrcadMigrationBlockerCategory = - | 'registration' - | 'exclusive-ownership' - | 'drainable-static-state' - | 'live-or-unverifiable' - | 'client-owned-state' - -export const ORCAD_MIGRATION_DEPENDENCY_KINDS = [ - 'saved-port-forward', - 'terminal-lease', - 'terminal-recovery', - 'workspace-session', - 'worktree-metadata', - 'worktree-lineage', - 'workspace-lineage', - 'sparse-preset', - 'retired-worktree-name', - 'automation', - 'automation-run', - 'mobile-tab-selection', - 'ui-routing' -] as const - -export type OrcadMigrationDependencyKind = (typeof ORCAD_MIGRATION_DEPENDENCY_KINDS)[number] - -export type OrcadMigrationDependency = { - kind: OrcadMigrationDependencyKind - count: number - /** A bounded sample of what holds the reference, so the user can find and clear it. */ - names?: string[] -} - -export type OrcadMigrationRepository = Pick -export type OrcadMigrationFolderWorkspace = Pick -export type OrcadMigrationTerminalLease = Pick< - SshRemotePtyLease, - 'ptyId' | 'worktreeId' | 'tabId' | 'leafId' | 'state' | 'updatedAt' -> - -export type OrcadMigrationBlocker = - | { - code: 'orcad_migration_target_not_found' - category: 'registration' - } - | { - code: 'orcad_migration_target_owned' - category: 'exclusive-ownership' - owner: NonNullable - } - | { - code: 'orcad_migration_direct_ssh_repositories' - category: 'drainable-static-state' - repositories: OrcadMigrationRepository[] - } - | { - code: 'orcad_migration_direct_ssh_folder_workspaces' - category: 'drainable-static-state' - folderWorkspaces: OrcadMigrationFolderWorkspace[] - } - | { - code: 'orcad_migration_direct_ssh_terminal_leases' - category: 'live-or-unverifiable' - terminalLeases: OrcadMigrationTerminalLease[] - } - | { - code: 'orcad_migration_saved_port_forwards' - category: 'client-owned-state' - portForwards: SavedPortForward[] - } - | { - code: 'orcad_migration_dependent_state' - category: 'client-owned-state' - dependencies: OrcadMigrationDependency[] - } - | { - code: 'orcad_migration_dependency_unverifiable' - category: 'live-or-unverifiable' - sources: OrcadMigrationDependencyKind[] - } - -export type OrcadMigrationPreflight = { - targetId: string - targetLabel: string | null - claimable: boolean - blockers: OrcadMigrationBlocker[] -} diff --git a/src/shared/orcad-migration-scrollback.test.ts b/src/shared/orcad-migration-scrollback.test.ts deleted file mode 100644 index 012b8538ff3..00000000000 --- a/src/shared/orcad-migration-scrollback.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultWorkspaceSession } from './constants' -import { - assertOrcadMigrationScrollbackReferences, - MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS, - OrcadMigrationSnapshotChunkRequestSchema, - parseOrcadMigrationSnapshotChunkResult, - parseOrcadMigrationTerminalScrollbackSnapshots, - type OrcadMigrationSnapshotChunkRequest, - type OrcadMigrationTerminalScrollbackSnapshot -} from './orcad-migration-scrollback' -import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from './terminal-scrollback-limits' - -const DIGEST = 'a'.repeat(64) - -describe('orcad migration scrollback wire validation', () => { - it('accepts only canonical non-empty bounded base64 chunks', () => { - const request = chunkRequest('YQ==') - expect(OrcadMigrationSnapshotChunkRequestSchema.safeParse(request).success).toBe(true) - for (const bytesBase64 of ['', 'YQ', 'YQ=', 'A===', '***=']) { - expect( - OrcadMigrationSnapshotChunkRequestSchema.safeParse({ ...request, bytesBase64 }).success - ).toBe(false) - } - }) - - it('requires an acknowledgment for exactly the decoded chunk length', () => { - const request = chunkRequest(Buffer.from('abc').toString('base64'), 7) - expect( - parseOrcadMigrationSnapshotChunkResult( - { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset: 10 - }, - request - ).acknowledgedOffset - ).toBe(10) - for (const acknowledgedOffset of [7, 9, 11]) { - expect(() => - parseOrcadMigrationSnapshotChunkResult( - { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset - }, - request - ) - ).toThrow('orcad_migration_snapshot_chunk_result_invalid') - } - }) - - it('requires unique refs and tab/leaf identities', () => { - const first = descriptor(1) - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots([first, { ...descriptor(2), ref: first.ref }]) - ).toThrow('orcad_migration_dormant_scrollback_snapshot_ref_duplicate') - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots([ - first, - { ...descriptor(2), tabId: first.tabId, leafId: first.leafId } - ]) - ).toThrow('orcad_migration_dormant_scrollback_snapshot_leaf_duplicate') - }) - - it('enforces descriptor count, per-file, and aggregate limits', () => { - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots( - Array.from({ length: MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS + 1 }, (_, index) => - descriptor(index + 1) - ) - ) - ).toThrow('orcad_migration_dormant_scrollback_snapshots_too_many') - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots([ - { ...descriptor(1), byteLength: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1 } - ]) - ).toThrow('orcad_migration_dormant_scrollback_size_invalid') - expect(() => - parseOrcadMigrationTerminalScrollbackSnapshots( - Array.from({ length: 52 }, (_, index) => ({ - ...descriptor(index + 1), - byteLength: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - })) - ) - ).toThrow('orcad_migration_dormant_scrollback_snapshots_too_large') - }) - - it('requires one exact descriptor for every published session ref', () => { - const session = getDefaultWorkspaceSession() - const first = descriptor(1) - session.terminalLayoutsByTabId = { - [first.tabId]: { - root: { type: 'leaf', leafId: first.leafId }, - activeLeafId: first.leafId, - expandedLeafId: null, - scrollbackRefsByLeafId: { [first.leafId]: first.ref } - } - } - expect(() => assertOrcadMigrationScrollbackReferences(session, [first])).not.toThrow() - expect(() => assertOrcadMigrationScrollbackReferences(session, [])).toThrow( - 'orcad_migration_dormant_scrollback_reference_invalid' - ) - expect(() => - assertOrcadMigrationScrollbackReferences(session, [{ ...first, ref: descriptor(2).ref }]) - ).toThrow('orcad_migration_dormant_scrollback_reference_invalid') - expect(() => assertOrcadMigrationScrollbackReferences(session, [first, descriptor(2)])).toThrow( - 'orcad_migration_dormant_scrollback_reference_invalid' - ) - }) -}) - -function chunkRequest(bytesBase64: string, offset = 0): OrcadMigrationSnapshotChunkRequest { - return { - migrationId: 'migration-1', - manifestSha256: DIGEST, - ref: `v1-${'1'.repeat(32)}`, - offset, - bytesBase64 - } -} - -function descriptor(index: number): OrcadMigrationTerminalScrollbackSnapshot { - const identity = index.toString(16).padStart(32, '0') - return { - tabId: `tab-${index}`, - leafId: `leaf-${index}`, - ref: `v1-${identity}`, - sha256: index.toString(16).padStart(64, '0'), - byteLength: 1 - } -} diff --git a/src/shared/orcad-migration-scrollback.ts b/src/shared/orcad-migration-scrollback.ts deleted file mode 100644 index 2b09e98b92d..00000000000 --- a/src/shared/orcad-migration-scrollback.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { z } from 'zod' -import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from './terminal-scrollback-limits' -import type { WorkspaceSessionState } from './workspace-session-state-types' -import { - assertUnique, - boundedArray, - requiredRecord, - requiredString -} from './orcad-migration-dormant-value-validation' - -export const MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS = 512 -export const MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES = 256 * 1024 * 1024 -export const ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES = 128 * 1024 - -const Identity = z.string().min(1).max(256) -const Digest = z.string().regex(/^[a-f0-9]{64}$/) -const SnapshotRef = z.string().regex(/^v1-[a-f0-9]{32}$/) -const CanonicalBase64Chunk = z - .string() - .max(Math.ceil(ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES / 3) * 4) - .refine((value) => { - try { - return value.length > 0 && Buffer.from(value, 'base64').toString('base64') === value - } catch { - return false - } - }) - -export type OrcadMigrationTerminalScrollbackSnapshot = { - tabId: string - leafId: string - ref: string - sha256: string - byteLength: number -} - -export const OrcadMigrationSnapshotChunkRequestSchema = z - .object({ - migrationId: Identity, - manifestSha256: Digest, - ref: SnapshotRef, - offset: z.number().int().nonnegative().max(MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES), - bytesBase64: CanonicalBase64Chunk - }) - .strict() - -export type OrcadMigrationSnapshotChunkRequest = z.infer< - typeof OrcadMigrationSnapshotChunkRequestSchema -> - -export type OrcadMigrationSnapshotChunkResult = { - migrationId: string - manifestSha256: string - ref: string - acknowledgedOffset: number -} - -export type OrcadMigrationSnapshotUploadState = OrcadMigrationTerminalScrollbackSnapshot & { - receivedBytes: number -} - -export function parseOrcadMigrationSnapshotUploadStates( - value: unknown, - snapshots: readonly OrcadMigrationTerminalScrollbackSnapshot[] -): OrcadMigrationSnapshotUploadState[] | undefined { - if (snapshots.length === 0) { - return undefined - } - if (!Array.isArray(value) || value.length !== snapshots.length) { - throw new Error('orcad_migration_snapshot_transfer_unsupported') - } - return snapshots.map((expected, index) => { - const record = requiredRecord(value[index], 'orcad_migration_snapshot_state_invalid') - if ( - record.tabId !== expected.tabId || - record.leafId !== expected.leafId || - record.ref !== expected.ref || - record.sha256 !== expected.sha256 || - record.byteLength !== expected.byteLength || - !Number.isSafeInteger(record.receivedBytes) || - Number(record.receivedBytes) < 0 || - Number(record.receivedBytes) > expected.byteLength - ) { - throw new Error('orcad_migration_snapshot_state_invalid') - } - return { ...expected, receivedBytes: Number(record.receivedBytes) } - }) -} - -export function parseOrcadMigrationSnapshotChunkResult( - value: unknown, - request: OrcadMigrationSnapshotChunkRequest -): OrcadMigrationSnapshotChunkResult { - const record = requiredRecord(value, 'orcad_migration_snapshot_chunk_result_invalid') - const acknowledgedOffset = - request.offset + decodeOrcadMigrationSnapshotChunk(request.bytesBase64).length - if ( - record.migrationId !== request.migrationId || - record.manifestSha256 !== request.manifestSha256 || - record.ref !== request.ref || - !Number.isSafeInteger(record.acknowledgedOffset) || - Number(record.acknowledgedOffset) !== acknowledgedOffset - ) { - throw new Error('orcad_migration_snapshot_chunk_result_invalid') - } - return { - migrationId: request.migrationId, - manifestSha256: request.manifestSha256, - ref: request.ref, - acknowledgedOffset - } -} - -export function parseOrcadMigrationTerminalScrollbackSnapshots( - value: unknown -): OrcadMigrationTerminalScrollbackSnapshot[] { - const snapshots = boundedArray( - value, - parseSnapshot, - 'scrollback_snapshots', - MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS - ) - assertUnique(snapshots, (entry) => entry.ref, 'scrollback_snapshot_ref') - assertUnique(snapshots, (entry) => `${entry.tabId}\0${entry.leafId}`, 'scrollback_snapshot_leaf') - if ( - snapshots.reduce((total, entry) => total + entry.byteLength, 0) > - MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES - ) { - throw new Error('orcad_migration_dormant_scrollback_snapshots_too_large') - } - return snapshots -} - -export function assertOrcadMigrationScrollbackReferences( - session: WorkspaceSessionState | undefined, - snapshots: readonly OrcadMigrationTerminalScrollbackSnapshot[] -): void { - const expected = new Map(snapshots.map((entry) => [`${entry.tabId}\0${entry.leafId}`, entry])) - let referenceCount = 0 - for (const [tabId, layout] of Object.entries(session?.terminalLayoutsByTabId ?? {})) { - for (const [leafId, ref] of Object.entries(layout.scrollbackRefsByLeafId ?? {})) { - const descriptor = expected.get(`${tabId}\0${leafId}`) - if (!descriptor || descriptor.ref !== ref) { - throw new Error('orcad_migration_dormant_scrollback_reference_invalid') - } - referenceCount += 1 - } - } - if (referenceCount !== snapshots.length) { - throw new Error('orcad_migration_dormant_scrollback_reference_invalid') - } -} - -export function decodeOrcadMigrationSnapshotChunk(bytesBase64: string): Buffer { - const bytes = Buffer.from(bytesBase64, 'base64') - if ( - bytes.length === 0 || - bytes.length > ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES || - bytes.toString('base64') !== bytesBase64 - ) { - throw new Error('orcad_migration_snapshot_chunk_invalid') - } - return bytes -} - -function parseSnapshot(value: unknown): OrcadMigrationTerminalScrollbackSnapshot { - const record = requiredRecord(value, 'orcad_migration_dormant_scrollback_snapshot_invalid') - const ref = requiredString(record.ref, 'orcad_migration_dormant_scrollback_ref_invalid') - const sha256 = requiredString(record.sha256, 'orcad_migration_dormant_scrollback_digest_invalid') - if (!SnapshotRef.safeParse(ref).success || !Digest.safeParse(sha256).success) { - throw new Error('orcad_migration_dormant_scrollback_identity_invalid') - } - if ( - !Number.isSafeInteger(record.byteLength) || - Number(record.byteLength) < 1 || - Number(record.byteLength) > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT - ) { - throw new Error('orcad_migration_dormant_scrollback_size_invalid') - } - return { - tabId: requiredString(record.tabId, 'orcad_migration_dormant_scrollback_tab_invalid'), - leafId: requiredString(record.leafId, 'orcad_migration_dormant_scrollback_leaf_invalid'), - ref, - sha256, - byteLength: Number(record.byteLength) - } -} diff --git a/src/shared/orcad-migration-staged-catalog-validation.test.ts b/src/shared/orcad-migration-staged-catalog-validation.test.ts deleted file mode 100644 index 9219d47a3c2..00000000000 --- a/src/shared/orcad-migration-staged-catalog-validation.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { normalizeOrcadMigrationStagedCatalogs } from './orcad-migration-staged-catalog-validation' -import { ORCAD_MIGRATION_MANIFEST_VERSION } from './orcad-migration-manifest-validation' - -function staged(migrationId: string) { - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - stagedAt: '2026-08-30T12:00:00.000Z', - manifest: { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - migrationId, - createdAt: '2026-08-30T12:00:00.000Z', - source: { sshTargetId: 'ssh-prod', sshTargetGeneration: 1, targetLabel: 'Production' }, - payload: { repositories: [], projectGroups: [], folderWorkspaces: [] }, - manifestSha256: 'a'.repeat(64) - } - } -} - -describe('orcad staged catalog normalization', () => { - it('recovers without Node 20-only array methods', () => { - const prototype: object = Array.prototype - const descriptor = Object.getOwnPropertyDescriptor(prototype, 'toReversed') - try { - Object.defineProperty(prototype, 'toReversed', { - configurable: true, - value: undefined, - writable: true - }) - expect(normalizeOrcadMigrationStagedCatalogs([staged('migration-1')])).toEqual([ - expect.objectContaining({ - manifest: expect.objectContaining({ migrationId: 'migration-1' }) - }) - ]) - } finally { - if (descriptor) { - Object.defineProperty(prototype, 'toReversed', descriptor) - } else { - Reflect.deleteProperty(prototype, 'toReversed') - } - } - }) -}) diff --git a/src/shared/orcad-migration-staged-catalog-validation.ts b/src/shared/orcad-migration-staged-catalog-validation.ts deleted file mode 100644 index f32dafec231..00000000000 --- a/src/shared/orcad-migration-staged-catalog-validation.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { OrcadMigrationStagedCatalog } from './orcad-migration-manifest' -import { - ORCAD_MIGRATION_MANIFEST_VERSION, - parseOrcadMigrationManifest -} from './orcad-migration-manifest-validation' - -export const MAX_ORCAD_MIGRATION_STAGED_CATALOGS = 4 - -export function normalizeOrcadMigrationStagedCatalogs( - value: unknown -): OrcadMigrationStagedCatalog[] { - if (!Array.isArray(value)) { - return [] - } - const staged: OrcadMigrationStagedCatalog[] = [] - const seen = new Set() - for (let index = value.length - 1; index >= 0; index--) { - try { - const entry = parseStagedCatalog(value[index]) - if (!seen.has(entry.manifest.migrationId)) { - seen.add(entry.manifest.migrationId) - staged.push(entry) - if (staged.length === MAX_ORCAD_MIGRATION_STAGED_CATALOGS) { - break - } - } - } catch { - // Invalid staging cannot be trusted as fencing evidence, so it is dropped fail-closed. - } - } - // Keep the migration journal runnable in the documented Node 18 rollback slot. - return staged.reduceRight((reversed, entry) => { - reversed.push(entry) - return reversed - }, []) -} - -function parseStagedCatalog(value: unknown): OrcadMigrationStagedCatalog { - if (!isRecord(value) || value.version !== ORCAD_MIGRATION_MANIFEST_VERSION) { - throw new Error('orcad_migration_staged_catalog_invalid') - } - const stagedAt = typeof value.stagedAt === 'string' ? value.stagedAt : '' - if (!Number.isFinite(Date.parse(stagedAt))) { - throw new Error('orcad_migration_staged_at_invalid') - } - return { - version: ORCAD_MIGRATION_MANIFEST_VERSION, - manifest: parseOrcadMigrationManifest(value.manifest), - stagedAt - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/src/shared/orcad-ssh-provisioning.ts b/src/shared/orcad-ssh-provisioning.ts deleted file mode 100644 index 11798f75e4c..00000000000 --- a/src/shared/orcad-ssh-provisioning.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { OrcadManagedDeployResult } from './orcad-managed-runtime' -import type { SshRepoReadoption, SshTargetCreateInput } from './ssh-types' - -export type OrcadSshProvisioningIntent = Readonly<{ - requestId: string - name: string -}> - -export type OrcadSshProvisioningRequest = OrcadSshProvisioningIntent & { - target: SshTargetCreateInput -} - -export type OrcadSshPendingProvisioning = OrcadSshProvisioningIntent & { - sshTargetId: string -} - -export type OrcadSshProvisioningResult = OrcadSshPendingProvisioning & { - repoReadoptions: SshRepoReadoption[] - result: OrcadManagedDeployResult | { outcome: 'pending'; reason: string } -} diff --git a/src/shared/orcad-stop-request.ts b/src/shared/orcad-stop-request.ts deleted file mode 100644 index 9b3d89aba2b..00000000000 --- a/src/shared/orcad-stop-request.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Stop requests for a running orcad, as files beside the process they address. - * - * Why files and not signals: a PID can be reused once its process exits, so a signal sent - * from a stale record can stop an unrelated process. A file in the slot or data root reaches - * only the orcad that watches it. - */ -import { z } from 'zod' - -/** Plain request in the slot directory: the same graceful stop as SIGTERM. */ -export const ORCAD_STOP_REQUEST_FILENAME = '.orcad-stop-request' -/** Prefix of an instance-bound request in the data root; older listeners never match it. */ -export const ORCAD_MANAGED_STOP_REQUEST_PREFIX = '.orcad-managed-stop-request' -export const ORCAD_MANAGED_STOP_REQUEST_MAX_BYTES = 32 * 1024 -export const ORCAD_STOP_RECEIPTS_DIRNAME = 'orcad-stop-receipts' -export const ORCAD_COMPLETE_MANAGED_STOP_FLAG = '--complete-managed-stop' - -/** One orcad process: its PID, start time, and the instance lock record it published. */ -export const OrcadManagedStopInstanceSchema = z.strictObject({ - pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - startedAtMs: z.number().finite().nonnegative().nullable(), - nonce: z.string().min(1).max(255), - lockPath: z.string().min(1).max(4096) -}) - -export const OrcadManagedStopRequestSchema = z.strictObject({ - schemaVersion: z.literal(1), - transactionId: z.uuid(), - version: z.string().min(1).max(255), - runtimeId: z.string().min(1).max(255), - instance: OrcadManagedStopInstanceSchema, - /** Best effort: also retire the terminal daemon if it is provably idle. Never blocks the stop. */ - retireIdleDaemon: z.literal(true).optional() -}) - -export type OrcadManagedStopInstance = z.infer -export type OrcadManagedStopRequest = z.infer -/** What a stopping orcad must match before a managed request may stop it. */ -export type OrcadManagedStopContext = Omit< - OrcadManagedStopRequest, - 'schemaVersion' | 'transactionId' -> - -/** The execution host's verdict; `exited` only on proof, never on silence. */ -export const OrcadManagedStopVerdictSchema = z.enum(['live', 'unverifiable', 'exited']) - -// Outputs are read by clients that may be older than the host: unknown fields are tolerated. -export const OrcadManagedStopCompletionSchema = z.object({ - ...OrcadManagedStopRequestSchema.shape, - kind: z.literal('orcad_managed_stop_completion'), - verdict: OrcadManagedStopVerdictSchema, - receiptPersisted: z.boolean(), - /** For a request that asked to retire the daemon, the outcome its receipt recorded. */ - retirement: z.enum(['retired', 'live', 'unverifiable']).optional() -}) - -/** `retired` only when the daemon accepted; a busy daemon stays up and keeps its terminals. */ -export const OrcadDaemonRetirementVerdictSchema = z.enum(['retired', 'live', 'unverifiable']) - -/** What the stopping orcad observed about its daemon, written before it exits. */ -export const OrcadDaemonRetirementRecordSchema = z.strictObject({ - schemaVersion: z.literal(1), - kind: z.literal('orcad_managed_stop_retirement'), - request: OrcadManagedStopRequestSchema, - retirement: OrcadDaemonRetirementVerdictSchema, - liveSessions: z.number().int().nonnegative().nullable(), - reason: z.string().max(4096).nullable() -}) - -export const OrcadCompletedStopReceiptSchema = z.strictObject({ - schemaVersion: z.literal(1), - kind: z.literal('orcad_managed_stop_completed'), - request: OrcadManagedStopRequestSchema, - exitedAt: z.iso.datetime({ offset: true }), - /** Present only when the request asked to retire the daemon. */ - retirement: OrcadDaemonRetirementVerdictSchema.optional() -}) - -export type OrcadManagedStopVerdict = z.infer -export type OrcadManagedStopCompletion = z.infer -export type OrcadCompletedStopReceipt = z.infer -export type OrcadDaemonRetirementVerdict = z.infer -export type OrcadDaemonRetirementRecord = z.infer - -export const ORCAD_CANCEL_MANAGED_STOP_FLAG = '--cancel-managed-stop' -/** Readiness `health.stopRequests`: this build consumes stop files and both commands above. */ -export const ORCAD_STOP_REQUESTS_CAPABILITY = 1 - -/** - * Which side won a managed request: the running orcad acting on it, or a client cancelling it. - * Created exclusively once per transaction, so the two can never both believe they won. - */ -export const OrcadManagedStopDecisionSchema = z.strictObject({ - schemaVersion: z.literal(1), - kind: z.literal('orcad_managed_stop_decision'), - request: OrcadManagedStopRequestSchema, - decision: z.enum(['dispatched', 'canceled']) -}) - -/** `dispatched` means orcad already acted on the request; the caller must await its exit. */ -export const OrcadManagedStopCancellationSchema = z.object({ - ...OrcadManagedStopRequestSchema.shape, - kind: z.literal('orcad_managed_stop_cancellation'), - outcome: z.enum(['canceled', 'dispatched']) -}) - -export type OrcadManagedStopDecision = z.infer -export type OrcadManagedStopCancellation = z.infer diff --git a/src/shared/orcad-terminal-census.ts b/src/shared/orcad-terminal-census.ts deleted file mode 100644 index 11cdfb3449d..00000000000 --- a/src/shared/orcad-terminal-census.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * How many terminals a host's daemon runs, as a client hands it to deploy, rollback and - * decommission planning. `null` means the daemon could not answer, which is never zero: - * loss of contact is not evidence of process death (docs/reference/ssh-execution-boundary.md). - */ -import { z } from 'zod' - -const CountSchema = z.number().int().nonnegative().nullable() - -export const OrcadTerminalCensusSchema = z.object({ - /** Sessions the live daemon owns right now. */ - liveSessions: CountSchema, - /** Of those, how many started at or after the active version's activation. */ - startedSinceActivation: CountSchema, - /** Protocol of the daemon that owns those sessions. */ - daemonProtocolVersion: z.number().int().positive().nullable() -}) - -export type OrcadTerminalCensus = z.infer - -/** RPC a managed orcad answers with its census; clients call it only when advertised. */ -export const ORCAD_TERMINAL_CENSUS_METHOD = 'orcad.terminalCensus' - -export const OrcadTerminalCensusParamsSchema = z.object({ - /** The active version's activation time, epoch ms; sessions created at or after it count. */ - activatedAt: z.number().finite().nonnegative() -}) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 99727a00bbd..9a58e704b31 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -318,12 +318,7 @@ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY = 'agentSession.create.tab-id.v1' as const -// Why: older hosts answer orcad.terminalCensus with method-not-found, so a client asks only when -// this is advertised and otherwise treats the census as unverifiable, never as zero. -export const ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY = 'orcad.terminal-census.v1' as const - export const RUNTIME_CAPABILITIES = [ - ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, diff --git a/src/shared/pty-consumer-session-contract.ts b/src/shared/pty-consumer-session-contract.ts index 701943afcaf..49527f83959 100644 --- a/src/shared/pty-consumer-session-contract.ts +++ b/src/shared/pty-consumer-session-contract.ts @@ -1,5 +1,4 @@ export const PTY_CONSUMER_SESSION_PROTOCOL_VERSION = 1 -export const PTY_CONSUMER_RESUME_CLIENT_METHOD = 'pty.resumeClient' export const PTY_CONSUMER_OWNER_GRACE_MS = 30_000 export const PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR = -32041 // Why: recovery is blocked only while the incumbent owner's grant publication is still settling — a diff --git a/src/shared/pty-consumer-session-resume-only.test.ts b/src/shared/pty-consumer-session-resume-only.test.ts deleted file mode 100644 index 91f43885efb..00000000000 --- a/src/shared/pty-consumer-session-resume-only.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, - PtyConsumerSession, - type PtyConsumerAuthentication, - type PtyConsumerSessionHello -} from './pty-consumer-session' - -function authentication(overrides: Partial = {}) { - return { - connectionId: 'successor', - principal: 'desktop', - authenticated: true, - allowSessionOwner: true, - ...overrides - } -} - -function hello(overrides: Partial = {}): PtyConsumerSessionHello { - return { - clientInstanceId: 'client-a', - requestedRole: 'session-owner', - resume: { ownerGeneration: 1, ownerLease: 'lease-1' }, - ...overrides - } -} - -function fixture() { - let now = 0 - let lease = 0 - const createLease = vi.fn(() => `lease-${++lease}`) - const session = new PtyConsumerSession({ - serverBuildId: 'relay-build', - createLease, - ownerGraceMs: 30_000, - now: () => now - }) - return { session, createLease, advance: () => (now += 30_001) } -} - -function activate(session: PtyConsumerSession) { - const admission = session.admit( - hello({ resume: undefined }), - authentication({ connectionId: 'source' }) - ) - admission.commitPublication() - return admission -} - -describe('PtyConsumerSession resume-only admission', () => { - it('does not shorten disconnected-owner grace after a refused strict claim', () => { - let now = 0 - const session = new PtyConsumerSession({ - serverBuildId: 'relay-build', - ownerGraceMs: 30_000, - createLease: () => 'lease-1', - now: () => now - }) - activate(session) - session.close('source', 'peer-closed') - expect(() => - session.admitResumed( - hello({ resume: { ownerGeneration: 1, ownerLease: 'wrong' } }), - authentication() - ) - ).toThrow() - now = 1_000 - expect(session.admitResumed(hello(), authentication()).grant.resumed).toBe(true) - }) - it('refuses a missing owner without minting a lease or consuming generations or the connection', () => { - const { session, createLease } = fixture() - expect(() => session.admitResumed(hello(), authentication())).toThrow( - 'pty_consumer_resume_owner_missing' - ) - expect(createLease).not.toHaveBeenCalled() - expect(session.activeGrant('successor')).toBeNull() - const ordinary = session.admit(hello(), authentication()) - expect(ordinary.grant).toMatchObject({ - resumed: false, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'lease-1' - }) - expect(createLease).toHaveBeenCalledTimes(1) - }) - - it('publishes a valid resumed owner atomically without minting a new lease', () => { - const { session, createLease } = fixture() - const source = activate(session) - const successor = session.admitResumed(hello(), authentication()) - expect(successor.grant).toMatchObject({ - resumed: true, - ownerGeneration: 2, - clientGeneration: 2, - ownerLease: 'lease-1' - }) - expect(successor.displacedOwner).toEqual({ connectionId: 'source', grant: source.grant }) - expect(session.activeGrant('source')).toBe(source.grant) - expect(session.activeGrant('successor')).toBeNull() - successor.commitPublication() - expect(session.activeGrant('source')).toBeNull() - expect(session.activeGrant('successor')).toBe(successor.grant) - expect(createLease).toHaveBeenCalledTimes(1) - }) - - it('restores the incumbent on rollback and permits retry on the same connection', () => { - const { session } = fixture() - const source = activate(session) - const successor = session.admitResumed(hello(), authentication()) - successor.rollbackPublication() - successor.commitPublication() - expect(session.activeGrant('source')).toBe(source.grant) - expect(session.activeGrant('successor')).toBeNull() - const retry = session.admitResumed(hello(), authentication()) - expect(retry.grant).toMatchObject({ resumed: true, ownerGeneration: 3, ownerLease: 'lease-1' }) - retry.commitPublication() - expect(session.activeGrant('successor')).toBe(retry.grant) - }) - - it.each([ - ['lease', hello({ resume: { ownerGeneration: 1, ownerLease: 'wrong' } }), authentication()], - ['client', hello({ clientInstanceId: 'other' }), authentication()], - ['principal', hello(), authentication({ principal: 'other' })], - [ - 'generation', - hello({ resume: { ownerGeneration: 99, ownerLease: 'lease-1' } }), - authentication() - ] - ])( - 'refuses a wrong %s without disturbing the owner or consuming the connection', - (_name, request, auth) => { - const { session, createLease } = fixture() - const source = activate(session) - expect(() => session.admitResumed(request, auth)).toThrow() - expect(session.activeGrant('source')).toBe(source.grant) - const valid = session.admitResumed(hello(), authentication()) - expect(valid.grant).toMatchObject({ resumed: true, clientGeneration: 2, ownerGeneration: 2 }) - expect(createLease).toHaveBeenCalledTimes(1) - } - ) - - it('refuses expired ownership while leaving ordinary fresh-claim fallback intact', () => { - const { session, createLease, advance } = fixture() - activate(session) - session.close('source') - advance() - expect(() => session.admitResumed(hello(), authentication())).toThrow( - 'pty_consumer_resume_owner_missing' - ) - expect(createLease).toHaveBeenCalledTimes(1) - expect(session.admit(hello(), authentication()).grant).toMatchObject({ - resumed: false, - clientGeneration: 2, - ownerGeneration: 2, - ownerLease: 'lease-2' - }) - }) - - it.each([ - [ - 'missing proof', - hello({ resume: undefined }), - authentication(), - 'pty_consumer_resume_required' - ], - [ - 'subscriber', - hello({ requestedRole: 'subscriber' }), - authentication(), - 'pty_consumer_resume_required' - ], - [ - 'owner-ineligible', - hello(), - authentication({ allowSessionOwner: false }), - 'pty_consumer_resume_required' - ], - [ - 'unauthenticated', - hello(), - authentication({ authenticated: false }), - 'authentication required' - ] - ])('refuses %s without reserving the connection', (_name, request, auth, error) => { - const { session } = fixture() - activate(session) - expect(() => session.admitResumed(request, auth)).toThrow(error) - expect(session.admitResumed(hello(), authentication()).grant).toMatchObject({ - resumed: true, - clientGeneration: 2, - ownerGeneration: 2 - }) - }) - - it('preserves pending-recovery refusal and permits retry after rollback', () => { - const { session } = fixture() - const source = activate(session) - const pending = session.admitResumed(hello(), authentication({ connectionId: 'pending' })) - expect(() => session.admitResumed(hello(), authentication())).toThrow( - expect.objectContaining({ code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR }) - ) - expect(session.activeGrant('source')).toBe(source.grant) - pending.rollbackPublication() - const retry = session.admitResumed(hello(), authentication()) - expect(retry.grant).toMatchObject({ resumed: true, clientGeneration: 3, ownerGeneration: 3 }) - retry.commitPublication() - expect(session.activeGrant('source')).toBeNull() - }) - - it('recovers a disconnected owner within grace', () => { - const { session } = fixture() - activate(session) - session.close('source') - const successor = session.admitResumed(hello(), authentication()) - expect(successor.displacedOwner).toBeUndefined() - expect(successor.grant).toMatchObject({ - resumed: true, - ownerGeneration: 2, - ownerLease: 'lease-1' - }) - successor.commitPublication() - expect(session.activeGrant('successor')).toBe(successor.grant) - }) - - it('recovers the last durable generation only after the unpersisted successor disconnects', () => { - const { session, createLease } = fixture() - activate(session) - const first = session.admitResumed(hello(), authentication()) - first.commitPublication() - const retryAuthentication = authentication({ connectionId: 'retry' }) - expect(() => session.admitResumed(hello(), retryAuthentication)).toThrow('superseded') - session.close('successor', 'peer-closed') - const retry = session.admitResumed(hello(), retryAuthentication) - expect(retry.grant).toMatchObject({ resumed: true, ownerGeneration: 3, ownerLease: 'lease-1' }) - retry.commitPublication() - expect(createLease).toHaveBeenCalledOnce() - expect(session.activeGrant('retry')).toBe(retry.grant) - }) -}) diff --git a/src/shared/pty-consumer-session.ts b/src/shared/pty-consumer-session.ts index 4972b03d1eb..7c5b29aacfd 100644 --- a/src/shared/pty-consumer-session.ts +++ b/src/shared/pty-consumer-session.ts @@ -64,22 +64,6 @@ export class PtyConsumerSession { admit( hello: PtyConsumerSessionHello, authentication: PtyConsumerAuthentication - ): PtyConsumerSessionAdmission { - return this.admitInternal(hello, authentication, false) - } - - /** Migration recovery must not turn an absent historical owner into a fresh claim. */ - admitResumed( - hello: PtyConsumerSessionHello, - authentication: PtyConsumerAuthentication - ): PtyConsumerSessionAdmission { - return this.admitInternal(hello, authentication, true) - } - - private admitInternal( - hello: PtyConsumerSessionHello, - authentication: PtyConsumerAuthentication, - requireResume: boolean ): PtyConsumerSessionAdmission { validateHello(hello) assertNonEmptyString(authentication.connectionId, 'connectionId') @@ -87,14 +71,6 @@ export class PtyConsumerSession { if (!authentication.authenticated) { throw new Error('PTY consumer authentication required') } - if ( - requireResume && - (!hello.resume || - hello.requestedRole !== 'session-owner' || - !authentication.allowSessionOwner) - ) { - throw new Error('pty_consumer_resume_required') - } this.expireOwner() // Why even an identical repeat is rejected: the two responses settle their publications @@ -104,13 +80,6 @@ export class PtyConsumerSession { throw new Error('pty.openClient may be used only once per transport connection') } - if (requireResume) { - if (!this.owner) { - throw new Error('pty_consumer_resume_owner_missing') - } - // Refused recovery must not shorten an incumbent's disconnected-owner grace. - assertPtyConsumerOwnerRecovery(hello, authentication, this.owner) - } const owner = this.selectOwner(hello, authentication) const grant = Object.freeze({ protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, diff --git a/src/shared/pty-ownership-transfer-release-gate.test.ts b/src/shared/pty-ownership-transfer-release-gate.test.ts deleted file mode 100644 index dbfc79ed6ae..00000000000 --- a/src/shared/pty-ownership-transfer-release-gate.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - isPtyOwnershipTransferMutationEnabled, - PTY_OWNERSHIP_TRANSFER_CANARY_ENV -} from './pty-ownership-transfer-release-gate' - -describe('PTY ownership-transfer release gate', () => { - it('is closed unless the exact canary value is present', () => { - expect(isPtyOwnershipTransferMutationEnabled({})).toBe(false) - expect( - isPtyOwnershipTransferMutationEnabled({ - [PTY_OWNERSHIP_TRANSFER_CANARY_ENV]: 'true' - }) - ).toBe(false) - expect( - isPtyOwnershipTransferMutationEnabled({ - [PTY_OWNERSHIP_TRANSFER_CANARY_ENV]: '1 ' - }) - ).toBe(false) - }) - - it('opens only for an explicit canary opt-in', () => { - expect( - isPtyOwnershipTransferMutationEnabled({ - [PTY_OWNERSHIP_TRANSFER_CANARY_ENV]: '1' - }) - ).toBe(true) - }) -}) diff --git a/src/shared/pty-ownership-transfer-release-gate.ts b/src/shared/pty-ownership-transfer-release-gate.ts deleted file mode 100644 index 2c411d89707..00000000000 --- a/src/shared/pty-ownership-transfer-release-gate.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * The live PTY ownership-transfer path is a release canary until routed, - * cross-platform recovery evidence is complete. Keep the opt-in exact and - * process-scoped so ordinary profiles cannot inherit it accidentally. - */ -export const PTY_OWNERSHIP_TRANSFER_CANARY_ENV = 'ORCA_ENABLE_PTY_OWNERSHIP_TRANSFER_MUTATION' - -export function isPtyOwnershipTransferMutationEnabled( - env: Record = process.env -): boolean { - return env[PTY_OWNERSHIP_TRANSFER_CANARY_ENV] === '1' -} diff --git a/src/shared/relay-owner-reset-contract.test.ts b/src/shared/relay-owner-reset-contract.test.ts deleted file mode 100644 index a52304195b6..00000000000 --- a/src/shared/relay-owner-reset-contract.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - RELAY_OWNER_RESET_CAPABILITY, - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from './relay-owner-reset-contract' -import { RELAY_RESET_PREPARATION_READ_FLAG } from './relay-reset-preparation-contract' -import { allowsRelayWorkDuringDrain } from './relay-work-drain-contract' - -// Relays and clients update independently, so these names are permanent once released. -describe('relay owner-reset wire contract', () => { - it('pins the advertised capabilities, methods and reader flag', () => { - expect(RELAY_OWNER_RESET_CAPABILITY).toBe('relay.ownerReset.v1') - expect(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY).toBe('relay.durableResetPreparation.v1') - expect(RELAY_OWNER_RESET_METHOD).toBe('relay.reset') - expect(RELAY_PREPARED_RESET_RECOVERY_METHOD).toBe('relay.recoverPreparedReset') - expect(RELAY_RESET_PREPARATION_READ_FLAG).toBe('--read-reset-preparation') - }) - - it('admits both reset requests during a work drain, so a prepared reset can settle', () => { - expect(allowsRelayWorkDuringDrain(RELAY_OWNER_RESET_METHOD)).toBe(true) - expect(allowsRelayWorkDuringDrain(RELAY_PREPARED_RESET_RECOVERY_METHOD)).toBe(true) - }) -}) diff --git a/src/shared/relay-owner-reset-contract.ts b/src/shared/relay-owner-reset-contract.ts deleted file mode 100644 index 28ceb9a546a..00000000000 --- a/src/shared/relay-owner-reset-contract.ts +++ /dev/null @@ -1,94 +0,0 @@ -export const RELAY_OWNER_RESET_CAPABILITY = 'relay.ownerReset.v1' -export const RELAY_DURABLE_RESET_PREPARATION_CAPABILITY = 'relay.durableResetPreparation.v1' -export const RELAY_OWNER_RESET_METHOD = 'relay.reset' -export const RELAY_PREPARED_RESET_RECOVERY_METHOD = 'relay.recoverPreparedReset' - -export type RelayOwnerResetRequest = Readonly<{ - version: 1 - operationId: string - runtimeIncarnation: string - ownerGeneration: number - ownerLease: string -}> - -export type RelayOwnerResetAcknowledgment = { - version: 1 - operationId: string - runtimeIncarnation: string - prepared: true -} - -/** A wire value as a plain record; anything else reads as absent. */ -export function readRelayResetRecord(value: unknown): Record | null { - return typeof value === 'object' && value !== null && !Array.isArray(value) ? { ...value } : null -} - -function isBoundedText(field: unknown, max: number): field is string { - return typeof field === 'string' && field.length > 0 && field.length <= max -} - -export function parseRelayOwnerResetRequest(value: unknown): RelayOwnerResetRequest { - const record = readRelayResetRecord(value) - const operationId = record?.operationId - const runtimeIncarnation = record?.runtimeIncarnation - const ownerGeneration = record?.ownerGeneration - const ownerLease = record?.ownerLease - if ( - record?.version !== 1 || - !isBoundedText(operationId, 128) || - !isBoundedText(runtimeIncarnation, 128) || - typeof ownerGeneration !== 'number' || - !Number.isSafeInteger(ownerGeneration) || - ownerGeneration < 1 || - !isBoundedText(ownerLease, 1024) - ) { - throw new Error('relay_reset_invalid_request') - } - return Object.freeze({ - version: 1, - operationId, - runtimeIncarnation, - ownerGeneration, - ownerLease - }) -} - -export function readRelayOwnerResetIncarnation(status: unknown): string { - const value = readRelayResetRecord(status) - const capabilities = value?.capabilities - const ownerReset = readRelayResetRecord(value?.ownerReset) - const incarnation = ownerReset?.runtimeIncarnation - if ( - !Array.isArray(capabilities) || - !capabilities.includes(RELAY_OWNER_RESET_CAPABILITY) || - ownerReset?.version !== 1 || - typeof incarnation !== 'string' || - incarnation.length === 0 || - incarnation.length > 128 - ) { - throw new Error('relay_reset_capability_unavailable') - } - return incarnation -} - -export function parseRelayOwnerResetAcknowledgment( - value: unknown, - expected: RelayOwnerResetRequest -): RelayOwnerResetAcknowledgment { - const result = readRelayResetRecord(value) - if ( - !result || - result.version !== 1 || - result.prepared !== true || - result.operationId !== expected.operationId || - result.runtimeIncarnation !== expected.runtimeIncarnation - ) { - throw new Error('relay_reset_acknowledgment_invalid') - } - return { - version: 1, - operationId: expected.operationId, - runtimeIncarnation: expected.runtimeIncarnation, - prepared: true - } -} diff --git a/src/shared/relay-reset-preparation-contract.test.ts b/src/shared/relay-reset-preparation-contract.test.ts deleted file mode 100644 index 968a116031b..00000000000 --- a/src/shared/relay-reset-preparation-contract.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { expect, it } from 'vitest' -import { RELAY_DURABLE_RESET_PREPARATION_CAPABILITY } from './relay-owner-reset-contract' -import { - parseRelayResetPreparationBinding, - readRelayResetPreparationBinding, - validateRelayResetPreparationRecord -} from './relay-reset-preparation-contract' - -const binding = parseRelayResetPreparationBinding({ - version: 1, - journalDirectory: '/journal', - principal: 'owner', - authenticationKind: 'endpoint-credential', - sockPath: '/socket', - serverBuildId: 'build' -}) -const request = { - version: 1 as const, - operationId: 'reset', - runtimeIncarnation: 'runtime', - ownerGeneration: 1, - ownerLease: 'lease' -} -const record = { - version: 1, - prepared: true, - request, - principal: binding.principal, - authenticationKind: binding.authenticationKind, - sockPath: binding.sockPath, - serverBuildId: binding.serverBuildId -} - -it('does not infer durable recovery support from unnegotiated metadata', () => { - expect(readRelayResetPreparationBinding({ ownerReset: { preparation: binding } })).toBeUndefined() - expect(readRelayResetPreparationBinding({ capabilities: [] })).toBeUndefined() -}) - -it('requires valid metadata once durable preparation is advertised', () => { - const capabilities = [RELAY_DURABLE_RESET_PREPARATION_CAPABILITY] - expect(() => readRelayResetPreparationBinding({ capabilities })).toThrow('invalid') - expect( - readRelayResetPreparationBinding({ capabilities, ownerReset: { preparation: binding } }) - ).toEqual(binding) -}) - -it('validates exact preparation without adding an exit or cleanup verdict', () => { - const result = validateRelayResetPreparationRecord(record, binding, request) - expect(result).toEqual(record) - expect(Object.isFrozen(result)).toBe(true) - expect(Object.isFrozen(result.request)).toBe(true) -}) - -it('preserves optional reader negotiation without assuming old hosts implement it', () => { - expect(parseRelayResetPreparationBinding(binding).readerVersion).toBeUndefined() - expect(parseRelayResetPreparationBinding({ ...binding, readerVersion: 1 }).readerVersion).toBe(1) - expect(() => parseRelayResetPreparationBinding({ ...binding, readerVersion: 2 })).toThrow( - 'reader_version_invalid' - ) -}) - -it.each(['principal', 'authenticationKind', 'sockPath', 'serverBuildId'] as const)( - 'rejects changed %s evidence', - (field) => { - const value = field === 'authenticationKind' ? 'launch-nonce' : 'other' - expect(() => - validateRelayResetPreparationRecord({ ...record, [field]: value }, binding, request) - ).toThrow('conflict') - } -) - -it.each(['operationId', 'runtimeIncarnation', 'ownerGeneration', 'ownerLease'] as const)( - 'rejects a changed request %s', - (field) => { - const value = field === 'ownerGeneration' ? 2 : 'other' - expect(() => - validateRelayResetPreparationRecord( - { ...record, request: { ...request, [field]: value } }, - binding, - request - ) - ).toThrow('conflict') - } -) - -it.each(['', 'x\0y', 'x'.repeat(8193)])('refuses invalid journal directory', (journalDirectory) => { - expect(() => parseRelayResetPreparationBinding({ ...binding, journalDirectory })).toThrow( - 'invalid' - ) -}) diff --git a/src/shared/relay-reset-preparation-contract.ts b/src/shared/relay-reset-preparation-contract.ts deleted file mode 100644 index 44bc7d5300a..00000000000 --- a/src/shared/relay-reset-preparation-contract.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - RELAY_DURABLE_RESET_PREPARATION_CAPABILITY, - parseRelayOwnerResetRequest, - readRelayResetRecord, - type RelayOwnerResetRequest -} from './relay-owner-reset-contract' - -export const RELAY_RESET_PREPARATION_READ_FLAG = '--read-reset-preparation' - -export type RelayResetPreparationBinding = Readonly<{ - version: 1 - journalDirectory: string - readerVersion?: 1 - principal: string - authenticationKind: 'launch-nonce' | 'endpoint-credential' - sockPath: string - serverBuildId: string -}> -export type RelayResetPreparationRecord = Readonly< - Omit & { - prepared: true - request: RelayOwnerResetRequest - } -> - -function isJournalText(value: unknown): value is string { - return ( - typeof value === 'string' && value.length > 0 && value.length <= 8192 && !value.includes('\0') - ) -} - -function isAuthenticationKind( - value: unknown -): value is RelayResetPreparationBinding['authenticationKind'] { - return value === 'launch-nonce' || value === 'endpoint-credential' -} - -function parseIdentity(record: Record | null) { - const { principal, sockPath, serverBuildId, authenticationKind } = record ?? {} - if ( - record?.version !== 1 || - !isJournalText(principal) || - !isJournalText(sockPath) || - !isJournalText(serverBuildId) || - !isAuthenticationKind(authenticationKind) - ) { - throw new Error('relay_reset_preparation_journal_invalid') - } - return { version: 1 as const, principal, authenticationKind, sockPath, serverBuildId } -} - -export function parseRelayResetPreparationBinding(value: unknown): RelayResetPreparationBinding { - const record = readRelayResetRecord(value) - const identity = parseIdentity(record) - const directory = record?.journalDirectory - const readerVersion = record?.readerVersion - if (!isJournalText(directory)) { - throw new Error('relay_reset_preparation_journal_invalid') - } - if (readerVersion !== undefined && readerVersion !== 1) { - throw new Error('relay_reset_preparation_reader_version_invalid') - } - return Object.freeze({ - ...identity, - journalDirectory: directory, - ...(readerVersion === undefined ? {} : { readerVersion }) - }) -} - -export function parseRelayResetPreparationRecord(value: unknown): RelayResetPreparationRecord { - const record = readRelayResetRecord(value) - const identity = parseIdentity(record) - if (record?.prepared !== true) { - throw new Error('relay_reset_preparation_journal_invalid') - } - return Object.freeze({ - version: 1, - prepared: true, - request: parseRelayOwnerResetRequest(record.request), - principal: identity.principal, - authenticationKind: identity.authenticationKind, - sockPath: identity.sockPath, - serverBuildId: identity.serverBuildId - }) -} - -export function readRelayResetPreparationBinding( - status: unknown -): RelayResetPreparationBinding | undefined { - const value = readRelayResetRecord(status) - const capabilities = value?.capabilities - if ( - !Array.isArray(capabilities) || - !capabilities.includes(RELAY_DURABLE_RESET_PREPARATION_CAPABILITY) - ) { - return undefined - } - return parseRelayResetPreparationBinding(readRelayResetRecord(value?.ownerReset)?.preparation) -} - -export function validateRelayResetPreparationRecord( - value: unknown, - expectedBinding: RelayResetPreparationBinding, - expectedRequest: RelayOwnerResetRequest -): RelayResetPreparationRecord { - const record = parseRelayResetPreparationRecord(value) - const binding = parseRelayResetPreparationBinding(expectedBinding) - if ( - record.principal !== binding.principal || - record.authenticationKind !== binding.authenticationKind || - record.sockPath !== binding.sockPath || - record.serverBuildId !== binding.serverBuildId || - JSON.stringify(record.request) !== JSON.stringify(parseRelayOwnerResetRequest(expectedRequest)) - ) { - throw new Error('relay_reset_preparation_journal_conflict') - } - return record -} diff --git a/src/shared/relay-work-drain-contract.ts b/src/shared/relay-work-drain-contract.ts deleted file mode 100644 index c2902597ec5..00000000000 --- a/src/shared/relay-work-drain-contract.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD } from './skill-ssh-relay-contract' -import { - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD -} from './relay-owner-reset-contract' - -// Why: a drain must still admit the requests and notifications that let in-flight work finish or cancel. -// Network-tunnel (T4) methods join these sets when their contract lands. -const drainRequests = new Set([ - 'relay.status', - RELAY_OWNER_RESET_METHOD, - RELAY_PREPARED_RESET_RECOVERY_METHOD, - 'fs.unwatchAndWait', - 'agent.cancelExec', - // Why (not in #16741): it only retires an existing delivery and replays its proof on retry; - // refusing it turns a provable client cancellation into an unverifiable one. - 'pty.cancelDelivery', - SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD -]) -const drainNotifications = new Set([ - 'rpc.cancel', - 'git.responseAck', - 'git.cancelResponseStream', - 'fs.streamAck', - 'fs.cancelStream', - 'fs.unwatch', - 'pty.ackData', - 'pty.setDeliveryPaused' -]) - -export function allowsRelayWorkDuringDrain(method: string, notification = false): boolean { - return (notification ? drainNotifications : drainRequests).has(method) -} diff --git a/src/shared/repo-maintenance-schedule.ts b/src/shared/repo-maintenance-schedule.ts new file mode 100644 index 00000000000..cbc5764b695 --- /dev/null +++ b/src/shared/repo-maintenance-schedule.ts @@ -0,0 +1,69 @@ +import { BoundedMap } from './bounded-map' +import { + PACK_INDEX_MAINTENANCE_COOLDOWN_MS, + PACK_INDEX_MAINTENANCE_FAILURE_COOLDOWN_MS, + type PackIndexMaintenanceOutcome +} from './repo-pack-index-maintenance-policy' +import { + REF_MAINTENANCE_CLEAN_COOLDOWN_MS, + type RefMaintenanceOutcome, + type RefMaintenanceSpan, + type RepoRefMaintenanceTarget +} from './repo-ref-maintenance-policy' + +export class RepoMaintenanceSchedule { + private readonly refCooldownUntil = new BoundedMap({ maxEntries: 256 }) + private readonly indexCooldownUntil = new BoundedMap({ maxEntries: 256 }) + + constructor(private readonly now: () => number) {} + + refDueAt(key: string): number { + return this.refCooldownUntil.peek(key) ?? 0 + } + + indexDueAt(key: string): number { + return this.indexCooldownUntil.get(key) ?? 0 + } + + postponeIndex(key: string, cooldownMs: number): void { + this.indexCooldownUntil.set(key, this.now() + cooldownMs) + } + + clear(): void { + this.refCooldownUntil.clear() + this.indexCooldownUntil.clear() + } + + settleRefs( + key: string, + span: RefMaintenanceSpan, + outcome: RefMaintenanceOutcome, + cooldownMs: number + ): void { + span.setAttribute('repo.maintenance_outcome', outcome) + this.refCooldownUntil.set(key, this.now() + cooldownMs) + } + + async maintain( + target: RepoRefMaintenanceTarget, + signal: AbortSignal, + span: RefMaintenanceSpan, + canWrite: () => boolean + ): Promise { + if (!target.maintainPackIndex || this.now() < this.indexDueAt(target.key)) { + return + } + const outcome = await target.maintainPackIndex(signal, span, canWrite) + if (signal.aborted || outcome === 'deferred') { + return outcome + } + const cooldown = + outcome === 'failed' + ? PACK_INDEX_MAINTENANCE_FAILURE_COOLDOWN_MS + : outcome === 'opted_out' || outcome === 'protected' + ? REF_MAINTENANCE_CLEAN_COOLDOWN_MS + : PACK_INDEX_MAINTENANCE_COOLDOWN_MS + this.postponeIndex(target.key, cooldown) + return outcome + } +} diff --git a/src/shared/repo-pack-index-maintenance-policy.ts b/src/shared/repo-pack-index-maintenance-policy.ts new file mode 100644 index 00000000000..25795e2d2a5 --- /dev/null +++ b/src/shared/repo-pack-index-maintenance-policy.ts @@ -0,0 +1,11 @@ +export const PACK_INDEX_MAINTENANCE_COOLDOWN_MS = 60 * 60_000 +export const PACK_INDEX_MAINTENANCE_FAILURE_COOLDOWN_MS = 30 * 60_000 + +export type PackIndexMaintenanceOutcome = + | 'written' + | 'unchanged' + | 'below_threshold' + | 'opted_out' + | 'protected' + | 'deferred' + | 'failed' diff --git a/src/shared/repo-pack-index-scheduling.test.ts b/src/shared/repo-pack-index-scheduling.test.ts new file mode 100644 index 00000000000..109e7d252a0 --- /dev/null +++ b/src/shared/repo-pack-index-scheduling.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PACK_INDEX_MAINTENANCE_COOLDOWN_MS, + PACK_INDEX_MAINTENANCE_FAILURE_COOLDOWN_MS +} from './repo-pack-index-maintenance-policy' +import { RepoRefMaintenance } from './repo-ref-maintenance' +import { + REF_MAINTENANCE_CLEAN_COOLDOWN_MS, + REF_MAINTENANCE_PACKED_COOLDOWN_MS, + type RepoRefMaintenanceTarget +} from './repo-ref-maintenance-policy' + +const { countLooseRefsMock } = vi.hoisted(() => ({ countLooseRefsMock: vi.fn() })) +vi.mock('./loose-ref-count', () => ({ countLooseRefs: countLooseRefsMock })) + +const QUIET_MS = 10 +const engines: RepoRefMaintenance[] = [] + +function fixture(withIndex = true) { + const resolveRefsDirectory = vi.fn(async () => '/repo/.git/refs') + const packRefs = vi.fn(async () => {}) + const maintainPackIndex = vi.fn(async () => 'written' as const) + const target: RepoRefMaintenanceTarget = { + key: 'local::/repo/.git', + resolveRefsDirectory, + packRefs, + ...(withIndex ? { maintainPackIndex } : {}) + } + const maintenance = new RepoRefMaintenance({ quietPeriodMs: QUIET_MS, looseRefThreshold: 10 }) + engines.push(maintenance) + return { maintenance, target, resolveRefsDirectory, packRefs, maintainPackIndex } +} + +async function advance(maintenance: RepoRefMaintenance, milliseconds: number): Promise { + await vi.advanceTimersByTimeAsync(milliseconds) + await maintenance.whenAttemptSettled() +} + +beforeEach(() => { + vi.useFakeTimers() + countLooseRefsMock.mockResolvedValue({ count: 0, saturated: false }) +}) + +afterEach(() => { + for (const maintenance of engines.splice(0)) { + maintenance.dispose() + } + vi.useRealTimers() + vi.resetAllMocks() +}) + +describe('independent idle pack-index scheduling', () => { + it('keeps a dirty ref arm until its cooldown ends without needing another fetch', async () => { + countLooseRefsMock.mockResolvedValue({ count: 20, saturated: true }) + const { maintenance, target, packRefs } = fixture(false) + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + expect(packRefs).toHaveBeenCalledTimes(1) + maintenance.arm(target) + await advance(maintenance, REF_MAINTENANCE_PACKED_COOLDOWN_MS - 1) + expect(packRefs).toHaveBeenCalledTimes(1) + await advance(maintenance, 1) + expect(packRefs).toHaveBeenCalledTimes(2) + }) + + it('refreshes changed packs while a low-ref repository is still on ref cooldown', async () => { + const { maintenance, target, resolveRefsDirectory, maintainPackIndex } = fixture() + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + maintenance.arm(target) + await advance(maintenance, PACK_INDEX_MAINTENANCE_COOLDOWN_MS - 1) + expect(maintainPackIndex).toHaveBeenCalledTimes(1) + await advance(maintenance, 1) + expect(maintainPackIndex).toHaveBeenCalledTimes(2) + expect(resolveRefsDirectory).toHaveBeenCalledTimes(1) + expect(PACK_INDEX_MAINTENANCE_COOLDOWN_MS).toBeLessThan(REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + }) + + it('keeps an index-only arm until its ref cooldown ends without needing another fetch', async () => { + const { maintenance, target, resolveRefsDirectory, maintainPackIndex } = fixture() + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + await advance(maintenance, PACK_INDEX_MAINTENANCE_COOLDOWN_MS) + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + expect(maintainPackIndex).toHaveBeenCalledTimes(2) + expect(resolveRefsDirectory).toHaveBeenCalledTimes(1) + await advance(maintenance, REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + expect(resolveRefsDirectory).toHaveBeenCalledTimes(2) + }) + + it('retries a failed index sooner without re-probing healthy refs', async () => { + const { maintenance, target, maintainPackIndex, resolveRefsDirectory } = fixture() + target.maintainPackIndex = vi.fn(async () => 'failed' as const) + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + maintenance.arm(target) + await advance(maintenance, PACK_INDEX_MAINTENANCE_FAILURE_COOLDOWN_MS) + expect(target.maintainPackIndex).toHaveBeenCalledTimes(2) + expect(resolveRefsDirectory).toHaveBeenCalledTimes(1) + expect(maintainPackIndex).not.toHaveBeenCalled() + }) + + it('honours idle admission again when a preserved cooldown arm becomes eligible', async () => { + const { maintenance, target, maintainPackIndex } = fixture() + let busy = false + target.isBusy = () => busy + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + maintenance.arm(target) + busy = true + await advance(maintenance, PACK_INDEX_MAINTENANCE_COOLDOWN_MS) + expect(maintainPackIndex).toHaveBeenCalledTimes(1) + busy = false + await advance(maintenance, QUIET_MS * 2) + expect(maintainPackIndex).toHaveBeenCalledTimes(2) + }) + + it('preserves protected user indexes for the longer clean cooldown', async () => { + const { maintenance, target } = fixture() + target.maintainPackIndex = vi.fn(async () => 'protected' as const) + maintenance.arm(target) + await advance(maintenance, QUIET_MS) + maintenance.arm(target) + await advance(maintenance, PACK_INDEX_MAINTENANCE_COOLDOWN_MS) + expect(target.maintainPackIndex).toHaveBeenCalledTimes(1) + await advance(maintenance, REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + expect(target.maintainPackIndex).toHaveBeenCalledTimes(2) + }) + + it('lets a create pause return while an admitted index writer is still running', async () => { + const { maintenance, target, packRefs } = fixture() + let started = () => {} + const writerStarted = new Promise((resolve) => { + started = resolve + }) + let finish = () => {} + const writer = new Promise<'written'>((resolve) => { + finish = () => resolve('written') + }) + target.maintainPackIndex = async () => { + started() + return writer + } + maintenance.arm(target) + await vi.advanceTimersByTimeAsync(QUIET_MS) + await writerStarted + const release = await maintenance.pause('worktree create') + expect(packRefs).not.toHaveBeenCalled() + finish() + await maintenance.whenAttemptSettled() + expect(packRefs).not.toHaveBeenCalled() + release() + }) +}) diff --git a/src/shared/repo-ref-maintenance-policy.ts b/src/shared/repo-ref-maintenance-policy.ts index 7dc8edd1de3..763bfb1d0dc 100644 --- a/src/shared/repo-ref-maintenance-policy.ts +++ b/src/shared/repo-ref-maintenance-policy.ts @@ -1,3 +1,5 @@ +import type { PackIndexMaintenanceOutcome } from './repo-pack-index-maintenance-policy' + /** * Idle-time loose-ref packing for repositories Orca itself degrades. * @@ -101,6 +103,7 @@ export type RefMaintenanceOutcome = | 'locked' | 'timed_out' | 'failed' + | 'index_only' /** Structurally satisfied by the tracer's `ActiveSpan`. */ export type RefMaintenanceSpan = { @@ -116,6 +119,12 @@ export type RepoRefMaintenanceTarget = { isOptedOut?(signal: AbortSignal): Promise /** True while work on *this repo* is in flight -- a fetch, a create, a removal. */ isBusy?(): boolean + /** Repair object lookup metadata before counting refs, under the same idle admission. */ + maintainPackIndex?( + signal: AbortSignal, + span: RefMaintenanceSpan, + canWrite: () => boolean + ): Promise /** * Runs `pack-refs` to completion. Deliberately takes no abort signal: killing * a pack is measurably worse than waiting for it (see `PACKED_REFS_LOCK_*`). diff --git a/src/shared/repo-ref-maintenance.test.ts b/src/shared/repo-ref-maintenance.test.ts index f59b894748f..c9940d13dd3 100644 --- a/src/shared/repo-ref-maintenance.test.ts +++ b/src/shared/repo-ref-maintenance.test.ts @@ -203,6 +203,52 @@ describe('RepoRefMaintenance gating', () => { }) }) + it('maintains the pack index even when the loose-ref backlog is small', async () => { + const refs = await refsDirectoryWith(1) + const { maintenance, packRefs } = createHarness() + const maintainPackIndex = vi.fn(async () => {}) + maintenance.arm(target('local::/fragmented/.git', refs, packRefs, { maintainPackIndex })) + await elapseQuietPeriod(maintenance) + expect(maintainPackIndex).toHaveBeenCalledTimes(1) + expect(packRefs).not.toHaveBeenCalled() + }) + + it('rechecks activity after config probes before starting object maintenance', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + let busy = false + const { maintenance, packRefs } = createHarness({ isBusy: () => busy }) + const maintainPackIndex = vi.fn(async () => {}) + maintenance.arm( + target('local::/busy-after-probe/.git', refs, packRefs, { + isOptedOut: async () => { + busy = true + return false + }, + maintainPackIndex + }) + ) + await elapseQuietPeriod(maintenance) + expect(maintainPackIndex).not.toHaveBeenCalled() + expect(packRefs).not.toHaveBeenCalled() + maintenance.dispose() + }) + + it('rechecks activity after object maintenance before packing refs', async () => { + const refs = await refsDirectoryWith(THRESHOLD + 2) + let busy = false + const { maintenance, packRefs } = createHarness({ isBusy: () => busy }) + maintenance.arm( + target('local::/busy-after-index/.git', refs, packRefs, { + maintainPackIndex: async () => { + busy = true + } + }) + ) + await elapseQuietPeriod(maintenance) + expect(packRefs).not.toHaveBeenCalled() + maintenance.dispose() + }) + it('does not run while the app is busy', async () => { const refs = await refsDirectoryWith(THRESHOLD + 2) let busy = true @@ -232,12 +278,17 @@ describe('RepoRefMaintenance gating', () => { const refs = await refsDirectoryWith(THRESHOLD + 2) const { maintenance, spans, packRefs } = createHarness() + const maintainPackIndex = vi.fn(async () => {}) maintenance.arm( - target('local::/opted-out/.git', refs, packRefs, { isOptedOut: async () => true }) + target('local::/opted-out/.git', refs, packRefs, { + isOptedOut: async () => true, + maintainPackIndex + }) ) await elapseQuietPeriod(maintenance) expect(packRefs).not.toHaveBeenCalled() + expect(maintainPackIndex).not.toHaveBeenCalled() expect(attributesOf(spans[0])['repo.maintenance_outcome']).toBe('opted_out') }) @@ -286,6 +337,37 @@ describe('RepoRefMaintenance gating', () => { }) describe('RepoRefMaintenance single-flight and backoff', () => { + it('serializes pack-index writes across execution hosts without merging their identities', async () => { + const refs = await refsDirectoryWith(1) + const { maintenance, packRefs } = createHarness() + const releases: (() => void)[] = [] + const started: string[] = [] + let concurrent = 0 + let peak = 0 + const indexTarget = (key: string) => + target(key, refs, packRefs, { + maintainPackIndex: async () => { + started.push(key) + concurrent += 1 + peak = Math.max(peak, concurrent) + await new Promise((resolve) => releases.push(resolve)) + concurrent -= 1 + } + }) + maintenance.arm(indexTarget('local::/repo/.git')) + maintenance.arm(indexTarget('wsl:Ubuntu::/repo/.git')) + await vi.advanceTimersByTimeAsync(QUIET_MS) + await until(() => concurrent === 1, 'the first index write') + releases.shift()?.() + await maintenance.whenAttemptSettled() + await untilWithTimers(() => started.length === 2, 'the second execution host') + releases.shift()?.() + await maintenance.whenAttemptSettled() + expect(peak).toBe(1) + expect(started).toEqual(['local::/repo/.git', 'wsl:Ubuntu::/repo/.git']) + expect(packRefs).not.toHaveBeenCalled() + }) + it('runs one repository at a time', async () => { const refs = await refsDirectoryWith(THRESHOLD + 2) let concurrent = 0 diff --git a/src/shared/repo-ref-maintenance.ts b/src/shared/repo-ref-maintenance.ts index 97e228fab52..bc4cf3ed9d8 100644 --- a/src/shared/repo-ref-maintenance.ts +++ b/src/shared/repo-ref-maintenance.ts @@ -1,5 +1,6 @@ import { countLooseRefs } from './loose-ref-count' import { PackedRefsLockGate } from './packed-refs-lock-gate' +import { RepoMaintenanceSchedule } from './repo-maintenance-schedule' import { LOOSE_REF_PACK_THRESHOLD, REF_MAINTENANCE_ATTEMPT_DEADLINE_MS, @@ -45,7 +46,7 @@ function hitDeadline(signal: AbortSignal): boolean { export class RepoRefMaintenance { private readonly tracked = new Map() - private readonly cooldownUntil = new Map() + private readonly phases: RepoMaintenanceSchedule private readonly now: () => number private readonly isAppBusy: () => boolean private readonly observe: NonNullable @@ -67,6 +68,7 @@ export class RepoRefMaintenance { constructor(options: RepoRefMaintenanceOptions = {}) { this.now = options.now ?? Date.now + this.phases = new RepoMaintenanceSchedule(this.now) this.isAppBusy = options.isBusy ?? (() => false) this.observe = options.observe ?? ((attempt) => attempt(noopSpan)) this.quietPeriodMs = options.quietPeriodMs ?? REF_MAINTENANCE_QUIET_PERIOD_MS @@ -167,7 +169,7 @@ export class RepoRefMaintenance { } } this.tracked.clear() - this.cooldownUntil.clear() + this.phases.clear() } private isBusy(tracked: TrackedRepo): boolean { @@ -227,8 +229,12 @@ export class RepoRefMaintenance { return } this.tracked.delete(key) - const cooldownUntil = this.cooldownUntil.get(key) - if (cooldownUntil !== undefined && this.now() < cooldownUntil) { + const refDueAt = this.phases.refDueAt(key) + const indexDueAt = tracked.target.maintainPackIndex ? this.phases.indexDueAt(key) : refDueAt + const nextDueAt = Math.min(refDueAt, indexDueAt) + if (this.now() < nextDueAt) { + this.tracked.set(key, tracked) + this.schedule(key, tracked, nextDueAt - this.now()) return } if (this.inFlight !== null) { @@ -266,19 +272,53 @@ export class RepoRefMaintenance { signal: AbortSignal ): Promise { span.setAttribute('repo.maintenance_key', key) - // Every await below carries the signal, so a caller waiting in `pause()` is - // never stuck behind a probe that has already been told to stop. + // Probes are cancellable; admitted index and ref writers finish before releasing the slot. if (await tracked.target.isOptedOut?.(signal)) { - this.settle(key, span, 'opted_out', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + this.phases.postponeIndex(key, REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'opted_out', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) return } if (signal.aborted) { this.yieldTo(key, tracked, span, signal) return } + if (this.suspensions > 0 || this.isBusy(tracked)) { + span.setAttribute('repo.maintenance_outcome', 'deferred' satisfies RefMaintenanceOutcome) + this.defer(key, tracked, true) + return + } + const indexOutcome = await this.phases.maintain( + tracked.target, + signal, + span, + () => !signal.aborted && this.suspensions === 0 && !this.isBusy(tracked) + ) + if (indexOutcome === 'deferred') { + span.setAttribute('repo.maintenance_outcome', 'deferred' satisfies RefMaintenanceOutcome) + this.defer(key, tracked, true) + return + } + if (signal.aborted) { + this.yieldTo(key, tracked, span, signal) + return + } + if (this.suspensions > 0 || this.isBusy(tracked)) { + span.setAttribute('repo.maintenance_outcome', 'deferred' satisfies RefMaintenanceOutcome) + this.defer(key, tracked, true) + return + } + if (this.now() < this.phases.refDueAt(key)) { + span.setAttribute('repo.maintenance_outcome', 'index_only' satisfies RefMaintenanceOutcome) + // Keep the arm for its ref phase; the write that armed it may have left loose refs. + if (!this.disposed && !this.tracked.has(key)) { + this.tracked.set(key, tracked) + this.schedule(key, tracked, this.phases.refDueAt(key) - this.now()) + } + return + } const refsDirectory = await tracked.target.resolveRefsDirectory(signal) if (!refsDirectory) { - this.settle(key, span, 'unresolved', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'unresolved', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) return } const budget = this.looseRefThreshold + 1 @@ -291,7 +331,7 @@ export class RepoRefMaintenance { span.setAttribute('git.loose_ref_threshold', this.looseRefThreshold) // A saturated walk stopped early, so `count` is a floor -- never read it as "clean". if (!before.saturated && before.count < this.looseRefThreshold) { - this.settle(key, span, 'below_threshold', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'below_threshold', REF_MAINTENANCE_CLEAN_COOLDOWN_MS) return } // The quiet window can close while the probe walks; re-check before spending a git slot. @@ -309,7 +349,7 @@ export class RepoRefMaintenance { } catch (error) { span.setAttribute('repo.maintenance_error', String(error)) if (error instanceof RefMaintenanceRepoLocked) { - this.settle(key, span, 'locked', REF_MAINTENANCE_LOCKED_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'locked', REF_MAINTENANCE_LOCKED_COOLDOWN_MS) return } partial = true @@ -324,11 +364,11 @@ export class RepoRefMaintenance { const after = await countLooseRefs(refsDirectory, budget, signal) span.setAttribute('git.loose_ref_count_after', after.count) if (partial && (after.saturated || after.count >= this.looseRefThreshold)) { - this.settle(key, span, 'failed', REF_MAINTENANCE_FAILURE_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'failed', REF_MAINTENANCE_FAILURE_COOLDOWN_MS) return } span.setAttribute('git.pack_refs_partial', partial) - this.settle(key, span, 'packed', REF_MAINTENANCE_PACKED_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'packed', REF_MAINTENANCE_PACKED_COOLDOWN_MS) } /** Record an aborted attempt: retry soon if Orca yielded, back off if it stalled. */ @@ -339,28 +379,10 @@ export class RepoRefMaintenance { signal: AbortSignal ): void { if (hitDeadline(signal)) { - this.settle(key, span, 'timed_out', REF_MAINTENANCE_FAILURE_COOLDOWN_MS) + this.phases.settleRefs(key, span, 'timed_out', REF_MAINTENANCE_FAILURE_COOLDOWN_MS) return } span.setAttribute('repo.maintenance_outcome', 'interrupted' satisfies RefMaintenanceOutcome) this.defer(key, tracked, false) } - - private settle( - key: string, - span: RefMaintenanceSpan, - outcome: RefMaintenanceOutcome, - cooldownMs: number - ): void { - span.setAttribute('repo.maintenance_outcome', outcome) - // Re-insert so Map order stays newest-last and the eviction below drops the oldest. - this.cooldownUntil.delete(key) - this.cooldownUntil.set(key, this.now() + cooldownMs) - if (this.cooldownUntil.size > MAX_TRACKED_REPOS * 4) { - const oldest = this.cooldownUntil.keys().next() - if (!oldest.done) { - this.cooldownUntil.delete(oldest.value) - } - } - } } diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index ca4f857e0fa..9b6680b08c2 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -23,7 +23,6 @@ import { PairingProvisionRelayParamsSchema } from '../mobile-relay-credential-contract' import { MobileWebBundleChunkParamsSchema } from '../mobile-web-bundle/bundle-rpc-contract' -import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census' import { pluginConsentRequestSchema } from '../plugins/plugin-consent-request' import { AccountsUnsubscribeParams, @@ -988,7 +987,6 @@ export const RPC_PARAMS_BY_METHOD = { 'notifications.testPush': null, 'notifications.unregisterPush': null, 'notifications.unsubscribe': NotificationUnsubscribeParams, - 'orcad.terminalCensus': OrcadTerminalCensusParamsSchema, 'orchestration.ask': AskParams, 'orchestration.callerShow': null, 'orchestration.check': CheckParams, diff --git a/src/shared/runtime-bootstrap.ts b/src/shared/runtime-bootstrap.ts index ca8cd456b72..993ab5e023d 100644 --- a/src/shared/runtime-bootstrap.ts +++ b/src/shared/runtime-bootstrap.ts @@ -42,7 +42,7 @@ export function findTransport( return null } -export const PRIMARY_RUNTIME_METADATA_FILE = 'orca-runtime.json' +const PRIMARY_RUNTIME_METADATA_FILE = 'orca-runtime.json' export function getRuntimeMetadataPath(userDataPath: string): string { return join(userDataPath, PRIMARY_RUNTIME_METADATA_FILE) diff --git a/src/shared/runtime-environment-authority-binding.ts b/src/shared/runtime-environment-authority-binding.ts deleted file mode 100644 index 79b6e114957..00000000000 --- a/src/shared/runtime-environment-authority-binding.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { KnownRuntimeEnvironment, PublicKnownRuntimeEnvironment } from './runtime-environments' - -/** Everything an SSH access operation was authorized against; any change invalidates the operation. */ -export function runtimeEnvironmentSshAccessBinding( - environment: KnownRuntimeEnvironment | PublicKnownRuntimeEnvironment, - ignoreIntent = false -): unknown { - return { - createdAt: environment.createdAt, - pairingRevision: environment.pairingRevision ?? environment.createdAt, - runtimeId: environment.runtimeId, - pairedDeviceId: environment.pairedDeviceId, - preferredEndpointId: environment.preferredEndpointId, - endpoints: environment.endpoints, - connectionDependency: environment.connectionDependency, - sshAccess: environment.sshAccess, - pendingSshAccessOperation: ignoreIntent ? undefined : environment.pendingSshAccessOperation - } -} diff --git a/src/shared/runtime-environment-managed-orcad-store.test.ts b/src/shared/runtime-environment-managed-orcad-store.test.ts deleted file mode 100644 index 2458ad176fa..00000000000 --- a/src/shared/runtime-environment-managed-orcad-store.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { encodePairingOffer } from './pairing' -import { - getEnvironmentStorePath, - listEnvironments, - removeEnvironment, - updateEnvironmentFromPairingCode -} from './runtime-environment-store' -import { - addManagedOrcadEnvironment, - refreshManagedOrcadPairing, - removeManagedOrcadEnvironment -} from './runtime-environment-managed-orcad-store' -import { getRuntimeEnvironmentSidecarPath } from './runtime-environment-sidecar' -import { getRuntimeSshAccess } from './runtime-environments' -import { shippedBuildRewrite } from './runtime-environment-shipped-store-fixture' - -const deployment = { - sshTargetId: 'ssh-1', - sshTargetGeneration: 4, - localPort: 46_768, - remotePort: 6_768 -} -const pairingCode = (endpoint = 'ws://127.0.0.1:46768') => - encodePairingOffer({ - v: 2, - endpoint, - deviceToken: 'private-device-token', - publicKeyB64: Buffer.alloc(32, 1).toString('base64') - }) - -describe('managed orcad environment store', () => { - let userDataPath: string - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-managed-orcad-store-')) - }) - afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) - - const add = (overrides: Partial[1]> = {}) => - addManagedOrcadEnvironment(userDataPath, { - id: 'environment-1', - name: 'Managed', - pairingCode: pairingCode(), - orcadDeployment: deployment, - now: 100, - ...overrides - }) - - it('keeps the deployment link out of the file shipped builds rewrite', () => { - const environment = add() - expect(environment.orcadDeployment).toEqual(deployment) - expect(getRuntimeSshAccess(environment)).toEqual(deployment) - const persisted = readFileSync(getEnvironmentStorePath(userDataPath), 'utf8') - expect(persisted).not.toContain('orcadDeployment') - expect(persisted).toContain('"connectionDependency":"ssh-tunnel"') - expect(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8')).not.toContain( - 'private-device-token' - ) - }) - - it('survives a downgraded build rewriting orca-environments.json', () => { - add() - shippedBuildRewrite(userDataPath, (environments) => { - environments[0]!.lastUsedAt = 500 - }) - const [restored] = listEnvironments(userDataPath) - expect(restored?.orcadDeployment).toEqual(deployment) - expect(restored?.lastUsedAt).toBe(500) - }) - - it('drops the link when a downgraded build re-pairs the server elsewhere', () => { - add() - shippedBuildRewrite(userDataPath, (environments) => { - environments[0]!.pairingRevision = 200 - }) - expect(listEnvironments(userDataPath)[0]?.orcadDeployment).toBeUndefined() - }) - - it('refuses a pairing that does not point at the deployment tunnel', () => { - expect(() => add({ pairingCode: pairingCode('ws://127.0.0.1:1234') })).toThrow( - 'does not point at its SSH tunnel' - ) - expect(() => add({ pairingCode: pairingCode('wss://server.example') })).toThrow() - expect(listEnvironments(userDataPath)).toEqual([]) - }) - - it('refuses duplicate ids and names', () => { - add() - expect(() => add({ name: 'Other' })).toThrow('already exists') - expect(() => add({ id: 'environment-2' })).toThrow('already exists') - }) - - it('keeps a managed server from being removed or re-paired outside its lifecycle', () => { - add() - expect(() => removeEnvironment(userDataPath, 'Managed')).toThrow('managed by Orca over SSH') - expect(() => - updateEnvironmentFromPairingCode(userDataPath, 'Managed', { pairingCode: pairingCode() }) - ).toThrow('managed by Orca over SSH') - expect(listEnvironments(userDataPath)).toHaveLength(1) - }) - - it('removes the server and its deployment record together once it is unlinked', () => { - add() - removeManagedOrcadEnvironment(userDataPath, 'environment-1') - expect(listEnvironments(userDataPath)).toEqual([]) - expect(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8')).not.toContain( - 'environment-1' - ) - }) - - it('leaves an unchanged pairing alone and re-binds the link when the offer rotates', () => { - const environment = add() - expect(refreshManagedOrcadPairing(userDataPath, 'environment-1', pairingCode())).toEqual( - environment - ) - const rotated = encodePairingOffer({ - v: 2, - endpoint: 'ws://127.0.0.1:46768', - deviceToken: 'rotated-token', - publicKeyB64: Buffer.alloc(32, 1).toString('base64') - }) - const refreshed = refreshManagedOrcadPairing(userDataPath, 'environment-1', rotated, 500) - expect(refreshed.endpoints[0]?.deviceToken).toBe('rotated-token') - expect(refreshed.pairingRevision).toBe(500) - expect(refreshed.orcadDeployment).toEqual(deployment) - expect(() => - refreshManagedOrcadPairing(userDataPath, 'environment-1', pairingCode('ws://127.0.0.1:1')) - ).toThrow('does not point at its SSH tunnel') - }) -}) diff --git a/src/shared/runtime-environment-managed-orcad-store.ts b/src/shared/runtime-environment-managed-orcad-store.ts deleted file mode 100644 index 7ca7e1dd9b5..00000000000 --- a/src/shared/runtime-environment-managed-orcad-store.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { parsePairingCode, type PairingOffer } from './pairing' -import { - createEnvironmentFromPairingOffer, - getPreferredLoopbackRuntimePort, - getPreferredPairingOffer, - PersistedRuntimeEnvironmentSchema, - type KnownRuntimeEnvironment, - type OrcadDeploymentLink -} from './runtime-environments' -import { - readEnvironmentStore, - readPersistedEnvironmentStore, - RuntimeEnvironmentStoreError, - writeEnvironmentStore -} from './runtime-environment-store-file' -import { - readCurrentRuntimeEnvironmentSidecarEntry, - writeRuntimeEnvironmentSidecarEntry -} from './runtime-environment-sidecar' -import { resolveEnvironmentFromStore } from './runtime-environment-store' - -/** Registers a server Orca deployed over SSH, paired through its own loopback tunnel. */ -export function addManagedOrcadEnvironment( - userDataPath: string, - args: { - id: string - name: string - pairingCode: string - orcadDeployment: OrcadDeploymentLink - now?: number - } -): KnownRuntimeEnvironment { - const offer = parsePairingCode(args.pairingCode) - if (!offer) { - throw new RuntimeEnvironmentStoreError('invalid_argument', 'Invalid managed pairing code.') - } - const known = readEnvironmentStore(userDataPath).environments - if (known.some((entry) => entry.id === args.id)) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - `A server with id "${args.id}" already exists.` - ) - } - if (known.some((entry) => entry.name === args.name)) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - `A server named "${args.name}" already exists.` - ) - } - const environment = createEnvironmentFromPairingOffer({ - id: args.id, - name: args.name, - now: args.now ?? Date.now(), - offer, - runtimeId: null, - connectionDependency: 'ssh-tunnel' - }) - if (getPreferredLoopbackRuntimePort(environment) !== args.orcadDeployment.localPort) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - 'The managed pairing does not point at its SSH tunnel.' - ) - } - const persisted = PersistedRuntimeEnvironmentSchema.parse(environment) - const environments = [ - ...readPersistedEnvironmentStore(userDataPath).environments, - persisted - ].sort((a, b) => a.name.localeCompare(b.name)) - // Sidecar first: a crash between the writes leaves an ownerless entry the next write prunes, - // never a registered server that has lost its deployment link. - writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, persisted, { - orcadDeployment: args.orcadDeployment - }) - writeEnvironmentStore(userDataPath, { version: 1, environments }) - const registered = readEnvironmentStore(userDataPath).environments.find( - (entry) => entry.id === args.id - ) - if (!registered?.orcadDeployment) { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - 'The managed server was saved without its deployment link.' - ) - } - return registered -} - -/** - * Unlinks a managed server once its orcad is proven to have exited: drops the persisted entry - * and the sidecar record that held its deployment link. - */ -export function removeManagedOrcadEnvironment(userDataPath: string, environmentId: string): void { - const store = readPersistedEnvironmentStore(userDataPath) - const persisted = resolveEnvironmentFromStore(store, environmentId) - const remaining = store.environments.filter((entry) => entry.id !== persisted.id) - writeEnvironmentStore(userDataPath, { version: 1, environments: remaining }) - writeRuntimeEnvironmentSidecarEntry(userDataPath, remaining, persisted, null) -} - -/** Keeps a managed server's pairing current after its orcad changed versions. */ -export function refreshManagedOrcadPairing( - userDataPath: string, - environmentId: string, - pairingCode: string, - now = Date.now() -): KnownRuntimeEnvironment { - const offer = parsePairingCode(pairingCode) - if (!offer) { - throw new RuntimeEnvironmentStoreError('invalid_argument', 'Invalid managed pairing code.') - } - const current = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environmentId) - const deployment = current.orcadDeployment - if (!deployment) { - throw new RuntimeEnvironmentStoreError('invalid_argument', 'This server is not managed.') - } - // Why skip: orcad keeps its pairing state across versions, so an unchanged offer needs no - // rewrite, and every rewrite re-binds the sidecar entry. - if (samePairing(getPreferredPairingOffer(current), offer)) { - return current - } - const store = readPersistedEnvironmentStore(userDataPath) - const existing = resolveEnvironmentFromStore(store, environmentId) - const entry = readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, existing) - const next = PersistedRuntimeEnvironmentSchema.parse({ - ...createEnvironmentFromPairingOffer({ - id: existing.id, - name: existing.name, - now: existing.createdAt, - offer, - runtimeId: existing.runtimeId, - connectionDependency: 'ssh-tunnel' - }), - updatedAt: now, - pairingRevision: Math.max(now, (existing.pairingRevision ?? existing.createdAt) + 1), - lastUsedAt: existing.lastUsedAt - }) - if (getPreferredLoopbackRuntimePort(next) !== deployment.localPort) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - 'The managed pairing does not point at its SSH tunnel.' - ) - } - const environments = store.environments.map((candidate) => - candidate.id === existing.id ? next : candidate - ) - const { binding: _binding, ...state } = entry ?? {} - writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, next, { - ...state, - orcadDeployment: deployment - }) - writeEnvironmentStore(userDataPath, { version: 1, environments }) - return resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environmentId) -} - -function samePairing(left: PairingOffer, right: PairingOffer): boolean { - return ( - left.endpoint === right.endpoint && - left.deviceToken === right.deviceToken && - left.publicKeyB64 === right.publicKeyB64 && - left.pairedDeviceId === right.pairedDeviceId - ) -} diff --git a/src/shared/runtime-environment-reconciliation-record.ts b/src/shared/runtime-environment-reconciliation-record.ts deleted file mode 100644 index 232ef9bfb95..00000000000 --- a/src/shared/runtime-environment-reconciliation-record.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod' - -export const RuntimeEnvironmentReconciliationRecordSchema = z.object({ - version: z.literal(1), - stage: z.enum(['prepared', 'catalog-active']), - requestId: z.string().min(1), - canonicalEnvironmentId: z.string().min(1), - runtimeId: z.string().min(1), - preparedAt: z.number().finite(), - registrations: z - .array( - z.object({ - environmentId: z.string().min(1), - authorityDigest: z.string().regex(/^[a-f0-9]{64}$/) - }) - ) - .length(2) -}) - -export type RuntimeEnvironmentReconciliationRecord = z.infer< - typeof RuntimeEnvironmentReconciliationRecordSchema -> - -export function assertRuntimeEnvironmentNotReconciling(environment: { - reconciliation?: unknown -}): void { - if (environment.reconciliation) { - throw new Error('Finish or cancel host reconciliation before changing this server lifecycle.') - } -} diff --git a/src/shared/runtime-environment-shipped-store-fixture.ts b/src/shared/runtime-environment-shipped-store-fixture.ts deleted file mode 100644 index fa0be626461..00000000000 --- a/src/shared/runtime-environment-shipped-store-fixture.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs' -import { z } from 'zod' -import { getEnvironmentStorePath } from './runtime-environment-store' - -// The environment schema v1.4.217 and v1.4.218 shipped: a plain z.object, so unknown keys are -// stripped, and every write (lastUsedAt included) rewrites the whole file. -const ShippedEnvironmentSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - createdAt: z.number().finite(), - updatedAt: z.number().finite(), - pairingRevision: z.number().finite().optional(), - pairedDeviceId: z.string().min(1).optional(), - lastUsedAt: z.number().finite().nullable(), - runtimeId: z.string().min(1).nullable(), - source: z.enum(['manual', 'ephemeral-vm']).optional(), - connectionDependency: z.literal('ssh-tunnel').optional(), - endpoints: z.array(z.object({}).passthrough()).min(1), - preferredEndpointId: z.string().min(1) -}) -const ShippedStoreSchema = z.object({ - version: z.literal(1), - environments: z.array(ShippedEnvironmentSchema) -}) - -/** Rewrites orca-environments.json the way a downgraded build does, stripping unknown keys. */ -export function shippedBuildRewrite( - userDataPath: string, - edit: (environments: z.infer[]) => void = () => {} -): void { - const path = getEnvironmentStorePath(userDataPath) - const store = ShippedStoreSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) - edit(store.environments) - writeFileSync(path, JSON.stringify(store)) -} diff --git a/src/shared/runtime-environment-sidecar.test.ts b/src/shared/runtime-environment-sidecar.test.ts deleted file mode 100644 index c65788695a8..00000000000 --- a/src/shared/runtime-environment-sidecar.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { encodePairingOffer } from './pairing' -import { - addEnvironmentFromPairingCode, - getEnvironmentStorePath, - listEnvironments, - removeEnvironment -} from './runtime-environment-store' -import { - getRuntimeEnvironmentSidecarPath, - writeRuntimeEnvironmentSidecarEntry -} from './runtime-environment-sidecar' -import { assertRuntimeEnvironmentNotReconciling } from './runtime-environment-reconciliation-record' -import { - linkVerifiedRuntimeEnvironmentSshAccess, - prepareRuntimeEnvironmentSshAccessLink -} from './runtime-environment-ssh-access-store' -import { readPersistedEnvironmentStore } from './runtime-environment-store-file' -import { shippedBuildRewrite } from './runtime-environment-shipped-store-fixture' - -const pairing = { - v: 2 as const, - endpoint: 'wss://server.example/runtime', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - deviceToken: 'private-device-token', - pairedDeviceId: 'paired-client' -} -const tunnel = { sshTargetId: 'target', sshTargetGeneration: 3, localPort: 41000, remotePort: 6768 } - -describe('runtime environment sidecar across a downgrade', () => { - let userDataPath: string - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-environment-sidecar-')) - }) - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - function linked() { - const seeded = addEnvironmentFromPairingCode(userDataPath, { - name: 'Host', - pairingCode: encodePairingOffer(pairing), - now: 100 - }) - const prepared = prepareRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: seeded, - requestId: 'link-request', - ...tunnel, - targetFingerprint: 'target-fingerprint' - }) - return linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepared, - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing: { ...pairing, endpoint: 'ws://127.0.0.1:41000/runtime' }, - tunnel, - now: 90 - }) - } - - it('keeps SSH access after a shipped build rewrites orca-environments.json', () => { - const access = linked() - shippedBuildRewrite(userDataPath, (environments) => { - environments[0]!.lastUsedAt = 500 - environments[0]!.runtimeId = 'host-runtime' - }) - const [restored] = listEnvironments(userDataPath) - expect(restored?.sshAccess).toEqual(access.sshAccess) - expect(restored?.preferredEndpointId).toBe(access.preferredEndpointId) - expect(restored?.lastUsedAt).toBe(500) - }) - - it('reads a link as stale once a shipped build re-pairs the server, and prunes it on the next write', () => { - const access = linked() - shippedBuildRewrite(userDataPath, (environments) => { - environments[0]!.pairingRevision = 200 - }) - const [restored] = listEnvironments(userDataPath) - expect(restored?.sshAccess).toBeUndefined() - expect(restored?.preferredEndpointId).toBe(access.sshAccess?.previousPreferredEndpointId) - expect(restored?.connectionDependency).toBeUndefined() - - const persisted = readPersistedEnvironmentStore(userDataPath).environments - writeRuntimeEnvironmentSidecarEntry(userDataPath, persisted, persisted[0]!, null) - expect( - JSON.parse(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8')) - ).toEqual({ version: 1, entries: {} }) - }) - - it('ignores a dangling entry for a server a shipped build removed', () => { - linked() - shippedBuildRewrite(userDataPath, (environments) => { - environments.splice(0, 1) - }) - expect(listEnvironments(userDataPath)).toEqual([]) - const other = addEnvironmentFromPairingCode(userDataPath, { - name: 'Other', - pairingCode: encodePairingOffer(pairing) - }) - expect(listEnvironments(userDataPath)).toEqual([other]) - }) - - it('refuses ordinary removal while linked, and drops the entry when an unlinked server is removed', () => { - const access = linked() - expect(() => removeEnvironment(userDataPath, access.id)).toThrow('Unlink') - const plain = addEnvironmentFromPairingCode(userDataPath, { - name: 'Plain', - pairingCode: encodePairingOffer(pairing) - }) - removeEnvironment(userDataPath, plain.id) - expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual([access.id]) - }) - - it('fails closed on an unreadable sidecar instead of dropping a pinned host key', () => { - linked() - writeFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), '{not json') - expect(() => listEnvironments(userDataPath)).toThrow('Orca environment links') - }) - - it('overlays a reconciliation record so lifecycle changes refuse while it exists', () => { - const seeded = addEnvironmentFromPairingCode(userDataPath, { - name: 'Host', - pairingCode: encodePairingOffer(pairing), - now: 100 - }) - const persisted = readPersistedEnvironmentStore(userDataPath).environments - writeRuntimeEnvironmentSidecarEntry(userDataPath, persisted, persisted[0]!, { - reconciliation: { - version: 1, - stage: 'prepared', - requestId: 'reconcile', - canonicalEnvironmentId: seeded.id, - runtimeId: 'host-runtime', - preparedAt: 1, - registrations: [ - { environmentId: seeded.id, authorityDigest: 'a'.repeat(64) }, - { environmentId: 'other', authorityDigest: 'b'.repeat(64) } - ] - } - }) - const [restored] = listEnvironments(userDataPath) - expect(() => assertRuntimeEnvironmentNotReconciling(restored!)).toThrow('reconciliation') - expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8'))).toEqual({ - version: 1, - environments: [seeded] - }) - }) -}) diff --git a/src/shared/runtime-environment-sidecar.ts b/src/shared/runtime-environment-sidecar.ts deleted file mode 100644 index d421d0e8794..00000000000 --- a/src/shared/runtime-environment-sidecar.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { z } from 'zod' -import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' -import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record' -import { - KnownRuntimeEnvironmentSchema, - OrcadDeploymentLinkSchema, - RuntimeAccessEndpointSchema, - RuntimeSshAccessLinkSchema, - RuntimeSshAccessOperationSchema, - type KnownRuntimeEnvironment, - type PersistedRuntimeEnvironment -} from './runtime-environments' -import { hardenExistingSecureFile } from './secure-file' - -// Why a sidecar: shipped builds parse orca-environments.json with key-stripping schemas and rewrite -// it on routine use, so T5 state stored there would vanish across a downgrade. They never touch this. -const SIDECAR_FILE = 'orca-environment-sidecar.json' -const MAX_SIDECAR_FILE_BYTES = 1024 * 1024 - -const SidecarBindingSchema = z.object({ - createdAt: z.number().finite(), - pairingRevision: z.number().finite(), - preferredEndpointId: z.string().min(1) -}) - -const SidecarSshAccessSchema = RuntimeSshAccessLinkSchema.extend({ - endpoint: RuntimeAccessEndpointSchema -}) - -const SidecarEntrySchema = z.object({ - binding: SidecarBindingSchema, - // The runtime a link verified; it outlives the link like a learned runtimeId would. - runtimeId: z.string().min(1).optional(), - sshAccess: SidecarSshAccessSchema.optional(), - pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), - // The managed deployment's tunnel; its loopback endpoint is the persisted pairing itself. - orcadDeployment: OrcadDeploymentLinkSchema.optional(), - // Keeps the overlaid pairing revision monotonic after the access that raised it is removed. - pairingRevisionFloor: z.number().finite().optional(), - reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() -}) - -const SidecarSchema = z.object({ - version: z.literal(1), - entries: z.record(z.string().min(1), SidecarEntrySchema) -}) - -export type RuntimeEnvironmentSidecarEntry = z.infer -export type RuntimeEnvironmentSidecarSshAccess = z.infer -type RuntimeEnvironmentSidecar = z.infer - -export class RuntimeEnvironmentSidecarInvalidError extends Error { - constructor(path: string) { - super(`Could not read Orca environment links at ${path}; the file is invalid.`) - this.name = 'RuntimeEnvironmentSidecarInvalidError' - } -} - -export function getRuntimeEnvironmentSidecarPath(userDataPath: string): string { - return join(userDataPath, SIDECAR_FILE) -} - -/** The persisted state a sidecar entry was written against; any change makes the entry stale. */ -export function runtimeEnvironmentSidecarBinding(environment: PersistedRuntimeEnvironment) { - return { - createdAt: environment.createdAt, - pairingRevision: environment.pairingRevision ?? environment.createdAt, - preferredEndpointId: environment.preferredEndpointId - } -} - -export function readRuntimeEnvironmentSidecar(userDataPath: string): RuntimeEnvironmentSidecar { - const path = getRuntimeEnvironmentSidecarPath(userDataPath) - if (!existsSync(path)) { - return { version: 1, entries: {} } - } - try { - hardenExistingSecureFile(path) - return SidecarSchema.parse( - JSON.parse(readNodeFileSyncWithinLimit(path, MAX_SIDECAR_FILE_BYTES).buffer.toString('utf8')) - ) - } catch { - throw new RuntimeEnvironmentSidecarInvalidError(path) - } -} - -function isCurrentEntry( - environment: PersistedRuntimeEnvironment, - entry: RuntimeEnvironmentSidecarEntry -): boolean { - const binding = runtimeEnvironmentSidecarBinding(environment) - return ( - entry.binding.createdAt === binding.createdAt && - entry.binding.pairingRevision === binding.pairingRevision && - entry.binding.preferredEndpointId === binding.preferredEndpointId - ) -} - -/** - * The environment callers see. An entry bound to a different pairing, or one whose overlay would be - * inconsistent, is stale and ignored rather than trusted. - */ -export function overlayRuntimeEnvironmentSidecar( - environment: PersistedRuntimeEnvironment, - entry: RuntimeEnvironmentSidecarEntry | undefined -): KnownRuntimeEnvironment { - const base = KnownRuntimeEnvironmentSchema.parse(environment) - if (!entry || !isCurrentEntry(environment, entry)) { - return base - } - const basePairingRevision = environment.pairingRevision ?? environment.createdAt - const access = entry.sshAccess - if ( - entry.runtimeId !== undefined && - environment.runtimeId !== null && - environment.runtimeId !== entry.runtimeId - ) { - return base - } - const pairingRevision = Math.max( - basePairingRevision, - entry.pairingRevisionFloor ?? basePairingRevision - ) - const { endpoint, ...link } = access ?? {} - const overlaid = KnownRuntimeEnvironmentSchema.safeParse({ - ...environment, - ...(pairingRevision !== basePairingRevision ? { pairingRevision } : {}), - ...(entry.runtimeId !== undefined - ? { runtimeId: environment.runtimeId ?? entry.runtimeId } - : {}), - ...(access && endpoint - ? { - connectionDependency: 'ssh-tunnel', - sshAccess: link, - endpoints: [...environment.endpoints.filter((e) => e.id !== endpoint.id), endpoint], - preferredEndpointId: endpoint.id - } - : {}), - ...(entry.pendingSshAccessOperation - ? { pendingSshAccessOperation: entry.pendingSshAccessOperation } - : {}), - ...(entry.orcadDeployment ? { orcadDeployment: entry.orcadDeployment } : {}), - ...(entry.reconciliation ? { reconciliation: entry.reconciliation } : {}) - }) - return overlaid.success ? overlaid.data : base -} - -/** Replaces one environment's sidecar entry and drops entries whose environment no longer exists. */ -export function writeRuntimeEnvironmentSidecarEntry( - userDataPath: string, - environments: readonly PersistedRuntimeEnvironment[], - environment: PersistedRuntimeEnvironment, - entry: Omit | null -): void { - const current = readRuntimeEnvironmentSidecar(userDataPath) - const live = new Map(environments.map((candidate) => [candidate.id, candidate])) - const entries: Record = {} - for (const [id, existing] of Object.entries(current.entries)) { - const owner = live.get(id) - if (id !== environment.id && owner && isCurrentEntry(owner, existing)) { - entries[id] = existing - } - } - const basePairingRevision = environment.pairingRevision ?? environment.createdAt - const hasState = - entry !== null && - ((entry.runtimeId !== undefined && environment.runtimeId === null) || - entry.sshAccess !== undefined || - entry.pendingSshAccessOperation !== undefined || - entry.orcadDeployment !== undefined || - entry.reconciliation !== undefined || - (entry.pairingRevisionFloor ?? basePairingRevision) > basePairingRevision) - if (entry && hasState) { - entries[environment.id] = SidecarEntrySchema.parse({ - ...entry, - binding: runtimeEnvironmentSidecarBinding(environment) - }) - } - writeSecureJsonFileWithinLimit( - getRuntimeEnvironmentSidecarPath(userDataPath), - SidecarSchema.parse({ version: 1, entries }), - MAX_SIDECAR_FILE_BYTES, - { durable: true } - ) -} - -/** The current sidecar entry for an environment, or none when absent or stale. */ -export function readCurrentRuntimeEnvironmentSidecarEntry( - userDataPath: string, - environment: PersistedRuntimeEnvironment -): RuntimeEnvironmentSidecarEntry | undefined { - const entry = readRuntimeEnvironmentSidecar(userDataPath).entries[environment.id] - return entry && isCurrentEntry(environment, entry) ? entry : undefined -} diff --git a/src/shared/runtime-environment-ssh-access-store.test.ts b/src/shared/runtime-environment-ssh-access-store.test.ts deleted file mode 100644 index b51adc71b8f..00000000000 --- a/src/shared/runtime-environment-ssh-access-store.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { encodePairingOffer } from './pairing' -import { - addEnvironmentFromPairingCode, - getEnvironmentStorePath, - listEnvironments, - markEnvironmentUsed, - removeEnvironment, - updateEnvironmentFromPairingCode -} from './runtime-environment-store' -import { getPreferredPairingOffer, PersistedRuntimeEnvironmentSchema } from './runtime-environments' -import { - linkVerifiedRuntimeEnvironmentSshAccess, - prepareRuntimeEnvironmentSshAccessLink, - prepareRuntimeEnvironmentSshAccessUnlink, - completeRuntimeEnvironmentSshAccessUnlink, - cancelRuntimeEnvironmentSshAccessLink -} from './runtime-environment-ssh-access-store' -import { z } from 'zod' - -const pairing = { - v: 2 as const, - endpoint: 'wss://server.example/runtime', - publicKeyB64: Buffer.alloc(32, 1).toString('base64'), - deviceToken: 'private-device-token', - pairedDeviceId: 'paired-client' -} -const tunnel = { sshTargetId: 'target', sshTargetGeneration: 3, localPort: 41000, remotePort: 6768 } -const verifiedPairing = { ...pairing, endpoint: 'ws://127.0.0.1:41000/runtime' } - -describe('independent paired runtime SSH access persistence', () => { - let userDataPath: string - beforeEach(() => { - userDataPath = mkdtempSync(join(tmpdir(), 'orca-ssh-access-store-')) - }) - afterEach(() => { - rmSync(userDataPath, { recursive: true, force: true }) - }) - - function seed() { - return addEnvironmentFromPairingCode(userDataPath, { - name: 'Independent host', - pairingCode: encodePairingOffer(pairing), - now: 100 - }) - } - - function prepare(expectedEnvironment = seed(), requestId = 'link-request') { - return prepareRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment, - requestId, - ...tunnel, - targetFingerprint: 'target-fingerprint' - }) - } - - function link(expectedEnvironment = seed()) { - return linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepare(expectedEnvironment), - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing, - tunnel, - now: 90 - }) - } - - function unlink(expectedEnvironment: ReturnType, now?: number) { - const prepared = prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment, - requestId: 'unlink-request', - now - }) - return completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'unlink-request' - }) - } - - it('adds access to the same host without deployment ownership and restores its original endpoint', () => { - const original = seed() - const linked = link(original) - expect(listEnvironments(userDataPath)).toEqual([linked]) - expect(linked.id).toBe(original.id) - expect(linked.name).toBe(original.name) - expect(linked.createdAt).toBe(original.createdAt) - expect(linked.runtimeId).toBe('host-runtime') - expect(linked.pairingRevision).toBe(101) - expect(linked.endpoints).toHaveLength(2) - expect(linked.endpoints[0]).toEqual(original.endpoints[0]) - expect(getPreferredPairingOffer(linked)).toEqual(verifiedPairing) - - const unlinked = unlink(linked, 80) - expect(unlinked.id).toBe(original.id) - expect(unlinked.runtimeId).toBe('host-runtime') - expect(unlinked.pairingRevision).toBe(102) - expect(unlinked.endpoints).toEqual(original.endpoints) - expect(unlinked.preferredEndpointId).toBe(original.preferredEndpointId) - expect(unlinked.sshAccess).toBeUndefined() - expect(unlinked.connectionDependency).toBeUndefined() - expect(listEnvironments(userDataPath)).toEqual([unlinked]) - }) - - it('refuses a link whose verification raced with re-pairing', () => { - const original = seed() - const replaced = updateEnvironmentFromPairingCode(userDataPath, original.id, { - pairingCode: encodePairingOffer({ ...pairing, deviceToken: 'replacement-token' }), - now: 100 - }) - expect(() => link(original)).toThrow('changed while SSH access') - expect(listEnvironments(userDataPath)).toEqual([replaced]) - }) - - it('permits unrelated last-used timestamp updates during verification', () => { - const original = seed() - markEnvironmentUsed(userDataPath, original.id, { now: 200 }) - expect(link(original).lastUsedAt).toBe(200) - }) - - it('keeps orca-environments.json in the shape shipped builds read while SSH access is linked', () => { - const original = seed() - const shippedEnvelope = z - .object({ - version: z.literal(1), - environments: z.array(PersistedRuntimeEnvironmentSchema.strict()) - }) - .strict() - const readEnvelope = () => - JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - const linked = link(original) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - expect(readEnvelope().environments[0]).toEqual(original) - markEnvironmentUsed(userDataPath, linked.id, { now: 500 }) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - expect(listEnvironments(userDataPath)[0]?.sshAccess).toEqual(linked.sshAccess) - unlink(linked) - expect(shippedEnvelope.safeParse(readEnvelope()).success).toBe(true) - }) - - it.each([ - { ...verifiedPairing, publicKeyB64: Buffer.alloc(32, 2).toString('base64') }, - { ...verifiedPairing, deviceToken: 'another-token' }, - { ...verifiedPairing, pairedDeviceId: 'another-client' } - ])('refuses a different authenticated host or pairing grant', (wrongPairing) => { - const original = seed() - const prepared = prepare(original) - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - expectedEnvironment: prepared, - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing: wrongPairing, - tunnel - }) - ).toThrow('did not verify') - expect(listEnvironments(userDataPath)).toEqual([prepared]) - }) - - it('refuses changing a known execution runtime identity', () => { - const original = seed() - markEnvironmentUsed(userDataPath, original.id, { runtimeId: 'incumbent-runtime' }) - const current = listEnvironments(userDataPath)[0] - expect(() => link(current)).toThrow('did not verify') - expect(listEnvironments(userDataPath)[0]).toMatchObject({ - ...current, - pendingSshAccessOperation: { operation: 'link' } - }) - }) - - it('refuses a second registration of the same execution runtime', () => { - const original = seed() - const duplicate = addEnvironmentFromPairingCode(userDataPath, { - name: 'Duplicate host', - pairingCode: encodePairingOffer(pairing) - }) - markEnvironmentUsed(userDataPath, duplicate.id, { runtimeId: 'host-runtime' }) - const before = listEnvironments(userDataPath) - expect(() => link(original)).toThrow('registered more than once') - expect( - listEnvironments(userDataPath).find((entry) => entry.id === original.id) - ?.pendingSshAccessOperation?.operation - ).toBe('link') - expect(listEnvironments(userDataPath).find((entry) => entry.id === duplicate.id)).toEqual( - before.find((entry) => entry.id === duplicate.id) - ) - }) - - it('prevents ordinary removal or re-pairing from orphaning SSH access', () => { - const linked = link() - expect(() => removeEnvironment(userDataPath, linked.id)).toThrow('Unlink') - expect(() => - updateEnvironmentFromPairingCode(userDataPath, linked.id, { - pairingCode: encodePairingOffer(pairing) - }) - ).toThrow('Unlink') - expect(() => link(linked)).toThrow('already has SSH access') - expect(listEnvironments(userDataPath)).toEqual([linked]) - }) - - it('does not let a stale unlink remove a subsequently replaced SSH link', () => { - const first = link() - const unlinked = unlink(first) - const replacement = link(unlinked) - expect(() => unlink(first)).toThrow('changed while SSH access') - expect(listEnvironments(userDataPath)).toEqual([replacement]) - }) - - it('persists retryable link intent before target claim and fences pairing mutations', () => { - const original = seed() - const prepared = prepare(original) - expect(prepared.endpoints).toEqual(original.endpoints) - expect(prepared.sshAccess).toBeUndefined() - expect(listEnvironments(userDataPath)).toEqual([prepared]) - expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')).version).toBe(1) - expect(prepare(original)).toEqual(prepared) - expect(prepare(prepared)).toEqual(prepared) - expect(() => prepare(prepared, 'other-request')).toThrow('Another SSH access') - expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink') - expect(() => - updateEnvironmentFromPairingCode(userDataPath, prepared.id, { - pairingCode: encodePairingOffer(pairing) - }) - ).toThrow('Unlink') - expect(() => - markEnvironmentUsed(userDataPath, prepared.id, { runtimeId: 'different-runtime' }) - ).toThrow('cannot change') - }) - - it('completes a matching verified link atomically and handles lost completion responses', () => { - const prepared = prepare() - const args = { - expectedEnvironment: prepared, - requestId: 'link-request', - verifiedRuntimeId: 'host-runtime', - verifiedPairing, - tunnel - } - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { ...args, requestId: 'stale-request' }) - ).toThrow('pending link intent') - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - ...args, - tunnel: { ...tunnel, sshTargetGeneration: 4 } - }) - ).toThrow('pending link intent') - const linked = linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, args) - expect(linked.pendingSshAccessOperation).toBeUndefined() - expect(linked.sshAccess?.requestId).toBe('link-request') - expect(linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, args)).toEqual(linked) - expect(() => - linkVerifiedRuntimeEnvironmentSshAccess(userDataPath, { - ...args, - verifiedRuntimeId: 'impostor' - }) - ).toThrow('completed link') - }) - - it('keeps a durable release intent after restoring the endpoint until exact completion', () => { - const linked = link() - const prepared = prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: linked, - requestId: 'release-request' - }) - expect(prepared.sshAccess).toBeUndefined() - expect(getPreferredPairingOffer(prepared)).toEqual(pairing) - expect(prepared.pendingSshAccessOperation).toMatchObject({ - operation: 'unlink', - requestId: 'release-request', - sshTargetId: tunnel.sshTargetId, - sshTargetGeneration: tunnel.sshTargetGeneration - }) - expect(JSON.parse(readFileSync(getEnvironmentStorePath(userDataPath), 'utf8')).version).toBe(1) - expect( - prepareRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'release-request' - }) - ).toEqual(prepared) - expect(() => - completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'stale-request' - }) - ).toThrow('pending unlink intent') - expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink') - const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'release-request' - }) - expect(finished.pendingSshAccessOperation).toBeUndefined() - }) - - it('turns failed verification into durable release intent without ever publishing access', () => { - const prepared = prepare() - expect(() => - cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'wrong' - }) - ).toThrow('pending link intent') - const cancelling = cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: prepared, - requestId: 'link-request' - }) - expect(cancelling.endpoints).toEqual(prepared.endpoints) - expect(cancelling.pendingSshAccessOperation?.operation).toBe('unlink') - expect(cancelling.sshAccess).toBeUndefined() - expect( - cancelRuntimeEnvironmentSshAccessLink(userDataPath, { - expectedEnvironment: cancelling, - requestId: 'link-request' - }) - ).toEqual(cancelling) - const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { - expectedEnvironment: cancelling, - requestId: 'link-request' - }) - expect(finished.pendingSshAccessOperation).toBeUndefined() - }) -}) diff --git a/src/shared/runtime-environment-ssh-access-store.ts b/src/shared/runtime-environment-ssh-access-store.ts deleted file mode 100644 index db14401223c..00000000000 --- a/src/shared/runtime-environment-ssh-access-store.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { runtimeEnvironmentSshAccessBinding } from './runtime-environment-authority-binding' -export { runtimeEnvironmentSshAccessBinding } from './runtime-environment-authority-binding' -import type { PairingOffer } from './pairing' -import { - getPreferredPairingOffer, - RuntimeSshAccessOperationSchema, - type KnownRuntimeEnvironment, - type PersistedRuntimeEnvironment, - type RuntimeSshAccessOperation, - type RuntimeSshTunnelLink -} from './runtime-environments' -import { - readEnvironmentStore, - readPersistedEnvironmentStore, - RuntimeEnvironmentStoreError -} from './runtime-environment-store-file' -import { - overlayRuntimeEnvironmentSidecar, - readCurrentRuntimeEnvironmentSidecarEntry, - runtimeEnvironmentSidecarBinding, - writeRuntimeEnvironmentSidecarEntry, - type RuntimeEnvironmentSidecarEntry -} from './runtime-environment-sidecar' - -type SidecarState = Omit - -type AccessContext = { - environments: PersistedRuntimeEnvironment[] - persisted: PersistedRuntimeEnvironment - entry: SidecarState - view: KnownRuntimeEnvironment -} - -export function prepareRuntimeEnvironmentSshAccessLink( - userDataPath: string, - args: { - expectedEnvironment: KnownRuntimeEnvironment - requestId: string - sshTargetId: string - sshTargetGeneration: number - remotePort: number - targetFingerprint: string - } -): KnownRuntimeEnvironment { - const { expectedEnvironment: _expected, ...fields } = args - const intent = RuntimeSshAccessOperationSchema.parse({ ...fields, operation: 'link' }) - const context = readAccessContext(userDataPath, args.expectedEnvironment, true) - const existing = context.view - if (existing.pendingSshAccessOperation) { - requireMatchingIntent(existing, intent) - return existing - } - if (existing.sshAccess) { - throw invalid('This server already has SSH access.') - } - if (existing.connectionDependency) { - throw invalid('Unlink the existing external tunnel before adding SSH access.') - } - return commit(userDataPath, context, { ...context.entry, pendingSshAccessOperation: intent }) -} - -export function linkVerifiedRuntimeEnvironmentSshAccess( - userDataPath: string, - args: { - expectedEnvironment: KnownRuntimeEnvironment - requestId: string - verifiedRuntimeId: string - verifiedPairing: PairingOffer - tunnel: RuntimeSshTunnelLink - now?: number - } -): KnownRuntimeEnvironment { - const views = readEnvironmentStore(userDataPath).environments - const completed = views.find((entry) => entry.id === args.expectedEnvironment.id) - if (completed?.sshAccess?.requestId && completed.sshAccess.requestId === args.requestId) { - return requireMatchingCompletedLink(completed, args) - } - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const existing = context.view - const intent = existing.pendingSshAccessOperation - if ( - !intent || - intent.operation !== 'link' || - intent.requestId !== args.requestId || - intent.sshTargetId !== args.tunnel.sshTargetId || - intent.sshTargetGeneration !== args.tunnel.sshTargetGeneration || - intent.remotePort !== args.tunnel.remotePort - ) { - throw invalid('SSH access completion does not match its pending link intent.') - } - const offer = getPreferredPairingOffer(existing) - if ( - !args.verifiedRuntimeId.trim() || - (existing.runtimeId !== null && existing.runtimeId !== args.verifiedRuntimeId) || - args.verifiedPairing.publicKeyB64 !== offer.publicKeyB64 || - args.verifiedPairing.deviceToken !== offer.deviceToken || - args.verifiedPairing.pairedDeviceId !== offer.pairedDeviceId - ) { - throw invalid('The SSH endpoint did not verify as this paired server.') - } - if ( - views.some((entry) => entry.id !== existing.id && entry.runtimeId === args.verifiedRuntimeId) - ) { - throw invalid( - 'This runtime is registered more than once. Reconcile its existing registrations first.' - ) - } - const endpointId = `ssh-${randomUUID()}` - const { pendingSshAccessOperation: _intent, ...remaining } = context.entry - return commit(userDataPath, context, { - ...remaining, - pairingRevisionFloor: nextPairingRevision(existing, args.now ?? Date.now()), - runtimeId: args.verifiedRuntimeId, - sshAccess: { - ...args.tunnel, - requestId: intent.requestId, - targetFingerprint: intent.targetFingerprint, - endpointId, - previousPreferredEndpointId: existing.preferredEndpointId, - endpoint: { - id: endpointId, - kind: 'websocket', - label: 'SSH tunnel', - endpoint: args.verifiedPairing.endpoint, - publicKeyB64: offer.publicKeyB64, - deviceToken: offer.deviceToken - } - } - }) -} - -export function prepareRuntimeEnvironmentSshAccessUnlink( - userDataPath: string, - args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string; now?: number } -): KnownRuntimeEnvironment { - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const existing = context.view - if (existing.pendingSshAccessOperation) { - if ( - existing.pendingSshAccessOperation.operation === 'unlink' && - existing.pendingSshAccessOperation.requestId === args.requestId - ) { - return existing - } - throw invalid('Another SSH access operation is pending.') - } - const sshAccess = existing.sshAccess - if (!sshAccess) { - throw invalid('This server does not have independently linked SSH access.') - } - const { sshAccess: _access, ...remaining } = context.entry - return commit(userDataPath, context, { - ...remaining, - pairingRevisionFloor: nextPairingRevision(existing, args.now ?? Date.now()), - pendingSshAccessOperation: { - requestId: args.requestId, - operation: 'unlink', - sshTargetId: sshAccess.sshTargetId, - sshTargetGeneration: sshAccess.sshTargetGeneration, - remotePort: sshAccess.remotePort, - targetFingerprint: sshAccess.targetFingerprint - } - }) -} - -export function completeRuntimeEnvironmentSshAccessUnlink( - userDataPath: string, - args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string } -): KnownRuntimeEnvironment { - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const intent = context.view.pendingSshAccessOperation - if (!intent || intent.operation !== 'unlink' || intent.requestId !== args.requestId) { - throw invalid('SSH access completion does not match its pending unlink intent.') - } - const { pendingSshAccessOperation: _intent, ...remaining } = context.entry - return commit(userDataPath, context, remaining) -} - -export function cancelRuntimeEnvironmentSshAccessLink( - userDataPath: string, - args: { expectedEnvironment: KnownRuntimeEnvironment; requestId: string } -): KnownRuntimeEnvironment { - const context = readAccessContext(userDataPath, args.expectedEnvironment) - const intent = context.view.pendingSshAccessOperation - if (!intent || intent.requestId !== args.requestId) { - throw invalid('SSH access cancellation does not match its pending link intent.') - } - if (intent.operation === 'unlink') { - return context.view - } - return commit(userDataPath, context, { - ...context.entry, - pendingSshAccessOperation: { ...intent, operation: 'unlink' } - }) -} - -/** A retried completion must name exactly the link it already completed. */ -function requireMatchingCompletedLink( - completed: KnownRuntimeEnvironment, - args: Parameters[1] -): KnownRuntimeEnvironment { - const access = completed.sshAccess! - const expectedIntent = args.expectedEnvironment.pendingSshAccessOperation - const prior = expectedIntent - ? { - ...completed, - runtimeId: args.expectedEnvironment.runtimeId, - pairingRevision: args.expectedEnvironment.pairingRevision, - sshAccess: undefined, - connectionDependency: undefined, - pendingSshAccessOperation: expectedIntent, - preferredEndpointId: access.previousPreferredEndpointId, - endpoints: completed.endpoints.filter((entry) => entry.id !== access.endpointId) - } - : completed - requireUnchangedEnvironment([prior], args.expectedEnvironment) - const offer = getPreferredPairingOffer(completed) - if ( - completed.runtimeId !== args.verifiedRuntimeId || - offer.endpoint !== args.verifiedPairing.endpoint || - offer.deviceToken !== args.verifiedPairing.deviceToken || - offer.publicKeyB64 !== args.verifiedPairing.publicKeyB64 || - offer.pairedDeviceId !== args.verifiedPairing.pairedDeviceId || - access.sshTargetId !== args.tunnel.sshTargetId || - access.sshTargetGeneration !== args.tunnel.sshTargetGeneration || - access.localPort !== args.tunnel.localPort || - access.remotePort !== args.tunnel.remotePort || - (expectedIntent && - (expectedIntent.operation !== 'link' || - expectedIntent.requestId !== args.requestId || - expectedIntent.targetFingerprint !== access.targetFingerprint)) - ) { - throw invalid('SSH access retry does not match its completed link.') - } - return completed -} - -function readAccessContext( - userDataPath: string, - expected: KnownRuntimeEnvironment, - ignoreIntent = false -): AccessContext { - const { environments } = readPersistedEnvironmentStore(userDataPath) - const views = readEnvironmentStore(userDataPath).environments - const view = requireUnchangedEnvironment(views, expected, ignoreIntent) - const persisted = environments.find((entry) => entry.id === view.id)! - const current = readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, persisted) - const { binding: _binding, ...entry } = current ?? { binding: undefined } - return { environments, persisted, entry, view } -} - -/** Writes only after the overlay proves every requested field survives validation. */ -function commit( - userDataPath: string, - context: AccessContext, - next: SidecarState -): KnownRuntimeEnvironment { - const view = overlayRuntimeEnvironmentSidecar(context.persisted, { - ...next, - binding: runtimeEnvironmentSidecarBinding(context.persisted) - }) - if ( - (next.sshAccess !== undefined) !== (view.sshAccess !== undefined) || - (next.pendingSshAccessOperation !== undefined) !== - (view.pendingSshAccessOperation !== undefined) - ) { - throw invalid('The SSH access state is inconsistent with this paired server.') - } - writeRuntimeEnvironmentSidecarEntry(userDataPath, context.environments, context.persisted, next) - return view -} - -function requireMatchingIntent( - environment: KnownRuntimeEnvironment, - intent: RuntimeSshAccessOperation -): void { - if (JSON.stringify(environment.pendingSshAccessOperation) !== JSON.stringify(intent)) { - throw invalid('Another SSH access operation is pending.') - } -} - -function requireUnchangedEnvironment( - environments: KnownRuntimeEnvironment[], - expected: KnownRuntimeEnvironment, - ignoreIntent = false -): KnownRuntimeEnvironment { - const existing = environments.find((entry) => entry.id === expected.id) - if ( - !existing || - JSON.stringify(runtimeEnvironmentSshAccessBinding(existing, ignoreIntent)) !== - JSON.stringify(runtimeEnvironmentSshAccessBinding(expected, ignoreIntent)) - ) { - throw invalid( - 'The paired server changed while SSH access was being verified. Retry with its current pairing.' - ) - } - return existing -} - -function nextPairingRevision(environment: KnownRuntimeEnvironment, now: number): number { - return Math.max(now, (environment.pairingRevision ?? environment.createdAt) + 1) -} - -function invalid(message: string): RuntimeEnvironmentStoreError { - return new RuntimeEnvironmentStoreError('invalid_argument', message) -} diff --git a/src/shared/runtime-environment-store-file.ts b/src/shared/runtime-environment-store-file.ts deleted file mode 100644 index a69a3fe22ea..00000000000 --- a/src/shared/runtime-environment-store-file.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' -import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' -import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' -import { - PersistedRuntimeEnvironmentSchema, - RuntimeEnvironmentStoreSchema, - type KnownRuntimeEnvironment, - type RuntimeEnvironmentStore -} from './runtime-environments' -import { - overlayRuntimeEnvironmentSidecar, - readRuntimeEnvironmentSidecar, - RuntimeEnvironmentSidecarInvalidError -} from './runtime-environment-sidecar' -import { hardenExistingSecureFile } from './secure-file' - -const ENVIRONMENTS_FILE = 'orca-environments.json' -export const MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES = 1024 * 1024 - -export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error' - -export class RuntimeEnvironmentStoreError extends Error { - readonly code: RuntimeEnvironmentStoreErrorCode - - constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) { - super(message) - this.name = 'RuntimeEnvironmentStoreError' - this.code = code - } -} - -export function getEnvironmentStorePath(userDataPath: string): string { - return join(userDataPath, ENVIRONMENTS_FILE) -} - -/** The orca-environments.json content, exactly as shipped builds read and rewrite it. */ -export function readPersistedEnvironmentStore(userDataPath: string): RuntimeEnvironmentStore { - const path = getEnvironmentStorePath(userDataPath) - if (!existsSync(path)) { - return { version: 1, environments: [] } - } - try { - hardenExistingSecureFile(path) - const parsed = RuntimeEnvironmentStoreSchema.parse( - JSON.parse( - readNodeFileSyncWithinLimit(path, MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES).buffer.toString( - 'utf8' - ) - ) - ) - return { - version: 1, - environments: parsed.environments - .map((entry) => PersistedRuntimeEnvironmentSchema.parse(entry)) - .sort((a, b) => a.name.localeCompare(b.name)) - } - } catch { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - `Could not read Orca environments at ${path}; the file is invalid.` - ) - } -} - -/** Persisted environments with their sidecar state overlaid; stale sidecar entries are ignored. */ -export function readEnvironmentStore(userDataPath: string): { - version: 1 - environments: KnownRuntimeEnvironment[] -} { - const store = readPersistedEnvironmentStore(userDataPath) - let sidecar: ReturnType - try { - sidecar = readRuntimeEnvironmentSidecar(userDataPath) - } catch (error) { - // Fail closed like the main file: a link we cannot read may pin a host key we must not drop. - if (error instanceof RuntimeEnvironmentSidecarInvalidError) { - throw new RuntimeEnvironmentStoreError('runtime_error', error.message) - } - throw error - } - return { - version: 1, - environments: store.environments.map((environment) => - overlayRuntimeEnvironmentSidecar(environment, sidecar.entries[environment.id]) - ) - } -} - -/** Writes only persisted fields; sidecar state is written by the sidecar module. */ -export function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void { - const path = getEnvironmentStorePath(userDataPath) - try { - writeSecureJsonFileWithinLimit( - path, - RuntimeEnvironmentStoreSchema.parse(store), - MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES - ) - } catch (error) { - if (error instanceof JsonStringifyByteLimitError) { - throw new RuntimeEnvironmentStoreError( - 'runtime_error', - `Could not write Orca environments at ${path}; the store exceeds its durable capacity.` - ) - } - throw error - } -} diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index b4e2ac83d22..e784dc80281 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -1,27 +1,40 @@ import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' +import { readNodeFileSyncWithinLimit } from './node-bounded-file-reader' import { parsePairingCode, type PairingOffer } from './pairing' import { classifyRemotePairingHostname } from './remote-pairing-address' +import { writeSecureJsonFileWithinLimit } from './bounded-secure-json-file' +import { hardenExistingSecureFile } from './secure-file' import { createEnvironmentFromPairingOffer, getPreferredPairingOffer, + KnownRuntimeEnvironmentSchema, + RuntimeEnvironmentStoreSchema, type KnownRuntimeEnvironment, - type PersistedRuntimeEnvironment, - type RuntimeEnvironmentSource + type RuntimeEnvironmentSource, + type RuntimeEnvironmentStore } from './runtime-environments' -import { - readEnvironmentStore, - readPersistedEnvironmentStore, - RuntimeEnvironmentStoreError, - writeEnvironmentStore -} from './runtime-environment-store-file' -import { writeRuntimeEnvironmentSidecarEntry } from './runtime-environment-sidecar' -export { - getEnvironmentStorePath, - MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES, - RuntimeEnvironmentStoreError, - type RuntimeEnvironmentStoreErrorCode -} from './runtime-environment-store-file' +const ENVIRONMENTS_FILE = 'orca-environments.json' +export const MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES = 1024 * 1024 + +export type RuntimeEnvironmentStoreErrorCode = 'invalid_argument' | 'runtime_error' + +export class RuntimeEnvironmentStoreError extends Error { + readonly code: RuntimeEnvironmentStoreErrorCode + + constructor(code: RuntimeEnvironmentStoreErrorCode, message: string) { + super(message) + this.name = 'RuntimeEnvironmentStoreError' + this.code = code + } +} + +export function getEnvironmentStorePath(userDataPath: string): string { + return join(userDataPath, ENVIRONMENTS_FILE) +} export function listEnvironments(userDataPath: string): KnownRuntimeEnvironment[] { return readEnvironmentStore(userDataPath).environments @@ -44,7 +57,7 @@ export function addEnvironmentFromPairingCode( 'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } - const store = readPersistedEnvironmentStore(userDataPath) + const store = readEnvironmentStore(userDataPath) const now = args.now ?? Date.now() const existing = store.environments.find((entry) => entry.name === args.name) if (existing) { @@ -74,14 +87,12 @@ export function addEnvironmentFromPairingCode( } export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment { - const environment = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector) - assertNoIndependentSshAccess(environment) - const store = readPersistedEnvironmentStore(userDataPath) - const persisted = resolveEnvironmentFromStore(store, environment.id) - const remaining = store.environments.filter((entry) => entry.id !== environment.id) - writeEnvironmentStore(userDataPath, { version: 1, environments: remaining }) - // A leftover entry would read as stale anyway; dropping it keeps the sidecar from growing. - writeRuntimeEnvironmentSidecarEntry(userDataPath, remaining, persisted, null) + const store = readEnvironmentStore(userDataPath) + const environment = resolveEnvironmentFromStore(store, selector) + writeEnvironmentStore(userDataPath, { + version: 1, + environments: store.environments.filter((entry) => entry.id !== environment.id) + }) return environment } @@ -97,10 +108,7 @@ export function updateEnvironmentFromPairingCode( 'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.' ) } - assertNoIndependentSshAccess( - resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), selector) - ) - const store = readPersistedEnvironmentStore(userDataPath) + const store = readEnvironmentStore(userDataPath) const existing = resolveEnvironmentFromStore(store, selector) const now = args.now ?? Date.now() const previousPairingRevision = existing.pairingRevision ?? existing.createdAt @@ -170,7 +178,7 @@ export function markEnvironmentUsed( selector: string, args: { runtimeId?: string | null; pairedDeviceId?: string; now?: number } = {} ): void { - const store = readPersistedEnvironmentStore(userDataPath) + const store = readEnvironmentStore(userDataPath) const environment = resolveEnvironmentFromStore(store, selector) const now = args.now ?? Date.now() const runtimeIdChanged = args.runtimeId != null && args.runtimeId !== environment.runtimeId @@ -183,15 +191,6 @@ export function markEnvironmentUsed( if (!runtimeIdChanged && !pairedDeviceIdChanged && lastUsedIsFresh) { return } - if (runtimeIdChanged || pairedDeviceIdChanged) { - const current = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environment.id) - if (current.sshAccess || current.pendingSshAccessOperation) { - throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - 'SSH access operation cannot change the paired runtime identity.' - ) - } - } const next = store.environments.map((entry) => entry.id === environment.id ? { @@ -206,10 +205,10 @@ export function markEnvironmentUsed( writeEnvironmentStore(userDataPath, { version: 1, environments: next }) } -export function resolveEnvironmentFromStore( - store: { environments: T[] }, +function resolveEnvironmentFromStore( + store: RuntimeEnvironmentStore, selector: string -): T { +): KnownRuntimeEnvironment { const byId = store.environments.find((entry) => entry.id === selector) if (byId) { return byId @@ -227,17 +226,49 @@ export function resolveEnvironmentFromStore KnownRuntimeEnvironmentSchema.parse(entry)) + .sort((a, b) => a.name.localeCompare(b.name)) + } + } catch { throw new RuntimeEnvironmentStoreError( - 'invalid_argument', - "Unlink this server's SSH access before replacing its pairing or removing it." + 'runtime_error', + `Could not read Orca environments at ${path}; the file is invalid.` ) } } + +function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void { + const path = getEnvironmentStorePath(userDataPath) + try { + writeSecureJsonFileWithinLimit( + path, + RuntimeEnvironmentStoreSchema.parse(store), + MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES + ) + } catch (error) { + if (error instanceof JsonStringifyByteLimitError) { + throw new RuntimeEnvironmentStoreError( + 'runtime_error', + `Could not write Orca environments at ${path}; the store exceeds its durable capacity.` + ) + } + throw error + } +} diff --git a/src/shared/runtime-environments-ssh-access.test.ts b/src/shared/runtime-environments-ssh-access.test.ts deleted file mode 100644 index d2ec327c151..00000000000 --- a/src/shared/runtime-environments-ssh-access.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing' -import { - createEnvironmentFromPairingOffer, - getPreferredPairingOffer, - getRuntimeSshAccess, - KnownRuntimeEnvironmentSchema, - redactRuntimeEnvironment, - RuntimeEnvironmentStoreSchema, - type RuntimeSshTunnelLink -} from './runtime-environments' - -const link: RuntimeSshTunnelLink = { - sshTargetId: 'ssh-host', - sshTargetGeneration: 7, - localPort: 46768, - remotePort: 6768 -} -const accessLink = { - ...link, - endpointId: 'ssh-access', - previousPreferredEndpointId: 'ws-environment-1' -} -const offer: PairingOffer = { - v: PAIRING_OFFER_VERSION, - endpoint: 'ws://127.0.0.1:46768', - deviceToken: 'secret-device-token', - publicKeyB64: 'secret-key', - pairedDeviceId: 'paired-device' -} -function accessEnvironment() { - const original = environment() - return { - ...original, - connectionDependency: 'ssh-tunnel' as const, - sshAccess: accessLink, - endpoints: [...original.endpoints, { ...original.endpoints[0]!, id: accessLink.endpointId }], - preferredEndpointId: accessLink.endpointId - } -} -function environment() { - return createEnvironmentFromPairingOffer({ - id: 'environment-1', - name: 'Host', - now: 1, - offer, - runtimeId: 'host-runtime' - }) -} - -describe('runtime SSH access without deployment ownership', () => { - it('keeps old stored environments valid without adding an SSH dependency', () => { - const original = environment() - const restored = RuntimeEnvironmentStoreSchema.parse({ version: 1, environments: [original] }) - .environments[0]! - expect(restored).toEqual(original) - expect(getRuntimeSshAccess(KnownRuntimeEnvironmentSchema.parse(restored))).toBeUndefined() - expect(restored).not.toHaveProperty('sshAccess') - // The persisted envelope never carries sidecar state, even when handed a linked environment. - const persisted = RuntimeEnvironmentStoreSchema.parse({ - version: 1, - environments: [accessEnvironment()] - }).environments[0]! - expect(persisted).not.toHaveProperty('sshAccess') - }) - - it.each([ - { sshTargetId: '' }, - { sshTargetGeneration: 0 }, - { sshTargetGeneration: 1.5 }, - { localPort: 0 }, - { localPort: 65536 }, - { remotePort: 0 }, - { remotePort: 65536 } - ])('rejects malformed generic access links: %j', (change) => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - sshAccess: { ...accessLink, ...change } - }).success - ).toBe(false) - }) - - it('preserves SSH metadata while redacting the same pairing secrets', () => { - const accessed = KnownRuntimeEnvironmentSchema.parse(accessEnvironment()) - const redacted = redactRuntimeEnvironment(accessed) - expect(getRuntimeSshAccess(redacted)).toEqual(accessLink) - expect(redacted.endpoints[0]).not.toHaveProperty('deviceToken') - expect(redacted.endpoints[0]).not.toHaveProperty('publicKeyB64') - expect(JSON.stringify(redacted)).not.toContain('secret-') - expect(getPreferredPairingOffer(accessed)).toEqual(offer) - }) - - it.each([ - { endpointId: 'missing' }, - { previousPreferredEndpointId: 'missing' }, - { previousPreferredEndpointId: 'ssh-access' }, - { localPort: 46769 } - ])('rejects access links that cannot restore the prior endpoint: %j', (change) => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - sshAccess: { ...accessLink, ...change } - }).success - ).toBe(false) - }) - - it('rejects an unpreferred SSH access endpoint', () => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - preferredEndpointId: accessLink.previousPreferredEndpointId - }).success - ).toBe(false) - }) - - it('rejects a non-loopback SSH access endpoint', () => { - const accessed = accessEnvironment() - accessed.endpoints[1]!.endpoint = 'ws://remote.example:46768' - expect(KnownRuntimeEnvironmentSchema.safeParse(accessed).success).toBe(false) - }) - - it('rejects a generic access link without its SSH dependency', () => { - expect( - KnownRuntimeEnvironmentSchema.safeParse({ - ...accessEnvironment(), - connectionDependency: undefined - }).success - ).toBe(false) - }) - - it('rejects a non-WebSocket loopback endpoint', () => { - const accessed = accessEnvironment() - accessed.endpoints[1]!.endpoint = 'https://127.0.0.1:46768' - expect(KnownRuntimeEnvironmentSchema.safeParse(accessed).success).toBe(false) - }) -}) diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index a7f19dd9788..b3411eb3f70 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -1,7 +1,5 @@ import { z } from 'zod' import { PAIRING_OFFER_VERSION, type PairingOffer } from './pairing' -import { classifyRemotePairingHostname } from './remote-pairing-address' -import { RuntimeEnvironmentReconciliationRecordSchema } from './runtime-environment-reconciliation-record' export const RuntimeAccessEndpointSchema = z.object({ id: z.string().min(1), @@ -22,42 +20,7 @@ export type PublicRuntimeAccessEndpoint = z.infer -export const RuntimeSshTunnelLinkSchema = z.object({ - sshTargetId: z.string().min(1), - sshTargetGeneration: z.number().int().positive(), - localPort: z.number().int().min(1).max(65_535), - remotePort: z.number().int().min(1).max(65_535) -}) - -export type RuntimeSshTunnelLink = z.infer - -/** A server Orca deployed and owns over SSH; unlike sshAccess, it grants lifecycle ownership. */ -export const OrcadDeploymentLinkSchema = RuntimeSshTunnelLinkSchema -export type OrcadDeploymentLink = RuntimeSshTunnelLink - -export const RuntimeSshAccessLinkSchema = RuntimeSshTunnelLinkSchema.extend({ - requestId: z.string().min(1).optional(), - targetFingerprint: z.string().min(1).optional(), - endpointId: z.string().min(1), - previousPreferredEndpointId: z.string().min(1) -}) - -export type RuntimeSshAccessLink = z.infer - -export const RuntimeSshAccessOperationSchema = RuntimeSshTunnelLinkSchema.omit({ localPort: true }) - .extend({ - requestId: z.string().min(1), - operation: z.enum(['link', 'unlink']), - targetFingerprint: z.string().min(1).optional() - }) - .refine((intent) => intent.operation !== 'link' || !!intent.targetFingerprint, { - message: 'Link intent requires a target fingerprint.' - }) - -export type RuntimeSshAccessOperation = z.infer - -/** The fields shipped builds read and rewrite in orca-environments.json. */ -export const PersistedRuntimeEnvironmentSchema = z.object({ +export const KnownRuntimeEnvironmentSchema = z.object({ id: z.string().min(1), name: z.string().min(1), createdAt: z.number().finite(), @@ -72,63 +35,6 @@ export const PersistedRuntimeEnvironmentSchema = z.object({ preferredEndpointId: z.string().min(1) }) -export type PersistedRuntimeEnvironment = z.infer - -/** - * A persisted environment with its sidecar state overlaid (runtime-environment-sidecar). These - * fields never enter orca-environments.json: shipped builds strip unknown keys when they rewrite it. - */ -export const KnownRuntimeEnvironmentSchema = PersistedRuntimeEnvironmentSchema.extend({ - sshAccess: RuntimeSshAccessLinkSchema.optional(), - pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), - orcadDeployment: OrcadDeploymentLinkSchema.optional(), - reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() -}) - .refine( - ({ pendingSshAccessOperation, sshAccess, orcadDeployment, connectionDependency }) => - !pendingSshAccessOperation || (!sshAccess && !orcadDeployment && !connectionDependency), - { - message: 'Pending SSH access operations cannot coexist with active SSH access.', - path: ['pendingSshAccessOperation'] - } - ) - .refine( - (environment) => { - const access = environment.sshAccess - return ( - !access || - (environment.connectionDependency === 'ssh-tunnel' && - environment.preferredEndpointId === access.endpointId && - access.previousPreferredEndpointId !== access.endpointId && - environment.endpoints.some( - (endpoint) => endpoint.id === access.previousPreferredEndpointId - ) && - getPreferredLoopbackRuntimePort(environment) === access.localPort) - ) - }, - { - message: - 'Runtime SSH access must preserve its previous endpoint and prefer its loopback endpoint.', - path: ['sshAccess'] - } - ) - .refine( - (environment) => { - const deployment = environment.orcadDeployment - return ( - !deployment || - (!environment.sshAccess && - environment.connectionDependency === 'ssh-tunnel' && - getPreferredLoopbackRuntimePort(environment) === deployment.localPort) - ) - }, - { - message: - 'A managed deployment must prefer its own tunnel and exclude independent SSH access.', - path: ['orcadDeployment'] - } - ) - export type KnownRuntimeEnvironment = z.infer export type PublicKnownRuntimeEnvironment = Omit & { @@ -146,11 +52,9 @@ export function redactRuntimeEnvironment( } } -// Why version 1 and the persisted schema: shipped builds accept only this shape, so every -// T5 field lives in the sidecar and a downgrade rewrite cannot lose or reject it. export const RuntimeEnvironmentStoreSchema = z.object({ version: z.literal(1), - environments: z.array(PersistedRuntimeEnvironmentSchema) + environments: z.array(KnownRuntimeEnvironmentSchema) }) export type RuntimeEnvironmentStore = z.infer @@ -217,35 +121,3 @@ export function getPreferredPairingOffer(environment: KnownRuntimeEnvironment): ...(environment.pairedDeviceId ? { pairedDeviceId: environment.pairedDeviceId } : {}) } } - -/** The tunnel a server is reached through; only orcadDeployment also grants lifecycle ownership. */ -export function getRuntimeSshAccess( - environment: Pick -): RuntimeSshTunnelLink | undefined { - return environment.orcadDeployment ?? environment.sshAccess -} - -export function getPreferredLoopbackRuntimePort(environment: { - endpoints: { id: string; endpoint: string }[] - preferredEndpointId: string -}): number | null { - const endpoint = environment.endpoints.find( - (entry) => entry.id === environment.preferredEndpointId - ) - if (!endpoint) { - return null - } - try { - const url = new URL(endpoint.endpoint) - const port = Number(url.port) - return (url.protocol === 'ws:' || url.protocol === 'wss:') && - classifyRemotePairingHostname(url.hostname) === 'loopback' && - Number.isInteger(port) && - port >= 1 && - port <= 65_535 - ? port - : null - } catch { - return null - } -} diff --git a/src/shared/runtime-rpc-call-queue-retirement.test.ts b/src/shared/runtime-rpc-call-queue-retirement.test.ts deleted file mode 100644 index 3958c200991..00000000000 --- a/src/shared/runtime-rpc-call-queue-retirement.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { RuntimeRpcCallQueueBusyError, RuntimeRpcCallQueuePool } from './runtime-rpc-call-queue' - -it('refuses an active or queued selector without interrupting or replaying its calls', async () => { - const queue = new RuntimeRpcCallQueuePool(1, 1) - const pending = Promise.withResolvers() - const first = queue.enqueue('host', 'terminal.send', () => pending.promise) - const secondRun = vi.fn(async () => 'second-ack') - const second = queue.enqueue('host', 'terminal.send', secondRun) - expect(() => queue.holdIdleSelectors(['host'])).toThrow(RuntimeRpcCallQueueBusyError) - pending.resolve('first-ack') - await expect(first).resolves.toBe('first-ack') - await expect(second).resolves.toBe('second-ack') - expect(secondRun).toHaveBeenCalledOnce() - await vi.waitFor(() => queue.holdIdleSelectors(['host'])()) -}) - -it('holds both identities, refuses new calls before dispatch, and leaves other hosts running', async () => { - const queue = new RuntimeRpcCallQueuePool() - const release = queue.holdIdleSelectors(['canonical', 'historical', 'canonical']) - const run = vi.fn(async () => 'ack') - for (const id of ['canonical', 'historical']) { - await expect(queue.enqueue(id, 'terminal.send', run)).rejects.toMatchObject({ - code: 'runtime_rpc_queue_busy' - }) - } - expect(run).not.toHaveBeenCalled() - await expect(queue.enqueue('other', 'terminal.send', run)).resolves.toBe('ack') - release() - await expect(queue.enqueue('historical', 'terminal.send', run)).resolves.toBe('ack') - expect(run).toHaveBeenCalledTimes(2) -}) - -it('acquires a group atomically without retaining a partial hold on failure', async () => { - const queue = new RuntimeRpcCallQueuePool() - const release = queue.holdIdleSelectors(['historical']) - expect(() => queue.holdIdleSelectors(['canonical', 'historical'])).toThrow( - RuntimeRpcCallQueueBusyError - ) - await expect(queue.enqueue('canonical', 'repo.list', async () => 'ok')).resolves.toBe('ok') - release() -}) - -it('does not let an old release clear a newer hold', async () => { - const queue = new RuntimeRpcCallQueuePool() - const first = queue.holdIdleSelectors(['host']) - first() - const second = queue.holdIdleSelectors(['host']) - first() - await expect(queue.enqueue('host', 'repo.list', async () => 'ok')).rejects.toThrow( - RuntimeRpcCallQueueBusyError - ) - second() - await expect(queue.enqueue('host', 'repo.list', async () => 'ok')).resolves.toBe('ok') -}) - -it('counts a selector busy while only its long-wait lane has a call', async () => { - const queue = new RuntimeRpcCallQueuePool() - const pending = Promise.withResolvers() - const removal = queue.enqueue('host', 'worktree.rm', () => pending.promise) - expect(() => queue.holdIdleSelectors(['host'])).toThrow(RuntimeRpcCallQueueBusyError) - pending.resolve('removed') - await expect(removal).resolves.toBe('removed') - await vi.waitFor(() => queue.holdIdleSelectors(['host'])()) -}) diff --git a/src/shared/runtime-rpc-call-queue.ts b/src/shared/runtime-rpc-call-queue.ts index 0503c5e06c0..132c8099273 100644 --- a/src/shared/runtime-rpc-call-queue.ts +++ b/src/shared/runtime-rpc-call-queue.ts @@ -16,15 +16,6 @@ export class RuntimeRpcCallQueueOverloadError extends Error { } } -export class RuntimeRpcCallQueueBusyError extends Error { - readonly code = 'runtime_rpc_queue_busy' - - constructor() { - super('Runtime calls are active or their routing is changing; retry after they settle.') - this.name = 'RuntimeRpcCallQueueBusyError' - } -} - type QueuedRuntimeCall = { background: boolean retainedBytes: number @@ -66,13 +57,8 @@ function isLongWaitRuntimeMethod(method: string): boolean { return method === 'worktree.rm' } -function longWaitQueueKey(selector: string): string { - return `${selector}\u0000long-wait` -} - export class RuntimeRpcCallQueuePool { private readonly queues = new Map() - private readonly heldSelectors = new Set() private queuedCallCount = 0 private retainedCallBytes = 0 @@ -84,32 +70,6 @@ export class RuntimeRpcCallQueuePool { private readonly maxRetainedBytes = REMOTE_RUNTIME_MAX_PREPARED_RPC_BYTES ) {} - /** Acquires all idle selectors atomically; release never replays refused calls. */ - holdIdleSelectors(selectors: readonly string[]): () => void { - const unique = [...new Set(selectors)] - // A selector's long-wait lane counts too: holding it must see every call still in flight. - const busy = (selector: string): boolean => - this.heldSelectors.has(selector) || - this.queues.has(selector) || - this.queues.has(longWaitQueueKey(selector)) - if (unique.some(busy)) { - throw new RuntimeRpcCallQueueBusyError() - } - for (const selector of unique) { - this.heldSelectors.add(selector) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const selector of unique) { - this.heldSelectors.delete(selector) - } - } - } - enqueue( selector: string, method: string, @@ -120,11 +80,8 @@ export class RuntimeRpcCallQueuePool { if (signal?.aborted) { return Promise.reject(abortSignalReason(signal)) } - if (this.heldSelectors.has(selector)) { - return Promise.reject(new RuntimeRpcCallQueueBusyError()) - } // Same concurrency bound, counted apart from the selector's other calls; global caps still apply. - const queueKey = isLongWaitRuntimeMethod(method) ? longWaitQueueKey(selector) : selector + const queueKey = isLongWaitRuntimeMethod(method) ? `${selector}\u0000long-wait` : selector if (this.queuedCallCount >= this.maxQueuedTotal) { return Promise.reject(new RuntimeRpcCallQueueOverloadError('global')) } diff --git a/src/shared/runtime-ssh-access.ts b/src/shared/runtime-ssh-access.ts deleted file mode 100644 index 167810f095a..00000000000 --- a/src/shared/runtime-ssh-access.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod' - -const AccessRequest = z.object({ - selector: z.string().trim().min(1).max(1024), - requestId: z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/) -}) - -export const RuntimeSshAccessLinkRequestSchema = AccessRequest.extend({ - sshTargetId: z.string().trim().min(1).max(1024), - remotePort: z.number().int().min(1).max(65_535) -}).strict() -export const RuntimeSshAccessUnlinkRequestSchema = AccessRequest.strict() - -export type RuntimeSshAccessLinkRequest = z.infer -export type RuntimeSshAccessUnlinkRequest = z.infer diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 285e0e9ac50..983498f92ca 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -1,5 +1,4 @@ import type { SshPendingPtyKill } from './ssh-pending-pty-kill' -import type { OrcadSshProvisioningIntent } from './orcad-ssh-provisioning' // ─── SSH Connection Types ─────────────────────────────────────────── @@ -89,12 +88,10 @@ export type SshTarget = { * re-adopt only, so automations fenced on an old registration cannot run on a * later target that happens to reuse the id. Never advanced by connect state. */ generation?: number - /** Main-owned provisioning intent; never fall back to a relay while it exists. */ - orcadProvisioning?: OrcadSshProvisioningIntent } /** Renderer-authored target fields; registration generations are allocated and owned by main. */ -export type SshTargetCreateInput = Omit +export type SshTargetCreateInput = Omit export type SshTargetUpdateInput = Partial /** Public target identity and observed host metadata safe to mirror to a paired client. */ diff --git a/src/shared/transport-publication-drain.test.ts b/src/shared/transport-publication-drain.test.ts deleted file mode 100644 index e515cf464f6..00000000000 --- a/src/shared/transport-publication-drain.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { TransportPublicationDrain } from './transport-publication-drain' - -describe('TransportPublicationDrain', () => { - it('waits for every tracked write, including writes tracked while draining', async () => { - const publication = new TransportPublicationDrain(() => {}) - const first = publication.trackWrite() - const done = vi.fn() - const drain = publication.drain(new AbortController().signal).then(done) - const second = publication.trackWrite() - first({ ok: true }) - await Promise.resolve() - expect(done).not.toHaveBeenCalled() - expect(() => publication.assertDrained()).toThrow('transport_publication_not_drained') - second({ ok: true }) - await drain - expect(done).toHaveBeenCalledOnce() - }) - - it('ignores a repeated settlement of the same write', async () => { - const publication = new TransportPublicationDrain(() => {}) - const settle = publication.trackWrite() - const other = publication.trackWrite() - settle({ ok: true }) - settle({ ok: true }) - expect(() => publication.assertDrained()).toThrow('transport_publication_not_drained') - other({ ok: true }) - await publication.drain(new AbortController().signal) - }) - - it('aborts only the observer and keeps a later write failure sticky', async () => { - const onFailure = vi.fn() - const publication = new TransportPublicationDrain(() => {}, onFailure) - const settle = publication.trackWrite() - const controller = new AbortController() - const drain = publication.drain(controller.signal) - controller.abort(new Error('observer cancelled')) - await expect(drain).rejects.toThrow('observer cancelled') - settle({ ok: false, error: new Error('lost write') }) - expect(onFailure).toHaveBeenCalledOnce() - await expect(publication.drain(new AbortController().signal)).rejects.toThrow('lost write') - expect(() => publication.assertCurrent()).toThrow('lost write') - }) - - it('fails once when the transport it was bound to is replaced', async () => { - let current = true - const onFailure = vi.fn() - const publication = new TransportPublicationDrain(() => { - if (!current) { - throw new Error('transport_replaced') - } - }, onFailure) - const settle = publication.trackWrite() - current = false - const drain = publication.drain(new AbortController().signal) - await expect(drain).rejects.toThrow('transport_replaced') - settle({ ok: true }) - expect(() => publication.assertDrained()).toThrow('transport_replaced') - expect(onFailure).toHaveBeenCalledOnce() - }) - - it('refuses construction against a transport that is already stale', () => { - expect( - () => - new TransportPublicationDrain(() => { - throw new Error('transport_replaced') - }) - ).toThrow('transport_replaced') - }) -}) diff --git a/src/shared/transport-publication-drain.ts b/src/shared/transport-publication-drain.ts deleted file mode 100644 index 3814b120195..00000000000 --- a/src/shared/transport-publication-drain.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { waitForPromiseWithSignal } from './abort-signal-reason' - -export type TransportPublicationSettlement = { ok: true } | { ok: false; error: Error } - -/** Local write completion only; downstream consumption requires separate proof. */ -export class TransportPublicationDrain { - private pending = 0 - protected failure: Error | undefined - private readonly observers = new Set<() => void>() - - constructor( - private readonly assertTransport: () => void, - private readonly onFailure: (error: Error) => void = () => {} - ) { - this.assertCurrent() - } - - trackWrite(): (result: TransportPublicationSettlement) => void { - this.pending++ - let settled = false - return (result) => { - if (settled) { - return - } - settled = true - this.pending-- - if (!result.ok) { - this.fail(result.error) - } - this.changed() - } - } - - fail(error: Error): void { - if (this.failure) { - return - } - this.failure = error - this.changed() - this.onFailure(error) - } - - assertDrained(): void { - this.assertCurrent() - if (this.pending !== 0) { - throw new Error('transport_publication_not_drained') - } - } - - async drain(signal: AbortSignal): Promise { - signal.throwIfAborted() - while (this.pending !== 0) { - this.assertCurrent() - // Why: no Promise.withResolvers — the legacy relay bundle still targets Node 18 hosts. - let notify!: () => void - const changed = new Promise((resolve) => { - notify = resolve - }) - this.observers.add(notify) - try { - await waitForPromiseWithSignal(changed, signal) - } finally { - this.observers.delete(notify) - } - } - signal.throwIfAborted() - this.assertDrained() - } - - assertCurrent(): void { - if (this.failure) { - throw this.failure - } - try { - this.assertTransport() - } catch (error) { - this.fail(error instanceof Error ? error : new Error(String(error))) - throw this.failure - } - } - - private changed(): void { - for (const notify of this.observers) { - notify() - } - } -} diff --git a/tests/e2e/activity-agent-pane-isolation.spec.ts b/tests/e2e/activity-agent-pane-isolation.spec.ts index 050f02e93cc..eb78ef70985 100644 --- a/tests/e2e/activity-agent-pane-isolation.spec.ts +++ b/tests/e2e/activity-agent-pane-isolation.spec.ts @@ -116,7 +116,7 @@ async function enableInlineAgentCards(page: Page): Promise { const state = store.getState() if (!state.worktreeCardProperties.includes('inline-agents')) { - state.toggleWorktreeCardProperty('inline-agents') + state.setWorktreeCardProperties([...state.worktreeCardProperties, 'inline-agents']) } state.closeActivityPage() }) @@ -272,6 +272,7 @@ test.describe('Activity Agent Pane Isolation', () => { const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) const [first, second] = await seedActivityThreadsForSplitPanes(orcaPage, snapshot) + await orcaPage.evaluate(() => window.__store?.getState().setWorktreeCardProperties([])) await enableInlineAgentCards(orcaPage) await clickWorkspaceCardAgentRow(orcaPage, first.prompt) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 20b64c7f6db..b31c8f008cd 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -4,14 +4,18 @@ import { join } from 'node:path' import { expect, test } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalItemIdentity, type AgentSessionJournalIdentity } from '../../../src/shared/agent-session-journal-types' import Database from '../../../src/main/sqlite/sync-database' import { journalDatabasePath } from '../../../src/main/native-chat/agent-session-journal/journal-host-database' import { + closeTestJournalHostDatabase, createTrackedJournalOpener, - liveTestJournalRows + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' @@ -54,6 +58,7 @@ type OldReplay = { truncateFrom?: number } +// Both downgrade probes load real old builds, including cold extraction and transforms. test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) const journals = createTrackedJournalOpener() @@ -147,7 +152,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol await journals.closeAll() rmSync(directory, { recursive: true, force: true }) } -}) +}, 120_000) type OlderJournal = { isReadOnly: boolean @@ -223,3 +228,64 @@ test("an older build opens this build's journal writable and appends to it; the rmSync(directory, { recursive: true, force: true }) } }, 120_000) + +// Why a Stop's event cannot have a row kind of its own yet: this build keeps a kind it does not know +// and goes read-only, but a build from before that deletes the journal from it. So a Stop kind ships +// its reader first and is written once no supported build lacks that reader, or is written at a +// bumped `v`. Move the baseline to the first release with this rule, and the older build keeps the +// row too. +test("this build keeps a newer build's row kind and goes read-only; a build before it deletes it", async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-newer-kind-downgrade-')) + const journals = createTrackedJournalOpener() + const newerKinds = () => storedRows(directory).filter((row) => row.includes('"future-mark"')) + try { + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await journal.appendItem(item(0), { kind: 'status', text: 'before' }, scope) + const at = journal.cursor().sequence + 1 + const newer = JSON.stringify({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'future-mark', + epoch: journal.epoch, + seq: at, + fence: 1, + ts: 2_000, + payload: { said: 'by a newer build' } + }) + await journals.closeAll() + insertTestJournalRowJson( + openTestJournalHostDatabase(directory).db, + IDENTITY.sessionId, + at, + newer + ) + closeTestJournalHostDatabase(directory) + const rowsBefore = storedRows(directory) + + const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + expect(reopened.isReadOnly).toBe(true) + expect(reopened.repair).toEqual({ malformedRows: 0 }) + await expect( + reopened.appendItem(item(1), { kind: 'status', text: 'after' }, scope) + ).rejects.toMatchObject({ code: 'journal_read_only' }) + await journals.closeAll() + expect(storedRows(directory)).toEqual(rowsBefore) + expect(newerKinds()).toEqual([newer]) + + const checkout = await materializeReleaseCheckout(WRITABLE_BASELINE_REF) + const support = await importReleaseCheckoutModule( + checkout, + `${JOURNAL}/journal-host-database-test-support.ts` + ) + const older = releaseExport<() => OlderOpener>(support, 'createTrackedJournalOpener')() + try { + await older.open({ identity: IDENTITY, stateDirectory: directory }) + } finally { + await older.closeAll() + } + expect(newerKinds()).toEqual([]) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 120_000) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 110fc8e489a..1980ed95c55 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -296,7 +296,10 @@ describe('cross-version structured agent sessions', () => { error: { message: expect.stringContaining('structured_agent_session_unsupported') } }) if (baseline.methodNames.includes('agentSession.createSupport')) { - expect(replies[0]?.error).toEqual((await createSupport(baseline, released()))[0]?.error) + // Older clients read the existing refusal fields and ignore additive error metadata. + expect(replies[0]).toMatchObject({ + error: (await createSupport(baseline, released()))[0]?.error + }) } }) diff --git a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts index 106e2ea778e..b722d006a00 100644 --- a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts +++ b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { existsSync, mkdirSync, @@ -25,6 +26,7 @@ import { type ReleaseCheckout } from './release-checkout' const temporaryRoots: string[] = [] +const temporaryRefs: { ref: string; commit: string }[] = [] const COMPRESSED_LOCK_OPTIONS: CheckoutLockOptions = { realpath: false, @@ -255,6 +257,9 @@ async function runContentionPhase( } afterEach(() => { + for (const { ref, commit } of temporaryRefs.splice(0)) { + git(['update-ref', '-d', ref, commit]) + } for (const root of temporaryRoots.splice(0)) { rmSync(root, { recursive: true, force: true }) } @@ -285,6 +290,7 @@ describe('release checkout materialization', () => { expect(relative(cacheRoot, checkouts[0]!.root)).not.toMatch(/^\.\./) }) + // Cold extraction and transforms need the cross-version suite's startup budget. it('loads a baseline module whose source imports another checkout-root file', async () => { const cacheRoot = temporaryCacheRoot() const checkout = await materializeReleaseCheckout('v1.4.190', { cacheRoot }) @@ -292,12 +298,21 @@ describe('release checkout materialization', () => { expect(protocol.REMOTE_SERVER_UPDATE_CAPABILITY).toBe('updater.remote-control.v1') expect(relative(cacheRoot, checkout.root)).not.toMatch(/^\.\./) - }) + }, 180_000) it('keeps an import live while another colliding release label materializes', async () => { - const merge = git(['rev-list', '--merges', '-1', 'HEAD']) - const firstRef = `${merge}~2` - const secondRef = `${merge}^2` + // Squash-merged history has no merge parents; create two distinct refs with colliding labels. + const scope = `refs/orca-checkout-test/${randomUUID()}` + const firstRef = `${scope}/release` + const secondRef = `${scope}_release` + for (const [ref, revision] of [ + [firstRef, 'HEAD'], + [secondRef, 'v1.4.190'] + ]) { + const commit = git(['rev-parse', `${revision}^{commit}`]) + git(['update-ref', ref, commit, '0'.repeat(40)]) + temporaryRefs.push({ ref, commit }) + } expect(git(['rev-parse', `${firstRef}^{commit}`])).not.toBe( git(['rev-parse', `${secondRef}^{commit}`]) ) @@ -328,6 +343,7 @@ describe('release checkout materialization', () => { } await expect(loading).resolves.toMatchObject({ loaded: 'first-release' }) + expect(first.label).toBe(second.label) expect(first.root).not.toBe(second.root) }) diff --git a/tests/e2e/e2e-worker-env-isolation.unit.test.ts b/tests/e2e/e2e-worker-env-isolation.unit.test.ts index 9973230456d..69e0094f8b9 100644 --- a/tests/e2e/e2e-worker-env-isolation.unit.test.ts +++ b/tests/e2e/e2e-worker-env-isolation.unit.test.ts @@ -1,4 +1,13 @@ -import { readFileSync, readdirSync, statSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' import { join, relative, resolve } from 'node:path' import { describe, expect, it } from 'vitest' @@ -26,7 +35,14 @@ const MODULE_SCOPE_ENV_WRITE = // No file may write at module scope. The replacement is a fixture option, which reaches the app // launch without touching the worker every other spec shares. const SCANNED_EXTENSIONS = ['.ts', '.tsx'] -const IGNORED_DIRECTORIES = new Set(['node_modules', 'dist', 'out', 'build', '__fixtures__']) +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '__fixtures__', + '.cross-version-checkouts' +]) function collectE2eFiles(root: string): string[] { const found: string[] = [] @@ -61,6 +77,21 @@ describe('e2e worker env isolation', () => { expect(offenders).toEqual([]) }) + it('checks current source while excluding extracted release copies', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-e2e-env-scan-')) + try { + const cached = join(root, '.cross-version-checkouts') + mkdirSync(cached) + writeFileSync(join(cached, 'old.ts'), "process.env.ORCA_E2E_X = '1'\n") + const source = join(root, 'current.ts') + writeFileSync(source, "process.env.ORCA_E2E_X = '1'\n") + expect(collectE2eFiles(root)).toEqual([source]) + expect(findModuleScopeEnvWrites(source)).toHaveLength(1) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('detects the shape it is meant to catch', () => { // Guards the regex itself: a green that cannot go red would pass this whole file forever. expect(MODULE_SCOPE_ENV_WRITE.test("process.env.ORCA_E2E_X ??= '1'")).toBe(true) diff --git a/tests/e2e/global-setup-cli-artifact.unit.test.ts b/tests/e2e/global-setup-cli-artifact.unit.test.ts index 4c22d1a219d..292b64653fd 100644 --- a/tests/e2e/global-setup-cli-artifact.unit.test.ts +++ b/tests/e2e/global-setup-cli-artifact.unit.test.ts @@ -17,7 +17,7 @@ vi.mock('node:fs', () => ({ })) vi.mock('./helpers/docker-ssh-relay-image', () => ({ prepareDockerSshRelayImage: vi.fn() })) -import globalSetup from './global-setup' +import globalSetup, { workerTestRepositoryPathFile } from './global-setup' beforeEach(() => { vi.resetAllMocks() @@ -42,6 +42,18 @@ function commands(): string[] { } describe('E2E shared CLI artifact', () => { + it('keeps worker publications separate from the run seed and from other workers', () => { + const runPath = path.join('temporary', 'run.txt') + expect(workerTestRepositoryPathFile(runPath)).toBe(runPath) + expect(workerTestRepositoryPathFile(runPath, '0')).not.toBe(runPath) + expect(workerTestRepositoryPathFile(runPath, '0')).not.toBe( + workerTestRepositoryPathFile(runPath, '1') + ) + expect(() => workerTestRepositoryPathFile(runPath, '../other')).toThrow( + 'Invalid Playwright worker index' + ) + }) + it('repairs downloaded permissions and installs the local launcher without recompiling', () => { vi.stubEnv('SKIP_BUILD', '1') globalSetup() diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 210c713159b..60a4642e4d6 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -18,10 +18,25 @@ import os from 'node:os' import { prepareDockerSshRelayImage } from './helpers/docker-ssh-relay-image' export const E2E_TEST_REPO_PATH_FILE_ENV = 'ORCA_E2E_TEST_REPO_PATH_FILE' -/** Temp file where the test repo path is stored for the fixture to read. */ -export const TEST_REPO_PATH_FILE = +const RUN_TEST_REPO_PATH_FILE = process.env[E2E_TEST_REPO_PATH_FILE_ENV] ?? path.join(os.tmpdir(), `orca-e2e-test-repo-path-${randomUUID()}.txt`) + +export function workerTestRepositoryPathFile(runPathFile: string, workerIndex?: string): string { + if (workerIndex === undefined) { + return runPathFile + } + if (!/^\d+$/.test(workerIndex)) { + throw new Error('Invalid Playwright worker index') + } + return `${runPathFile}.worker-${workerIndex}` +} + +// Restart helpers and the app fixture must read the same worker-owned repository. +export const TEST_REPO_PATH_FILE = workerTestRepositoryPathFile( + RUN_TEST_REPO_PATH_FILE, + process.env.TEST_WORKER_INDEX +) const ELECTRON_E2E_BUILD_TIMEOUT_MS = 300_000 const CLI_E2E_BUILD_TIMEOUT_MS = 120_000 const WEB_E2E_BUILD_TIMEOUT_MS = 300_000 diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 0961eb0ee5c..d33a5e87553 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -6,7 +6,8 @@ */ import { execFileSync } from 'node:child_process' -import { readFileSync, existsSync, realpathSync, rmSync } from 'node:fs' +import { readFileSync, readdirSync, existsSync, realpathSync, rmSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' import { TEST_REPO_PATH_FILE } from './global-setup' export function linkedWorktreePaths(testRepoDir: string): string[] { @@ -47,16 +48,33 @@ export function cleanupTestRepository(testRepoDir: string): void { rmSync(root, { recursive: true, force: true }) } -export default function globalTeardown(): void { - if (!existsSync(TEST_REPO_PATH_FILE)) { +export function cleanupTestRepositoryPathFiles(runPathFile: string): void { + const directory = dirname(runPathFile) + if (!existsSync(directory)) { return } - - const testRepoDir = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() - if (testRepoDir && existsSync(testRepoDir)) { - cleanupTestRepository(testRepoDir) - console.error(`[e2e] Cleaned up test repo at ${testRepoDir}`) + const workerPrefix = `${basename(runPathFile)}.worker-` + const workerPathFiles = readdirSync(directory, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && + entry.name.startsWith(workerPrefix) && + /^\d+$/.test(entry.name.slice(workerPrefix.length)) + ) + .map((entry) => join(directory, entry.name)) + for (const pathFile of [runPathFile, ...workerPathFiles]) { + if (!existsSync(pathFile)) { + continue + } + const testRepoDir = readFileSync(pathFile, 'utf-8').trim() + if (testRepoDir && existsSync(testRepoDir)) { + cleanupTestRepository(testRepoDir) + console.error(`[e2e] Cleaned up test repo at ${testRepoDir}`) + } + rmSync(pathFile, { force: true }) } +} - rmSync(TEST_REPO_PATH_FILE, { force: true }) +export default function globalTeardown(): void { + cleanupTestRepositoryPathFiles(TEST_REPO_PATH_FILE) } diff --git a/tests/e2e/global-teardown.unit.test.ts b/tests/e2e/global-teardown.unit.test.ts index fde77199434..ec2a58a3ef3 100644 --- a/tests/e2e/global-teardown.unit.test.ts +++ b/tests/e2e/global-teardown.unit.test.ts @@ -3,7 +3,11 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { cleanupTestRepository, linkedWorktreePaths } from './global-teardown' +import { + cleanupTestRepository, + cleanupTestRepositoryPathFiles, + linkedWorktreePaths +} from './global-teardown' const roots: string[] = [] @@ -18,6 +22,33 @@ afterEach(() => { }) describe('E2E global teardown ownership', () => { + it('cleans orphaned worker publications while preserving another run', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-worker-teardown-')) + roots.push(root) + const runPathFile = path.join(root, 'run.txt') + const ownedRepositories = ['seed', 'worker-0', 'worker-3'].map((name) => { + const repository = path.join(root, name) + mkdirSync(repository) + git(repository, ['init']) + return repository + }) + const publications = [runPathFile, `${runPathFile}.worker-0`, `${runPathFile}.worker-3`] + publications.forEach((publication, index) => { + writeFileSync(publication, ownedRepositories[index]!) + }) + const unrelatedRepo = path.join(root, 'unrelated') + mkdirSync(unrelatedRepo) + const unrelatedPublication = path.join(root, 'another-run.txt.worker-0') + writeFileSync(unrelatedPublication, unrelatedRepo) + + cleanupTestRepositoryPathFiles(runPathFile) + + expect(ownedRepositories.every((repository) => !existsSync(repository))).toBe(true) + expect(publications.every((publication) => !existsSync(publication))).toBe(true) + expect(existsSync(unrelatedRepo)).toBe(true) + expect(existsSync(unrelatedPublication)).toBe(true) + }) + it('removes every linked run worktree and preserves unrelated siblings', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-teardown-contract-')) roots.push(root) diff --git a/tests/e2e/golden-worktree-create-switch.spec.ts b/tests/e2e/golden-worktree-create-switch.spec.ts index dd15ca3115c..55eacbe8903 100644 --- a/tests/e2e/golden-worktree-create-switch.spec.ts +++ b/tests/e2e/golden-worktree-create-switch.spec.ts @@ -1,3 +1,4 @@ +import { removeWorktreeViaStore } from './helpers/dead-terminal' import { openSidebarWorkspaceComposer } from './helpers/sidebar-project-dialog' import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' @@ -26,12 +27,6 @@ async function createWorkspace(page: Page, name: string): Promise { await expect(dialog).toBeHidden({ timeout: 20_000 }) } -async function removeCreatedWorktree(page: Page, worktreeId: string): Promise { - await page.evaluate(async (id) => { - await window.__store?.getState().removeWorktree(id, true) - }, worktreeId) -} - test('creates a worktree, keeps its terminal isolated, and switches back @golden', async ({ orcaPage }) => { @@ -86,7 +81,7 @@ test('creates a worktree, keeps its terminal isolated, and switches back @golden .click() .catch(() => undefined) } - await removeCreatedWorktree(orcaPage, childWorktreeId).catch(() => undefined) + await removeWorktreeViaStore(orcaPage, childWorktreeId) } } }) diff --git a/tests/e2e/helpers/dead-terminal.ts b/tests/e2e/helpers/dead-terminal.ts index 71814f82b7e..2256124b77d 100644 --- a/tests/e2e/helpers/dead-terminal.ts +++ b/tests/e2e/helpers/dead-terminal.ts @@ -125,10 +125,20 @@ export async function createAndActivateWorktreeWithSetup( export async function removeWorktreeViaStore(page: TestPage, worktreeId: string): Promise { await page.evaluate(async (id) => { - try { - await window.__store?.getState().removeWorktree(id, true) - } catch { - /* best-effort */ + const state = window.__store?.getState() + if (!state) { + throw new Error('Worktree cleanup requires the app store') + } + const worktree = state.getKnownWorktreeById(id) + if (!worktree) { + return + } + const result = await state.removeWorktree( + { id, executionHostId: worktree.hostId ?? null }, + true + ) + if (!result.ok) { + throw new Error(`Worktree cleanup failed: ${result.error}`) } }, worktreeId) } diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index af0915a6621..e1d34d72229 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -21,10 +21,9 @@ import { type ElectronApplication, type TestInfo } from '@stablyai/playwright-test' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { TEST_REPO_PATH_FILE } from '../global-setup' import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown' import { getOrcaElectronLaunchArgs } from './electron-launch-args' import { retryTransientMainEvaluate } from './electron-main-evaluate-retry' @@ -33,7 +32,11 @@ import { assertElectronResolvedIsolatedHome, createElectronHomeIsolation } from './electron-home-isolation' -import { createSeededTestRepo, isValidGitRepo } from './seeded-test-repo' +import { + createSeededTestRepo, + isValidGitRepo, + provideWorkerTestRepository +} from './seeded-test-repo' type OrcaTestFixtures = { electronApp: ElectronApplication @@ -66,7 +69,7 @@ type OrcaTestFixtures = { } type OrcaWorkerFixtures = { - /** Absolute path to the test git repo created by globalSetup. */ + /** Absolute path to this worker's disposable test git repo. */ testRepoPath: string } @@ -142,19 +145,13 @@ export function forwardElectronProcessLogs(app: ElectronApplication, testInfo: T * userData directory so state cannot leak across specs through persistence. */ export const test = base.extend({ - // Worker-scoped: read the test repo path once + // Auto: restart-only specs also read the published path without requesting an app fixture. testRepoPath: [ // oxlint-disable-next-line no-empty-pattern -- Playwright fixture callbacks require object destructuring here. async ({}, provideFixture) => { - const persistedRepoPath = existsSync(TEST_REPO_PATH_FILE) - ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() - : '' - const repoPath = isValidGitRepo(persistedRepoPath) - ? persistedRepoPath - : createSeededTestRepo() - await provideFixture(repoPath) + await provideWorkerTestRepository(provideFixture) }, - { scope: 'worker' } + { scope: 'worker', auto: true } ], // Why: Windows keeps watched worktrees locked until Electron and its @@ -374,9 +371,7 @@ export const test = base.extend({ }, seededRepoId) .catch(() => false) - // Why: parallel specs mutate real git worktrees in the shared fixture repo. - // A first scan can briefly return no rows while git holds a worktree lock, - // so poll the public fetch path until the seeded primary + secondary load. + // Wait for the public fetch path to discover both seeded worktrees. await playwrightExpect .poll( () => diff --git a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts index f01661f2de8..446f8b2c6ef 100644 --- a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts +++ b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts @@ -1,6 +1,7 @@ import type { Page } from '@stablyai/playwright-test' import type { PRComment } from '../../../src/shared/github/comment-types' import type { PRInfo } from '../../../src/shared/github/pull-request-types' +import { getDefaultSourceControlAiSettings } from '../../../src/shared/source-control-ai' export type PRCommentsSidebarSeed = { worktreeId: string @@ -46,7 +47,11 @@ export const FIXTURE_COMMENTS: PRComment[] = [ /** Seed an open PR on e2e-secondary with mixed comment triage states for sidebar tests. */ export async function seedPRCommentsSidebarFixture(page: Page): Promise { - return page.evaluate(async (fixtureComments: PRComment[]) => { + const seed = { + fixtureComments: FIXTURE_COMMENTS, + sourceControlAiDefaults: getDefaultSourceControlAiSettings() + } + return page.evaluate(async ({ fixtureComments, sourceControlAiDefaults }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -117,12 +122,13 @@ export async function seedPRCommentsSidebarFixture(page: Page): Promise { + fetchPRForBranch: async (_repoPath: string, targetBranch: string) => { if (targetBranch !== branch) { return null } @@ -137,12 +143,13 @@ export async function seedPRCommentsSidebarFixture(page: Page): Promise [], fetchPRComments: async () => comments, setPRCommentReaction: async () => true, - fetchUpstreamStatus: async () => undefined, + fetchUpstreamStatus: async (worktreeId) => + store.getState().remoteStatusesByWorktree[worktreeId] ?? null, setUpstreamStatus: () => undefined })) window.localStorage.setItem('orca:pr-comment-presentation', 'cards') return { worktreeId: worktree.id, branch, prNumber } - }, FIXTURE_COMMENTS) + }, seed) } diff --git a/tests/e2e/helpers/remote-typing-load-script.ts b/tests/e2e/helpers/remote-typing-load-script.ts new file mode 100644 index 00000000000..f2e2c296c53 --- /dev/null +++ b/tests/e2e/helpers/remote-typing-load-script.ts @@ -0,0 +1,22 @@ +export function remoteTypingLoadScript(runId: string): string { + return [ + "process.stdin.setEncoding('utf8')", + 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', + 'process.stdin.resume()', + 'const statusRow = Math.max(2, process.stdout.rows || 24)', + "process.stdout.write('\\x1b[1;' + (statusRow - 1) + 'r')", + 'let seq = 0', + 'let frame = 0', + 'let bg = null', + `process.stdout.write('REMOTE_TUI_READY_${runId}\\n')`, + "setTimeout(() => { bg = setInterval(() => { frame += 1; process.stdout.write('\\x1b[1;1HBG_' + frame + '_' + 'x'.repeat(4096) + '\\n') }, 8) }, 500)", + "process.stdin.on('data', (chunk) => {", + ' if (chunk.includes(String.fromCharCode(3))) { if (bg) clearInterval(bg); process.exit(0) }', + ' for (const char of chunk) {', + " if (char === '\\r' || char === '\\n') continue", + ' seq += 1', + ` process.stdout.write('\\x1b[' + statusRow + ';2H\\x1b[2KREMOTE_KEY_${runId}_' + seq + '_' + char)`, + ' }', + '})' + ].join(';') +} diff --git a/tests/e2e/helpers/remote-typing-load-script.unit.test.ts b/tests/e2e/helpers/remote-typing-load-script.unit.test.ts new file mode 100644 index 00000000000..2b06a5897ca --- /dev/null +++ b/tests/e2e/helpers/remote-typing-load-script.unit.test.ts @@ -0,0 +1,56 @@ +import '../../../src/main/daemon/xterm-env-polyfill' +import { EventEmitter } from 'node:events' +import { runInNewContext } from 'node:vm' +import { Terminal } from '@xterm/headless' +import { expect, it } from 'vitest' +import { remoteTypingLoadScript } from './remote-typing-load-script' + +it.each([12, 24, 40])( + 'keeps typed output visible through background pressure at %i rows', + async (rows) => { + const terminal = new Terminal({ rows, cols: 80, scrollback: 1000, allowProposedApi: true }) + const input = new EventEmitter() + const chunks: string[] = [] + let background = (): void => { + throw new Error('Background pressure did not start') + } + try { + runInNewContext(remoteTypingLoadScript('test'), { + process: { + stdin: { + isTTY: true, + setEncoding() {}, + setRawMode() {}, + resume() {}, + on: input.on.bind(input) + }, + stdout: { rows, write: (chunk: string) => chunks.push(chunk) }, + exit() {} + }, + setTimeout: (callback: () => void) => callback(), + setInterval: (callback: () => void) => { + background = callback + return 1 + }, + clearInterval() {} + }) + input.emit('data', 'ab\r\ncd') + for (let frame = 0; frame < 40; frame += 1) { + background() + } + const output = chunks.join('') + expect(output.length).toBeGreaterThan(160_000) + await new Promise((resolve) => terminal.write(output, resolve)) + const screen = Array.from( + { length: rows }, + (_, row) => + terminal.buffer.active + .getLine(terminal.buffer.active.baseY + row) + ?.translateToString(true) ?? '' + ).join('\n') + expect(screen).toContain('REMOTE_KEY_test_4_d') + } finally { + terminal.dispose() + } + } +) diff --git a/tests/e2e/helpers/runtime-types.ts b/tests/e2e/helpers/runtime-types.ts index 215e1ce5bcc..0d4b864ee64 100644 --- a/tests/e2e/helpers/runtime-types.ts +++ b/tests/e2e/helpers/runtime-types.ts @@ -1,4 +1,5 @@ import type { AppState } from '../../../src/renderer/src/store/types' +import type { PaneManager } from '../../../src/renderer/src/lib/pane-manager/pane-manager' import type { OpenFile, RightSidebarTab } from '../../../src/renderer/src/store/slices/editor' import type { ManagedPane, @@ -31,6 +32,7 @@ export type PaneManagerLike = { getPanes(limit?: number): ManagedPaneHandle[] splitPane(paneId: number, direction: 'vertical' | 'horizontal'): ManagedPaneHandle | null closePane(paneId: number): void + movePane: PaneManager['movePane'] setActivePane(paneId: number, opts?: { focus?: boolean }): void suspendRendering(): void resumeRendering(): void diff --git a/tests/e2e/helpers/seeded-test-repo.ts b/tests/e2e/helpers/seeded-test-repo.ts index 34c4f346714..866fc42dad6 100644 --- a/tests/e2e/helpers/seeded-test-repo.ts +++ b/tests/e2e/helpers/seeded-test-repo.ts @@ -3,12 +3,38 @@ * disposable test repo (plus its secondary worktree) that specs operate on. */ -import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' import { execSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import os from 'node:os' import path from 'node:path' import { TEST_REPO_PATH_FILE } from '../global-setup' +import { cleanupTestRepository } from '../global-teardown' + +export async function provideWorkerTestRepository( + provideFixture: (repository: string) => Promise +): Promise { + const persistedRepoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + : '' + const repoPath = isValidGitRepo(persistedRepoPath) ? persistedRepoPath : createSeededTestRepo() + try { + await provideFixture(repoPath) + } finally { + if (existsSync(repoPath)) { + cleanupTestRepository(repoPath) + } + rmSync(TEST_REPO_PATH_FILE, { force: true }) + } +} export function isValidGitRepo(repoPath: string): boolean { if (!repoPath || !existsSync(repoPath)) { diff --git a/tests/e2e/helpers/source-control-ai-generation.ts b/tests/e2e/helpers/source-control-ai-generation.ts index 3a488bc58db..b290cdce1e9 100644 --- a/tests/e2e/helpers/source-control-ai-generation.ts +++ b/tests/e2e/helpers/source-control-ai-generation.ts @@ -134,7 +134,8 @@ export async function seedCreatePrComposer(page: Page): Promise<{ // Ignore provider work queued before this generation-only fixture was installed. getEffectiveGitHubPRRefreshState: () => undefined, prRefreshStates: {}, - fetchUpstreamStatus: async () => undefined, + fetchUpstreamStatus: async (worktreeId) => + store.getState().remoteStatusesByWorktree[worktreeId] ?? null, setUpstreamStatus: () => undefined })) @@ -222,8 +223,8 @@ export async function seedCommitMessageComposer(page: Page): Promise<{ status: 'ready' as const } }, - gitBranchCompareEntriesByWorktree: { - ...current.gitBranchCompareEntriesByWorktree, + gitBranchChangesByWorktree: { + ...current.gitBranchChangesByWorktree, [primaryWorktree.id]: [] } })) @@ -290,8 +291,8 @@ export async function seedCleanBranchEmptyState( status: 'ready' as const } }, - gitBranchCompareEntriesByWorktree: { - ...current.gitBranchCompareEntriesByWorktree, + gitBranchChangesByWorktree: { + ...current.gitBranchChangesByWorktree, [primaryWorktree.id]: [] } })) diff --git a/tests/e2e/helpers/terminal-active-pane.ts b/tests/e2e/helpers/terminal-active-pane.ts index b5987c61f3a..08ca40331c5 100644 --- a/tests/e2e/helpers/terminal-active-pane.ts +++ b/tests/e2e/helpers/terminal-active-pane.ts @@ -126,7 +126,7 @@ export async function discoverActivePtyId(page: Page): Promise { // Echo a numeric probe index, then map it back to the opaque ID in Node. for (const [index, id] of candidateIds.entries()) { for (const input of candidateInputs[index] ?? []) { - window.api.pty.write(String(id), input) + window.api.pty.write(String(id), input, 'driving') } } }, diff --git a/tests/e2e/helpers/terminal-input-probes.ts b/tests/e2e/helpers/terminal-input-probes.ts index 9d6b45457a0..a7b3496551c 100644 --- a/tests/e2e/helpers/terminal-input-probes.ts +++ b/tests/e2e/helpers/terminal-input-probes.ts @@ -247,7 +247,7 @@ export async function mainProbeDirectWrite( const inputs = buildSettledShellProbeInputSequence(`echo ${marker}\r`) await mainRendererEval( electronApp, - `for (const input of ${JSON.stringify(inputs)}) { window.api.pty.write(${JSON.stringify(ptyId)}, input) }` + `for (const input of ${JSON.stringify(inputs)}) { window.api.pty.write(${JSON.stringify(ptyId)}, input, "driving") }` ) } catch { return false diff --git a/tests/e2e/helpers/terminal-pane-operations.ts b/tests/e2e/helpers/terminal-pane-operations.ts index d3d488f35e3..81023dce669 100644 --- a/tests/e2e/helpers/terminal-pane-operations.ts +++ b/tests/e2e/helpers/terminal-pane-operations.ts @@ -55,7 +55,7 @@ export async function moveTerminalPaneByLeafId( export async function sendToTerminal(page: Page, ptyId: string, text: string): Promise { await page.evaluate( ({ ptyId, text }) => { - window.api.pty.write(ptyId, text) + window.api.pty.write(ptyId, text, 'driving') }, { ptyId, text } ) diff --git a/tests/e2e/helpers/terminal-pty-write-spy.ts b/tests/e2e/helpers/terminal-pty-write-spy.ts index 5cb88e6868d..68295c08e61 100644 --- a/tests/e2e/helpers/terminal-pty-write-spy.ts +++ b/tests/e2e/helpers/terminal-pty-write-spy.ts @@ -71,10 +71,10 @@ export async function readTerminalPtyWriteEntries( } export async function setTerminalPtyWriteDelay( - app: ElectronApplication, + app: Pick, delayMs: number ): Promise { - await app.evaluate((nextDelayMs) => { + await app.evaluate((_electron, nextDelayMs) => { const global = globalThis as unknown as { __terminalPtyWriteDelayMs?: number } global.__terminalPtyWriteDelayMs = Math.max(0, nextDelayMs) }, delayMs) diff --git a/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts b/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts new file mode 100644 index 00000000000..c72ea255e98 --- /dev/null +++ b/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts @@ -0,0 +1,23 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { setTerminalPtyWriteDelay } from './terminal-pty-write-spy' + +declare global { + var __terminalPtyWriteDelayMs: number | undefined +} + +afterEach(() => vi.unstubAllGlobals()) + +it('sets and clears paste backpressure using the main-process evaluate argument', async () => { + vi.stubGlobal('__terminalPtyWriteDelayMs', 0) + const evaluate = vi.fn() + evaluate.mockImplementation( + (callback: (electron: unknown, argument: number) => void, delay: number) => + callback(undefined, delay) + ) + const app = { evaluate } + + await setTerminalPtyWriteDelay(app, 35) + expect(globalThis.__terminalPtyWriteDelayMs).toBe(35) + await setTerminalPtyWriteDelay(app, 0) + expect(globalThis.__terminalPtyWriteDelayMs).toBe(0) +}) diff --git a/tests/e2e/paired-client-hosted-browser-markup.spec.ts b/tests/e2e/paired-client-hosted-browser-markup.spec.ts index 43572cbff75..519d7530b71 100644 --- a/tests/e2e/paired-client-hosted-browser-markup.spec.ts +++ b/tests/e2e/paired-client-hosted-browser-markup.spec.ts @@ -35,7 +35,13 @@ test('draws and copies a screenshot from a client-hosted browser without replaci const target = { urlPrefix: fixture.origin, remotePageId: browser.remotePageId } await waitForRenderedClientWebview(client.page, target, 'client-hosted fixture never rendered') - await client.page.getByRole('button', { name: 'Got it', exact: true }).click() + // The markup hint also says "Got it" and can appear before the browser tour. + const browserTour = client.page.getByRole('dialog', { + name: 'This page renders on your desktop', + exact: true + }) + await browserTour.getByRole('button', { name: 'Got it', exact: true }).click() + await expect(browserTour).toBeHidden() await testInfo.attach('client-hosted-toolbar', { body: await client.page.screenshot({ path: testInfo.outputPath('toolbar.png') }), contentType: 'image/png' diff --git a/tests/e2e/paired-terminal-after-anchor-placement.spec.ts b/tests/e2e/paired-terminal-after-anchor-placement.spec.ts new file mode 100644 index 00000000000..8583b58c587 --- /dev/null +++ b/tests/e2e/paired-terminal-after-anchor-placement.spec.ts @@ -0,0 +1,150 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { toHostSessionTabId, toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' + +async function createTerminal(page: Page, worktreeId: string, afterTabId?: string) { + const tabs = page.locator('[data-testid="sortable-tab"]') + const before = await tabs.evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute('data-tab-id')) + ) + const outcome = await page.evaluate( + async ({ worktreeId, afterTabId }) => { + const environment = (await window.api.runtimeEnvironments.list())[0] + const bridge: unknown = + '__webRuntimeSessionE2E' in window ? window.__webRuntimeSessionE2E : undefined + if ( + !environment || + !bridge || + typeof bridge !== 'object' || + !('createTerminal' in bridge) || + typeof bridge.createTerminal !== 'function' + ) { + throw new Error('Paired terminal creation is unavailable') + } + return bridge.createTerminal({ + worktreeId, + environmentId: environment.id, + activate: false, + ...(afterTabId ? { afterTabId } : {}) + }) + }, + { worktreeId, afterTabId } + ) + expect(outcome).toEqual({ status: 'created' }) + let created: string[] = [] + await expect + .poll(async () => { + const ids = await tabs.evaluateAll((tabs) => + tabs.map((tab) => tab.getAttribute('data-tab-id')) + ) + created = ids.filter((id): id is string => id !== null && !before.includes(id)) + return created.length + }) + .toBe(1) + const parentTabId = toHostSessionTabId(created[0]) + const surfaceId = await page.evaluate( + async ({ worktreeId, parentTabId }) => { + const response = await window.api.runtime.call({ + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` } + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = response.result + if ( + !result || + typeof result !== 'object' || + !('tabs' in result) || + !Array.isArray(result.tabs) + ) { + throw new Error('Terminal inventory is missing') + } + const surface: unknown = result.tabs.find((tab) => tab.parentTabId === parentTabId) + if ( + !surface || + typeof surface !== 'object' || + !('id' in surface) || + typeof surface.id !== 'string' + ) { + throw new Error('Created terminal surface is missing') + } + return surface.id + }, + { worktreeId, parentTabId } + ) + return { id: toWebTerminalSurfaceTabId(surfaceId), parentTabId } +} + +async function tabWindow(page: Page, anchor: string) { + return page.locator('[data-testid="sortable-tab"]').evaluateAll((tabs, anchor) => { + const ids = tabs.map((tab) => tab.getAttribute('data-tab-id')) + const index = ids.indexOf(anchor) + return index === -1 ? [] : ids.slice(index, index + 3) + }, anchor) +} + +test('paired web creation places a terminal after its anchor on both host and client', async ({ + electronApp, + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedWebClient(electronApp, offer) + try { + const worktreeId = await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId) + if (!worktreeId) { + throw new Error('Paired worktree is missing') + } + await client.page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) + await expect( + client.page.locator('[data-testid="sortable-tab"][data-active="true"]') + ).toBeVisible() + const first = await createTerminal(client.page, worktreeId) + const second = await createTerminal(client.page, worktreeId) + const inserted = await createTerminal(client.page, worktreeId, first.id) + const labels = [first, second, inserted].map((tab, index) => ({ + id: tab.parentTabId, + webId: toWebTerminalSurfaceTabId(tab.parentTabId), + title: ['Anchor A', 'Successor B', 'Inserted C'][index] + })) + for (const [page, web] of [ + [orcaPage, false], + [client.page, true] + ] as const) { + await page.evaluate( + ({ labels, web }) => { + for (const { id, webId, title } of labels) { + window.__store?.getState().setTabCustomTitle(web ? webId : id, title) + } + }, + { labels, web } + ) + } + await expect + .poll(() => tabWindow(orcaPage, first.parentTabId), { timeout: 15_000 }) + .toEqual([first.parentTabId, inserted.parentTabId, second.parentTabId]) + const clientIds = [first, inserted, second].map((tab) => + toWebTerminalSurfaceTabId(tab.parentTabId) + ) + await expect + .poll(() => tabWindow(client.page, clientIds[0]), { timeout: 15_000 }) + .toEqual(clientIds) + } finally { + await testInfo.attach('host-tab-placement', { + body: await orcaPage.screenshot({ path: testInfo.outputPath('host-tab-placement.png') }), + contentType: 'image/png' + }) + await testInfo.attach('client-tab-placement', { + body: await client.page.screenshot({ path: testInfo.outputPath('client-tab-placement.png') }), + contentType: 'image/png' + }) + await client.dispose() + } +}) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index fe39cfef709..2a1e00ba265 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -1,3 +1,5 @@ +import { remoteTypingLoadScript } from './helpers/remote-typing-load-script' +import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { @@ -55,27 +57,6 @@ function encodedRemoteNodeCommand(script: string): string { return `node -e ${shellQuote(`eval(Buffer.from('${encoded}', 'base64').toString('utf8'))`)}` } -function remoteTypingLoadScript(runId: string): string { - return [ - "process.stdin.setEncoding('utf8')", - 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', - 'process.stdin.resume()', - 'let seq = 0', - 'let frame = 0', - 'let bg = null', - `process.stdout.write('REMOTE_TUI_READY_${runId}\\n')`, - "setTimeout(() => { bg = setInterval(() => { frame += 1; process.stdout.write('BG_' + frame + '_' + 'x'.repeat(4096) + '\\n') }, 8) }, 500)", - "process.stdin.on('data', (chunk) => {", - ' if (chunk.includes(String.fromCharCode(3))) { if (bg) clearInterval(bg); process.exit(0) }', - ' for (const char of chunk) {', - " if (char === '\\r' || char === '\\n') continue", - ' seq += 1', - ` process.stdout.write('\\x1b[20;2HREMOTE_KEY_${runId}_' + seq + '_' + char + '\\n')`, - ' }', - '})' - ].join(';') -} - function remoteBackgroundFloodScript(runId: string): string { return [ "process.stdin.setEncoding('utf8')", @@ -107,7 +88,10 @@ async function measureRemoteTyping( const char = KEY_LATENCY_SAMPLES[index] const marker = `REMOTE_KEY_${runId}_${index + 1}_${char}` const started = performance.now() - await page.evaluate(({ ptyId, char }) => window.api.pty.write(ptyId, char), { ptyId, char }) + await page.evaluate(({ ptyId, char }) => window.api.pty.write(ptyId, char, 'driving'), { + ptyId, + char + }) await waitForTerminalOutput(page, marker, 10_000, 80_000) latencies.push(performance.now() - started) } @@ -141,7 +125,7 @@ async function readSshPtyAckGate(page: Page): Promise { - await page.evaluate((targetPtyId) => window.api.pty.write(targetPtyId, '\x03'), ptyId) + await page.evaluate((targetPtyId) => window.api.pty.write(targetPtyId, '\x03', 'driving'), ptyId) } test.describe('Docker SSH relay perf', () => { @@ -163,7 +147,7 @@ test.describe('Docker SSH relay perf', () => { const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) const runId = String(Date.now()) - await execInTerminal(orcaPage, ptyId, `node -e ${shellQuote(remoteTypingLoadScript(runId))}`) + await execInTerminal(orcaPage, ptyId, encodedRemoteNodeCommand(remoteTypingLoadScript(runId))) await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${runId}`, 30_000, 80_000) const measurement = await measureRemoteTyping(orcaPage, ptyId, runId) const summary = `median=${measurement.medianLatencyMs.toFixed( @@ -204,11 +188,14 @@ test.describe('Docker SSH relay perf', () => { await execInTerminal( orcaPage, backgroundPtyId, - `node -e ${shellQuote(remoteBackgroundFloodScript(runId))}` + encodedRemoteNodeCommand(remoteBackgroundFloodScript(runId)) ) await waitForTerminalOutput(orcaPage, `REMOTE_ACK_FLOOD_READY_${runId}`, 30_000, 80_000) await holdSshPtyAckGate(orcaPage, [backgroundPtyId]) - await orcaPage.evaluate((ptyId) => window.api.pty.write(ptyId, 'g'), backgroundPtyId) + await orcaPage.evaluate( + (ptyId) => window.api.pty.write(ptyId, 'g', 'driving'), + backgroundPtyId + ) await splitActiveTerminalPane(orcaPage, 'vertical') await focusLastTerminalPane(orcaPage) @@ -219,7 +206,7 @@ test.describe('Docker SSH relay perf', () => { await execInTerminal( orcaPage, activePtyId, - `node -e ${shellQuote(remoteTypingLoadScript(activeRunId))}` + encodedRemoteNodeCommand(remoteTypingLoadScript(activeRunId)) ) await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${activeRunId}`, 30_000, 80_000) const heldAckPressure = expect.poll( @@ -287,11 +274,11 @@ test.describe('Docker SSH relay perf', () => { orcaPage, ptyId, `dd if=/dev/urandom of=${shellQuote(loadFile)} bs=1M count=8 status=none && ` + - `echo LOAD_FILES_READY_${runId}` + `echo LOAD_FILES_READY_'${runId}'` ) await waitForTerminalOutput(orcaPage, `LOAD_FILES_READY_${runId}`, 60_000, 80_000) - await execInTerminal(orcaPage, ptyId, `node -e ${shellQuote(remoteTypingLoadScript(runId))}`) + await execInTerminal(orcaPage, ptyId, encodedRemoteNodeCommand(remoteTypingLoadScript(runId))) await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${runId}`, 30_000, 80_000) // Background relay pressure: continuous large file reads plus git status diff --git a/tests/e2e/terminal-option-composed-ascii.spec.ts b/tests/e2e/terminal-option-composed-ascii.spec.ts index 8aec5f4471c..a060d655900 100644 --- a/tests/e2e/terminal-option-composed-ascii.spec.ts +++ b/tests/e2e/terminal-option-composed-ascii.spec.ts @@ -1,161 +1,232 @@ // Option composition must survive kitty negotiation (#14024, #20171, #20850). import { test, expect } from './helpers/orca-app' -import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { focusActiveTerminalInput, waitForTerminalOutput } from './helpers/terminal' +import { clearTerminalPtyWriteLog as clearPtyWriteLog } from './helpers/terminal-pty-write-spy' import { - execInTerminal, - waitForTerminalOutput, - waitForActiveTerminalManager, - waitForActivePanePtyId -} from './helpers/terminal' -import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' -import { - clearTerminalPtyWriteLog as clearPtyWriteLog, - installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, - readTerminalPtyWrites as getPtyWrites -} from './helpers/terminal-pty-write-spy' - -type MacOptionAsAltSetting = 'auto' | 'true' | 'false' | 'left' | 'right' - -async function setMacOptionAsAlt(page: Page, value: MacOptionAsAltSetting): Promise { - await page.evaluate(async (value) => { - await window.__store?.getState().updateSettings({ terminalMacOptionAsAlt: value }) - }, value) - await expect - .poll( - async () => - page.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt ?? null), - { timeout: 5_000, message: 'terminalMacOptionAsAlt did not apply' } - ) - .toBe(value) -} - -/** Reads the pane's mirrored kitty flags — the exact value the policy consults. */ -async function getPaneKittyKeyboardFlags(page: Page): Promise { - return page.evaluate(() => { - const state = window.__store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const terminal = pane?.terminal as - | { - core?: { coreService?: { kittyKeyboard?: { flags?: number } } } - _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } - } - | undefined - return ( - terminal?.core?.coreService?.kittyKeyboard?.flags ?? - terminal?._core?.coreService?.kittyKeyboard?.flags ?? - 0 - ) - }) -} - -/** - * Dispatches the keydown macOS delivers for an Option-composed key: `key` is - * already the composed glyph while `code` still names the physical key. - */ -async function pressOptionComposedKey( - page: Page, - press: { key: string; code: string; shiftKey?: boolean } -): Promise<{ keydownDefaultPrevented: boolean }> { - return page.evaluate((press) => { - const state = window.__store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const textarea = pane?.container.querySelector( - '.xterm-helper-textarea' - ) as HTMLTextAreaElement | null - if (!pane || !textarea) { - throw new Error('No active terminal textarea for the Option chord dispatch') - } - pane.terminal.focus() - textarea.focus() - - // Why: the policy resolves left-vs-right Option from the modifier's own - // keydown, so the chord has to be preceded by a real AltLeft press. - const modifierInit = { key: 'Alt', code: 'AltLeft', altKey: true, bubbles: true } - const altDown = new KeyboardEvent('keydown', modifierInit) - Object.defineProperty(altDown, 'location', { get: () => 1 }) - textarea.dispatchEvent(altDown) - - const keydown = new KeyboardEvent('keydown', { - key: press.key, - code: press.code, - altKey: true, - shiftKey: press.shiftKey === true, - bubbles: true, - cancelable: true - }) - textarea.dispatchEvent(keydown) - - textarea.dispatchEvent( - new KeyboardEvent('keyup', { - key: press.key, - code: press.code, - altKey: true, - shiftKey: press.shiftKey === true, - bubbles: true, - cancelable: true - }) - ) - const altUp = new KeyboardEvent('keyup', modifierInit) - Object.defineProperty(altUp, 'location', { get: () => 1 }) - textarea.dispatchEvent(altUp) - - return { keydownDefaultPrevented: keydown.defaultPrevented } - }, press) -} - -async function armKittyKeyboardFromPty(page: Page, ptyId: string, flags: number): Promise { - // Why: this is the byte a real kitty-protocol TUI pushes at startup; routing it - // through the PTY exercises the same output-scanning mirror the policy reads. - // `cat` stays in the foreground: flags left armed at exit are grounded by the host. - await execInTerminal(page, ptyId, `printf '\\033[>${flags}u'; cat`) - await expect - .poll(async () => getPaneKittyKeyboardFlags(page), { - timeout: 15_000, - message: 'the pane never mirrored the application kitty keyboard flags' - }) - .toBe(flags) -} - -async function setUpPane( - page: Page, - app: ElectronApplication, - kittyFlags = 1 -): Promise<{ joinedWrites: () => Promise }> { - await waitForSessionReady(page) - await waitForActiveWorktree(page) - await ensureTerminalVisible(page) - await waitForActiveTerminalManager(page) - const ptyId = await waitForActivePanePtyId(page) - await installMainProcessPtyWriteSpy(app) - await armKittyKeyboardFromPty(page, ptyId, kittyFlags) - return { joinedWrites: async () => (await getPtyWrites(app)).join('') } -} + setMacOptionAsAlt, + pressOptionComposedKey, + setUpOptionKeyboardPane, + publishMacKeyboardLayout, + waitForPaneOptionAsAlt, + pressChromiumOptionPunctuation +} from './terminal-option-key-input' test.describe('Option-composed text in a kitty-keyboard pane', () => { test.skip(process.platform !== 'darwin', 'Option composition is a macOS-only input path (#14024)') + test('the settings control enables punctuation shortcuts and keeps the other Option side as text', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 7) + await orcaPage.evaluate(async () => { + const state = window.__store?.getState() + await state?.updateSettings({ uiLanguage: 'en' }) + state?.openSettingsTarget({ pane: 'terminal', repoId: null }) + state?.openSettingsPage() + state?.setSettingsSearchQuery('Option as Alt') + }) + const control = orcaPage.getByRole('radiogroup', { name: 'Option as Alt', exact: true }) + await expect( + orcaPage.getByText(/Choose Both for Option shortcuts, Off for accents and symbols/) + ).toBeVisible() + const keys = [ + { key: '…', code: 'Semicolon', codePoint: 59 }, + { key: '≥', code: 'Period', codePoint: 46 }, + { key: '≤', code: 'Comma', codePoint: 44 } + ] + for (const [label, setting] of [ + ['Both', 'true'], + ['Left', 'left'], + ['Right', 'right'] + ] as const) { + await control.getByRole('radio', { name: label, exact: true }).click() + await expect + .poll(() => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt) + ) + .toBe(setting) + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + for (const side of ['left', 'right'] as const) { + await clearPtyWriteLog(electronApp) + for (const key of keys) { + await pressOptionComposedKey(orcaPage, { ...key, side }) + } + const isAlt = setting === 'true' || setting === side + const expected = keys + .map( + ({ key, codePoint }) => `${isAlt ? `\x1b[${codePoint};3u` : key}\x1b[${codePoint};3:3u` + ) + .join('') + await expect.poll(joinedWrites).toBe(expected) + } + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsPage() + state?.setSettingsSearchQuery('Option as Alt') + }) + } + }) + + test('Chromium Option punctuation produces text in compose mode and shortcuts in Both mode', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 7) + const cdp = await orcaPage.context().newCDPSession(orcaPage) + try { + for (const setting of ['false', 'true'] as const) { + await setMacOptionAsAlt(orcaPage, setting) + await clearPtyWriteLog(electronApp) + for (const key of [ + { key: '…', code: 'Semicolon', base: ';', codePoint: 59, windowsVirtualKeyCode: 186 }, + { key: '≥', code: 'Period', base: '.', codePoint: 46, windowsVirtualKeyCode: 190 }, + { key: '≤', code: 'Comma', base: ',', codePoint: 44, windowsVirtualKeyCode: 188 } + ]) { + await pressChromiumOptionPunctuation(cdp, key) + } + const expected = [ + ['…', 59], + ['≥', 46], + ['≤', 44] + ] + .map( + ([key, codePoint]) => + `${setting === 'true' ? `\x1b[${codePoint};3u` : key}\x1b[${codePoint};3:3u` + ) + .join('') + await expect.poll(joinedWrites).toBe(expected) + if (setting === 'false') { + for (const glyph of ['…', '≥', '≤']) { + await waitForTerminalOutput(orcaPage, glyph) + } + } + } + } finally { + await cdp.detach() + } + }) + + test('Auto follows ABC and composing input sources without replacing explicit Option settings', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 7) + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ uiLanguage: 'en' }) + }) + await setMacOptionAsAlt(orcaPage, 'auto') + const abc = 'com.apple.keylayout.ABC' + const international = 'com.apple.keylayout.USInternational-PC' + const pinyin = 'com.apple.inputmethod.SCIM.ITABC' + const keyCharacters = { + Semicolon: { unmodified: ';', shifted: ':' }, + Period: { unmodified: '.', shifted: '>' }, + Comma: { unmodified: ',', shifted: '<' } + } + const period = { key: '≥', code: 'Period', base: '.', windowsVirtualKeyCode: 190 } + let generation = Date.now() + const cdp = await orcaPage.context().newCDPSession(orcaPage) + try { + for (const inputSourceId of [abc, international, abc, pinyin, abc, null, abc]) { + await publishMacKeyboardLayout( + electronApp, + inputSourceId + ? { + inputSourceId, + layoutSourceId: inputSourceId === pinyin ? abc : inputSourceId, + keyCharacters + } + : null, + ++generation + ) + const isAlt = inputSourceId === abc + await waitForPaneOptionAsAlt(orcaPage, isAlt) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.openSettingsTarget({ pane: 'terminal', repoId: null }) + state?.openSettingsPage() + state?.setSettingsSearchQuery('Option as Alt') + }) + const control = orcaPage.getByRole('radiogroup', { name: 'Option as Alt', exact: true }) + await expect(control.getByRole('radio', { name: 'Auto', exact: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + await expect( + orcaPage.getByText( + isAlt + ? /Auto — detected: ABC or U.S. — Option sends Alt\/Esc sequences/ + : inputSourceId + ? /Auto — detected: layout uses Option to compose characters/ + : /Auto — detected: unknown layout — Option composes characters/ + ) + ).toBeVisible() + await orcaPage.evaluate(() => window.__store?.getState().closeSettingsPage()) + await focusActiveTerminalInput(orcaPage) + await clearPtyWriteLog(electronApp) + await pressChromiumOptionPunctuation(cdp, period) + await expect.poll(joinedWrites).toBe(`${isAlt ? '\x1b[46;3u' : '≥'}\x1b[46;3:3u`) + await expect + .poll(() => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt) + ) + .toBe('auto') + } + await setMacOptionAsAlt(orcaPage, 'false') + await waitForPaneOptionAsAlt(orcaPage, false) + await clearPtyWriteLog(electronApp) + await pressChromiumOptionPunctuation(cdp, period) + await expect.poll(joinedWrites).toBe('≥\x1b[46;3:3u') + await setMacOptionAsAlt(orcaPage, 'left') + for (const inputSourceId of [international, abc]) { + await publishMacKeyboardLayout( + electronApp, + { inputSourceId, layoutSourceId: inputSourceId, keyCharacters }, + ++generation + ) + for (const side of ['left', 'right'] as const) { + await clearPtyWriteLog(electronApp) + await pressOptionComposedKey(orcaPage, { ...period, side }) + await expect + .poll(joinedWrites) + .toBe(`${side === 'left' ? '\x1b[46;3u' : '≥'}\x1b[46;3:3u`) + } + expect( + await orcaPage.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt) + ).toBe('left') + } + } finally { + await cdp.detach() + } + }) + + test('configured Option punctuation keeps legacy Alt bytes without enhanced reporting', async ({ + orcaPage, + electronApp + }) => { + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 0) + for (const setting of ['true', 'left', 'right'] as const) { + await setMacOptionAsAlt(orcaPage, setting) + await clearPtyWriteLog(electronApp) + const side = setting === 'right' ? 'right' : 'left' + for (const key of [ + { key: '…', code: 'Semicolon' }, + { key: '≥', code: 'Period' }, + { key: '≤', code: 'Comma' } + ]) { + await pressOptionComposedKey(orcaPage, { ...key, side }) + } + await expect.poll(joinedWrites).toBe('\x1b;\x1b.\x1b,') + } + }) + test('types the composed character instead of reporting the physical Alt chord', async ({ orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) @@ -174,7 +245,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { }) test('types a composed character that also needs Shift', async ({ orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) @@ -200,7 +271,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'true') await clearPtyWriteLog(electronApp) @@ -220,7 +291,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp) await setMacOptionAsAlt(orcaPage, 'true') await clearPtyWriteLog(electronApp) @@ -241,7 +312,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp, 5) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 5) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) const letters = [ @@ -278,7 +349,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp, 29) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 29) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) await pressOptionComposedKey(orcaPage, { key: 'ą', code: 'KeyA' }) @@ -289,7 +360,7 @@ test.describe('Option-composed text in a kitty-keyboard pane', () => { orcaPage, electronApp }, testInfo) => { - const { joinedWrites } = await setUpPane(orcaPage, electronApp, 5) + const { joinedWrites } = await setUpOptionKeyboardPane(orcaPage, electronApp, 5) await setMacOptionAsAlt(orcaPage, 'false') await clearPtyWriteLog(electronApp) const cdp = await orcaPage.context().newCDPSession(orcaPage) diff --git a/tests/e2e/terminal-option-key-input.ts b/tests/e2e/terminal-option-key-input.ts new file mode 100644 index 00000000000..a56cfe0065f --- /dev/null +++ b/tests/e2e/terminal-option-key-input.ts @@ -0,0 +1,210 @@ +import type { CDPSession, ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' +import { + execInTerminal, + waitForActiveTerminalManager, + waitForActivePanePtyId +} from './helpers/terminal' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { + installTerminalPtyWriteSpy as installMainProcessPtyWriteSpy, + readTerminalPtyWrites as getPtyWrites +} from './helpers/terminal-pty-write-spy' +import type { KeyboardLayoutSnapshot } from '../../src/shared/keyboard-layout-snapshot' + +type MacOptionAsAltSetting = 'auto' | 'true' | 'false' | 'left' | 'right' + +export async function setMacOptionAsAlt(page: Page, value: MacOptionAsAltSetting): Promise { + await page.evaluate(async (value) => { + await window.__store?.getState().updateSettings({ terminalMacOptionAsAlt: value }) + }, value) + await expect + .poll( + async () => + page.evaluate(() => window.__store?.getState().settings?.terminalMacOptionAsAlt ?? null), + { timeout: 5_000, message: 'terminalMacOptionAsAlt did not apply' } + ) + .toBe(value) +} + +/** Reads the pane's mirrored kitty flags — the exact value the policy consults. */ +async function getPaneKittyKeyboardFlags(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm exposes negotiated flags through either bundled core shape. + const terminal = pane?.terminal as + | { + core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + } + | undefined + return ( + terminal?.core?.coreService?.kittyKeyboard?.flags ?? + terminal?._core?.coreService?.kittyKeyboard?.flags ?? + 0 + ) + }) +} + +/** + * Dispatches the keydown macOS delivers for an Option-composed key: `key` is + * already the composed glyph while `code` still names the physical key. + */ +export async function pressOptionComposedKey( + page: Page, + press: { key: string; code: string; shiftKey?: boolean; side?: 'left' | 'right' } +): Promise<{ keydownDefaultPrevented: boolean }> { + return page.evaluate((press) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const textarea = pane?.container.querySelector('.xterm-helper-textarea') + if (!pane || !textarea) { + throw new Error('No active terminal textarea for the Option chord dispatch') + } + pane.terminal.focus() + textarea.focus() + + // The side-specific setting reads the modifier's location before the chord. + const location = press.side === 'right' ? 2 : 1 + const modifierInit = { + key: 'Alt', + code: location === 2 ? 'AltRight' : 'AltLeft', + altKey: true, + bubbles: true + } + const altDown = new KeyboardEvent('keydown', modifierInit) + Object.defineProperty(altDown, 'location', { get: () => location }) + textarea.dispatchEvent(altDown) + + const keydown = new KeyboardEvent('keydown', { + key: press.key, + code: press.code, + altKey: true, + shiftKey: press.shiftKey === true, + bubbles: true, + cancelable: true + }) + const keyCodes: Record = { Semicolon: 186, Comma: 188, Period: 190 } + const keyCode = keyCodes[press.code] + if (keyCode) { + Object.defineProperty(keydown, 'keyCode', { value: keyCode }) + } + textarea.dispatchEvent(keydown) + + textarea.dispatchEvent( + new KeyboardEvent('keyup', { + key: press.key, + code: press.code, + altKey: true, + shiftKey: press.shiftKey === true, + bubbles: true, + cancelable: true + }) + ) + const altUp = new KeyboardEvent('keyup', modifierInit) + Object.defineProperty(altUp, 'location', { get: () => location }) + textarea.dispatchEvent(altUp) + + return { keydownDefaultPrevented: keydown.defaultPrevented } + }, press) +} + +async function armKittyKeyboardFromPty(page: Page, ptyId: string, flags: number): Promise { + // Why: this is the byte a real kitty-protocol TUI pushes at startup; routing it + // through the PTY exercises the same output-scanning mirror the policy reads. + // `cat` stays in the foreground: flags left armed at exit are grounded by the host. + await execInTerminal(page, ptyId, `printf '\\033[>${flags}u'; cat`) + await expect + .poll(async () => getPaneKittyKeyboardFlags(page), { + timeout: 15_000, + message: 'the pane never mirrored the application kitty keyboard flags' + }) + .toBe(flags) +} + +export async function setUpOptionKeyboardPane( + page: Page, + app: ElectronApplication, + kittyFlags = 1 +): Promise<{ joinedWrites: () => Promise }> { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page) + const ptyId = await waitForActivePanePtyId(page) + await installMainProcessPtyWriteSpy(app) + await armKittyKeyboardFromPty(page, ptyId, kittyFlags) + return { joinedWrites: async () => (await getPtyWrites(app)).join('') } +} + +export async function publishMacKeyboardLayout( + app: ElectronApplication, + snapshot: KeyboardLayoutSnapshot | null, + generation: number +): Promise { + await app.evaluate( + ({ ipcMain, BrowserWindow }, { snapshot, generation }) => { + ipcMain.removeHandler('app:getKeyboardLayoutSnapshot') + ipcMain.handle('app:getKeyboardLayoutSnapshot', () => snapshot) + ipcMain.removeHandler('app:getKeyboardInputSourceId') + ipcMain.handle('app:getKeyboardInputSourceId', () => snapshot?.inputSourceId ?? null) + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('app:keyboardLayoutChanged', { phase: 'refresh', generation }) + } + }, + { snapshot, generation } + ) +} + +export async function waitForPaneOptionAsAlt(page: Page, expected: boolean): Promise { + await expect + .poll(() => + page.evaluate(() => { + const state = window.__store?.getState() + const tabId = state?.activeTabId + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.terminal.options.macOptionIsMeta + }) + ) + .toBe(expected) +} + +export async function pressChromiumOptionPunctuation( + cdp: CDPSession, + key: { key: string; code: string; base: string; windowsVirtualKeyCode: number } +): Promise { + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: key.key, + code: key.code, + modifiers: 1, + text: key.key, + unmodifiedText: key.base, + windowsVirtualKeyCode: key.windowsVirtualKeyCode + }) + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: key.key, + code: key.code, + modifiers: 1, + windowsVirtualKeyCode: key.windowsVirtualKeyCode + }) +} diff --git a/tests/e2e/terminal-output-scheduler.spec.ts b/tests/e2e/terminal-output-scheduler.spec.ts index dc7b5525883..031658ea0dc 100644 --- a/tests/e2e/terminal-output-scheduler.spec.ts +++ b/tests/e2e/terminal-output-scheduler.spec.ts @@ -159,7 +159,7 @@ async function sendPtyCommands( ): Promise { await page.evaluate((items) => { for (const item of items) { - window.api.pty.write(item.ptyId, `${item.command}\r`) + window.api.pty.write(item.ptyId, `${item.command}\r`, 'driving') } }, commands) } diff --git a/tests/e2e/worktree-lineage-agent-expansion.spec.ts b/tests/e2e/worktree-lineage-agent-expansion.spec.ts index 6662d7eafe0..d8388bb300e 100644 --- a/tests/e2e/worktree-lineage-agent-expansion.spec.ts +++ b/tests/e2e/worktree-lineage-agent-expansion.spec.ts @@ -29,7 +29,7 @@ async function seedTwoParentAgents(page: Page, worktreeId: string): Promise