Merge remote-tracking branch 'origin/main' into brennanb2025/agent-launch-host-prompt-delivery

This commit is contained in:
Brennan Benson
2026-10-02 02:37:07 -07:00
619 changed files with 13875 additions and 35842 deletions
+21 -7
View File
@@ -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/
+45 -10
View File
@@ -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' &&
+4 -1
View File
@@ -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)
@@ -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.
@@ -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))
}
}
)
+49 -25
View File
@@ -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))
}
}
@@ -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 })
})
@@ -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'
})
})
})
@@ -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
@@ -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],
+36
View File
@@ -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 `<Agent> 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.
+39 -2
View File
@@ -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 |
+14
View File
@@ -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.
-41
View File
@@ -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.<sha256 of the instance lock nonce>`. 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 '<request JSON>'` 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
`<data-root>/orcad-stop-receipts/<transactionId>.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 '<request JSON>'` withdraws a request orcad has not acted on.
orcad and the canceller each try to create `<transactionId>.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.** `<data-root>/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 |
@@ -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)
})
})
@@ -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)
})
})
@@ -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<typeof WorktreeLogic>()).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<void> {
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()
})
@@ -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'
})
@@ -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)
})
@@ -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<typeof sample>,
second = OOM,
gapMs = RELOAD_OOM - FIRST_OOM,
platform: NodeJS.Platform = 'win32',
readGoneTime: () => Record<string, number> = 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()
})
})
@@ -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<string, CrashReportDetailValue>
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
}
}
}
}
-13
View File
@@ -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
}
}
+1 -53
View File
@@ -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<void>((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,
+10 -67
View File
@@ -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<DaemonHealthCheck, 'runtimeKind' | 'runtimeVersion'> {
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<DaemonHealthCheck> {
export function checkDaemonHealth(socketPath: string, tokenPath: string): Promise<DaemonHealth> {
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<string, unknown>
} 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<DaemonHealth> {
return (await checkDaemonHealthWithCoverage(socketPath, tokenPath)).verdict
}
export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise<boolean> {
return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy'
}
@@ -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)
-3
View File
@@ -8,9 +8,6 @@ export {
getDaemonEndpointFacts,
getDaemonProvider,
listLiveDaemonPtyIds,
listLiveDaemonSessions,
requestIdleDaemonRetirement,
releaseDaemonRetirementFence,
readDaemonPidRecord,
replaceDaemonProvider,
shutdownDaemon,
@@ -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 }
])
})
+18 -79
View File
@@ -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<MacDaem
)
}
// 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)
}
// 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<void> {
await adapter?.disconnectOnly()
adapter = null
}
/** Kill the daemon and all its sessions. Use for full cleanup only. */
export async function shutdownDaemon(): Promise<void> {
adapter?.dispose()
adapter = null
await spawner?.shutdown()
spawner = null
}
/** Returns null unless every daemon generation supplied an authoritative inventory. */
export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
if (!adapter) {
@@ -145,54 +115,23 @@ export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
)
}
/** Returns null unless every daemon generation supplied an authoritative session inventory. */
export async function listLiveDaemonSessions(): Promise<SessionInfo[] | null> {
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<DaemonSessionInfo[] | null> {
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<void> {
await adapter?.disconnectOnly()
adapter = null
}
/** Atomically fence new daemon terminals and retire only an idle, single-generation daemon. */
export async function requestIdleDaemonRetirement(): Promise<DaemonIdleRetirementResult> {
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<void> {
adapter?.dispose()
adapter = null
await spawner?.shutdown()
spawner = null
}
@@ -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<DaemonIdleRetirementResult> {
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<DaemonIdleRetirementResult> {
try {
await this.client.ensureConnected()
const result = await this.client.request<ShutdownIfIdleResult>('shutdownIfIdle', undefined)
if (result.retiring) {
this.idleRetirementState = 'retiring'
return { state: 'retiring' }
}
let liveSessions: number | null = null
try {
const inventory = await this.client.request<ListSessionsResult>('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<void> {
@@ -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 {
@@ -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<PtySpawnResult> => {
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
}
+159 -81
View File
@@ -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<PtySpawnResult> => {
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',
+41 -34
View File
@@ -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<string, DaemonPtyAdapter>()
private readonly ownerResolver: DaemonSessionOwnerResolver<DaemonPtyAdapter>
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<PtySpawnResult> {
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<DaemonIdleRetirementResult> {
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<string>
): Promise<{ alive: string[]; killed: string[] }> {
return reconcileDaemonRouterSessions(this.allAdapters(), this.ownerResolver, validWorktreeIds)
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
alive: string[]
killed: string[]
}> {
const alive: string[] = []
const killed: string[] = []
const aliveProviders = new Map<string, Set<DaemonPtyAdapter>>()
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<DaemonPtyAdapter>()
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 {
+1 -13
View File
@@ -57,7 +57,6 @@ export type DaemonPtyAdapterOptions = {
historyPath?: string
runtimeDir?: string
packagedAppVersion?: string | null
recoveryOnly?: boolean
respawn?: (reason: DaemonRespawnReason) => Promise<void | (() => 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<DaemonIdleRetirementResult> | null = null
protected respawnPromise: Promise<void> | null = null
protected staleBundleReplacementPromise: Promise<void> | null = null
protected writeRecoveryPromise: Promise<void> | 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
@@ -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<PtySpawnResult> {
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<PtySpawnResult> {
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
@@ -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<typeof createMockSubprocess>
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()
}
})
@@ -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()
}
@@ -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')
})
@@ -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 })
}
@@ -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')
}
}
+1 -2
View File
@@ -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)
}
@@ -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)
})
@@ -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<DaemonIdleRetirementResult> | 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<DaemonIdleRetirementResult> {
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<DaemonIdleRetirementResult> {
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' }
}
}
@@ -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<DaemonPtyAdapter>,
validWorktreeIds: Set<string>
): Promise<{ alive: string[]; killed: string[] }> {
const alive: string[] = []
const killed: string[] = []
const aliveProviders = new Map<string, Set<DaemonPtyAdapter>>()
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<DaemonPtyAdapter>()
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 }
}
@@ -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 {
@@ -246,8 +246,7 @@ export class DaemonStreamDataBatcher {
this.maxLineBytes,
sliceSequenceChars,
entry.seq,
entry.transformed,
entry.incarnationId
entry.transformed
)
}
this.updateBackpressure(clientId, batch)
+3 -8
View File
@@ -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
)
}
}
@@ -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<Socket, 'write'> = {
@@ -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 = [
'',
+14 -44
View File
@@ -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))
}
}
-1
View File
@@ -9,7 +9,6 @@ export type DataEvent = {
sessionId: string
payload: {
data: string
incarnationId?: PtyIncarnationId
seq?: number
rawLength?: number
transformed?: boolean
@@ -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()
})
})
@@ -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
}
+16 -25
View File
@@ -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', {
@@ -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({
+5 -30
View File
@@ -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)
}
+12 -8
View File
@@ -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
}
@@ -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
@@ -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')
+29 -5
View File
@@ -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<void> {
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
@@ -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<string, unknown> = {}
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)
})
})
+122
View File
@@ -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<string, { stamp: string; writtenAt: number }>({
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<PackIndexMaintenanceOutcome> {
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')
}
}
@@ -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<string> {
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 })
})
})
+94
View File
@@ -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<string | undefined> {
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<boolean> {
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) }
}
@@ -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<string[]> {
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()
@@ -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<GitExec>())
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
vi.mock('./worktree-preparation-lock', async (importOriginal) => ({
...(await importOriginal<typeof WorktreePreparationLock>()),
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' })
@@ -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)
})
@@ -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<void>((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<void>((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<void>((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)
@@ -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)
+83 -111
View File
@@ -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<void> {
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<void>
): Promise<void> {
// 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<void> {
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<void> {
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<void> {
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<AddWorktreeResult> {
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<void> =>
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
}
@@ -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<typeof WorktreePreparationLock>()),
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)
@@ -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<typeof FilePromises>()),
lstat: mocks.lstat
}))
vi.mock('./worktree-preparation-lock', async (importOriginal) => ({
...(await importOriginal<typeof PreparationLock>()),
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)
})
+119
View File
@@ -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<boolean> {
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<string> {
const add = async (lockArgs: string[]): Promise<void> => {
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
)
}
)
}
@@ -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<typeof WorktreePreparationLock>()),
verifyWorktreePreparationLock: vi.fn(async () => '/owned-lock'),
verifyWorktreePreparationLockAtPath: vi.fn(),
unlockWorktreePreparationAtPath: vi.fn()
}))
vi.mock('./worktree-base-refresh', async (importOriginal) => ({
...(await importOriginal<typeof WorktreeBaseRefresh>()),
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),
@@ -43,16 +43,28 @@ function nextPreparedPath(label: string): string {
return join(preparationRoot, `${process.pid}-${label}-${sequence}`)
}
const lockReasons = new Map<string, string>()
function checkout(preparedPath: string, signal?: AbortSignal): Promise<void> {
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<void> {
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<Sample> {
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<Sample> {
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) {
@@ -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<void> {
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<void> {
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(() => {})
}
}
@@ -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<string> {
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)
})
@@ -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<string> {
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()
}
})
@@ -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<typeof FilePromises>()),
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'
)
})
})
+130
View File
@@ -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<string> {
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<string> {
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<void> {
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<void> {
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<string> {
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<void> {
const lockReason = await readFile(lockPath, 'utf8').catch((error: unknown) => {
throw new WorktreePreparationLockOwnershipError(error)
})
if (lockReason !== `${expectedLockReason}\n`) {
throw new WorktreePreparationLockOwnershipError()
}
signal?.throwIfAborted()
}
@@ -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<string> {
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<string> {
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<void>((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)
}
})
@@ -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<unknown>) => 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<unknown>) => 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()
})
})
@@ -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<void> {
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)
}
})
}
+75 -70
View File
@@ -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 <key> 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 <key> 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')
})
+37 -27
View File
@@ -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<string, unknown>
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<string | null> {
async function readSelectedKeyboardInputSource(): Promise<SelectedKeyboardInputSource | null> {
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<string> {
return readCommandStdout(
'/usr/bin/defaults',
['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'],
'Keyboard layout input source probe timed out'
)
}
async function readKeyboardInputSourceId(): Promise<string | null> {
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<boolean> => 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<string | null> => {
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
}
})
@@ -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' })
)
})
})
+32 -42
View File
@@ -23,7 +23,7 @@ export function findCreatedWorktree<T extends { path: string; branch?: string }>
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<CreatedWorktreeResolution> {
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
}
@@ -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<boolean> {
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
)
)
}
+28 -8
View File
@@ -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<typeof importExternalPathsSshTracked>
): 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<boolean> {
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
)
)
}
@@ -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<string, unknown>
const handlers = new Map<
string,
(_event: unknown, args: FilesystemHandlerArgs) => Promise<unknown>
>()
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<void>()
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<void>()
// 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<void>()
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<void>()
const rollingBack = Promise.withResolvers<void>()
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<typeof fileStat>()
const inspected = Promise.withResolvers<void>()
const uploaded = Promise.withResolvers<void>()
const upload = Promise.withResolvers<void>()
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<void>()
// 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)
})
})
@@ -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, {
@@ -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()
})
})
@@ -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<OrcadManagedRuntimeStatus> =>
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()
}
@@ -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<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 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()
})
})
@@ -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
}): void {
ipcMain.handle(
'runtimeEnvironments:updateOrcad',
async (
_event,
args: { selector: string; force?: boolean }
): Promise<OrcadManagedDeployResult> => {
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<OrcadManagedRollbackResult> => {
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<OrcadManagedRecoveryResult> => {
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<OrcadManagedStopResult> =>
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<OrcadManagedCancelStopResult> =>
cancelManagedOrcadStop(options.getUserDataPath(), {
selector: requiredString(args?.selector, 'Server')
})
)
}
@@ -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')
})
})
@@ -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())
)
}
@@ -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<ChildProcess, Promise<void>>()
const signalledChildren = new WeakSet<ChildProcess>()
/** 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<boolean> {
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<boolean> {
})
}
export function watcherChildPhysicalExit(child: ChildProcess): Promise<void> {
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<void>((resolve) => {
const finish = (): void => {
child.removeListener('exit', finish)
@@ -118,14 +108,11 @@ export function watcherChildPhysicalExit(child: ChildProcess): Promise<void> {
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<number, PendingWatcherUnsubscribe>,
onFinished: (exited: boolean) => void
): Promise<void> {
// 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,
@@ -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.
@@ -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()
})
@@ -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<ChildProcessHandle>()
private disposal: Promise<void> | 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<void> {
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
}
}

Some files were not shown because too many files have changed in this diff Show More