mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-launch-host-prompt-delivery
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
])
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
'',
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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,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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user