mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +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
|
# Native cache misses need the compiler, Electron needs Xvfb, and paired
|
||||||
# Quick Open needs ripgrep. Install them in one apt transaction per shard.
|
# Quick Open needs ripgrep. Install them in one apt transaction per shard.
|
||||||
- name: Install native build and headless UI tools
|
- 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
|
- uses: ./.github/actions/install-node-dependencies
|
||||||
with:
|
with:
|
||||||
@@ -241,7 +255,7 @@ jobs:
|
|||||||
# them as an artifact makes post-mortem debugging on CI possible without
|
# them as an artifact makes post-mortem debugging on CI possible without
|
||||||
# re-running locally.
|
# re-running locally.
|
||||||
- name: Upload Playwright traces
|
- name: Upload Playwright traces
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: playwright-traces-${{ matrix.shard_name }}
|
name: playwright-traces-${{ matrix.shard_name }}
|
||||||
@@ -267,7 +281,7 @@ jobs:
|
|||||||
# unbounded inventory fallback; the paired fixture exercises that real boundary.
|
# 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
|
# 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.
|
# 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
|
- uses: ./.github/actions/install-node-dependencies
|
||||||
with:
|
with:
|
||||||
@@ -344,7 +358,7 @@ jobs:
|
|||||||
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
|
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
|
||||||
|
|
||||||
- name: Upload Playwright traces
|
- name: Upload Playwright traces
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: playwright-traces-changed
|
name: playwright-traces-changed
|
||||||
@@ -403,7 +417,7 @@ jobs:
|
|||||||
ref: ${{ inputs.ref || github.ref }}
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Install native build and headless UI tools
|
- 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
|
- uses: ./.github/actions/install-node-dependencies
|
||||||
with:
|
with:
|
||||||
@@ -459,7 +473,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload watcher isolation traces
|
- name: Upload watcher isolation traces
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }}
|
name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }}
|
||||||
@@ -544,7 +558,7 @@ jobs:
|
|||||||
ORCA_E2E_FORWARD_APP_LOGS: '1'
|
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
|
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
|
- uses: actions/upload-artifact@v7
|
||||||
if: failure()
|
if: failure() || cancelled()
|
||||||
with:
|
with:
|
||||||
name: localhost-ssh-traces
|
name: localhost-ssh-traces
|
||||||
path: test-results/
|
path: test-results/
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ on:
|
|||||||
- '.github/actions/install-node-dependencies/**'
|
- '.github/actions/install-node-dependencies/**'
|
||||||
- '.github/actions/prepare-native-runtime/**'
|
- '.github/actions/prepare-native-runtime/**'
|
||||||
- '.github/workflows/node-server-tests.yml'
|
- '.github/workflows/node-server-tests.yml'
|
||||||
# The pull request qualifies one platform for an unflavoured change; this is where all six
|
# Relevant main pushes qualify every platform after the dependency check.
|
||||||
# are re-qualified, so a platform break surfaces minutes after merge instead of next cron.
|
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
@@ -60,15 +59,16 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
# Why a run-scoped group for template builds: a release call shares github.ref with main's push
|
# Main pushes must finish detection before they can supersede relevant qualification.
|
||||||
# runs, and cancelling either would drop a release's template or a main qualification.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: node-server-${{ inputs.build_template && format('template-{0}', github.run_id) || github.event.pull_request.number || github.ref }}
|
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 }}
|
cancel-in-progress: ${{ !inputs.build_template && github.event_name != 'push' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
outputs:
|
outputs:
|
||||||
@@ -84,7 +84,20 @@ jobs:
|
|||||||
- name: Detect headless-server build and test inputs
|
- name: Detect headless-server build and test inputs
|
||||||
id: scope
|
id: scope
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
PUSH_BASE: ${{ github.event.before }}
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
run: |
|
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.
|
# 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
|
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"
|
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes"
|
||||||
@@ -94,6 +107,9 @@ jobs:
|
|||||||
|
|
||||||
persistence:
|
persistence:
|
||||||
needs: changes
|
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.
|
# Missing/failed detection runs the full matrix; manual runs remain unconditional.
|
||||||
# A draft carries no platform verdict; readiness re-triggers this workflow. Spelled against
|
# 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.
|
# 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
|
continue-on-error: true
|
||||||
shell: bash
|
shell: bash
|
||||||
run: node config/scripts/orcad-windows-prebuild-cache.mjs --fingerprint
|
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
|
id: orcad-prebuild-cache-restore
|
||||||
if: >-
|
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.outcome == 'success' &&
|
||||||
steps.orcad-prebuild-cache-identity.outputs.key != ''
|
steps.orcad-prebuild-cache-identity.outputs.key != ''
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
@@ -234,6 +250,9 @@ jobs:
|
|||||||
|
|
||||||
linux_glibc_floor:
|
linux_glibc_floor:
|
||||||
needs: [changes, persistence]
|
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.
|
# A failed smoke already blocks qualification; missing scope still selects every platform.
|
||||||
if: >-
|
if: >-
|
||||||
${{ !cancelled() && needs.persistence.result == 'success' &&
|
${{ !cancelled() && needs.persistence.result == 'success' &&
|
||||||
@@ -262,7 +281,17 @@ jobs:
|
|||||||
PYTHON: /opt/python/cp312-cp312/bin/python3
|
PYTHON: /opt/python/cp312-cp312/bin/python3
|
||||||
steps:
|
steps:
|
||||||
- name: Install glibc 2.28 prerequisites
|
- 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
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref }}
|
ref: ${{ inputs.ref }}
|
||||||
@@ -289,6 +318,9 @@ jobs:
|
|||||||
|
|
||||||
linux_glibc217_compat:
|
linux_glibc217_compat:
|
||||||
needs: [changes, persistence]
|
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.
|
# A failed smoke already blocks qualification; missing scope still selects every platform.
|
||||||
if: >-
|
if: >-
|
||||||
${{ !cancelled() && needs.persistence.result == 'success' &&
|
${{ !cancelled() && needs.persistence.result == 'success' &&
|
||||||
@@ -341,6 +373,9 @@ jobs:
|
|||||||
|
|
||||||
linux_musl:
|
linux_musl:
|
||||||
needs: [changes, persistence]
|
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.
|
# A failed smoke already blocks qualification; missing scope still selects every platform.
|
||||||
if: >-
|
if: >-
|
||||||
${{ !cancelled() && needs.persistence.result == 'success' &&
|
${{ !cancelled() && needs.persistence.result == 'success' &&
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const BUILD_SCRIPTS = [
|
|||||||
'config/scripts/pinned-node-downloads.mjs',
|
'config/scripts/pinned-node-downloads.mjs',
|
||||||
'config/scripts/build-orcad.mjs',
|
'config/scripts/build-orcad.mjs',
|
||||||
'config/scripts/build-orcad-prebuilds.mjs',
|
'config/scripts/build-orcad-prebuilds.mjs',
|
||||||
|
'config/scripts/orcad-windows-prebuild-cache.mjs',
|
||||||
'config/scripts/orcad-prebuild-smoke-child.cjs',
|
'config/scripts/orcad-prebuild-smoke-child.cjs',
|
||||||
'config/scripts/build-windows-process-tree-relay-addon.mjs',
|
'config/scripts/build-windows-process-tree-relay-addon.mjs',
|
||||||
'config/scripts/run-node-server-tests.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 changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean)
|
||||||
const result = await classifyNodeServerChanges(changedFiles)
|
const result = await classifyNodeServerChanges(changedFiles)
|
||||||
console.log(result.reason)
|
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`
|
const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n`
|
||||||
if (process.env.GITHUB_OUTPUT) {
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
appendFileSync(process.env.GITHUB_OUTPUT, 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',
|
'src/main/worker-thread-entry-path.ts',
|
||||||
'config/scripts/zip-extractor-command.mjs',
|
'config/scripts/zip-extractor-command.mjs',
|
||||||
'config/scripts/windows-process-tree-gyp-rebuild.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/profile-state-worker-smoke.mjs',
|
||||||
'config/scripts/vitest-host-ports-setup.ts',
|
'config/scripts/vitest-host-ports-setup.ts',
|
||||||
'tests/e2e/daemon-running-work-probe.unit.test.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(
|
const workflow = parse(
|
||||||
readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8')
|
readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8')
|
||||||
)
|
)
|
||||||
expect(workflow.on).toHaveProperty('workflow_dispatch')
|
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['fetch-depth']).toBe(2)
|
||||||
expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false)
|
expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false)
|
||||||
const detect = workflow.jobs.changes.steps.find((step) => step.id === 'scope')
|
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.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.pull_request.types).toContain('ready_for_review')
|
||||||
expect(workflow.on.schedule).toHaveLength(1)
|
expect(workflow.on.schedule).toHaveLength(1)
|
||||||
// A pull request may qualify one platform, so the merged commit must re-qualify all six.
|
// 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'
|
'windows-11-arm'
|
||||||
]
|
]
|
||||||
|
|
||||||
// Only surfaces whose behaviour actually differs per platform. Escalating on `config/`,
|
// Shared execution and storage changes need every host; explicit platform paths need their family.
|
||||||
// `resources/` and `.github/` wholesale took 36.5% of the last 1100 commits through all six
|
const BUILD_PREFIXES = [
|
||||||
// platforms where a platform-flavoured predicate takes 19%.
|
|
||||||
const PLATFORM_PREFIXES = [
|
|
||||||
'native/',
|
'native/',
|
||||||
'config/patches/',
|
'config/patches/',
|
||||||
'.github/actions/install-node-dependencies/',
|
'.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/persistence/',
|
||||||
'src/main/sqlite/',
|
'src/main/sqlite/',
|
||||||
'src/main/orcad/',
|
'src/main/orcad/',
|
||||||
'src/main/providers/',
|
|
||||||
'src/main/daemon/',
|
'src/main/daemon/',
|
||||||
'src/main/ssh/',
|
|
||||||
'src/main/wsl/',
|
'src/main/wsl/',
|
||||||
'src/relay/',
|
'src/shared/child-process/'
|
||||||
'src/shared/child-process/',
|
|
||||||
// Every native prebuild slot is compiled and smoked against the pinned runtime.
|
|
||||||
'src/shared/node-runtime-pin.ts'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function nodeServerQualification(changedFiles, scope) {
|
const PLATFORM_FAMILIES = [
|
||||||
const platformSpecific = changedFiles.some(
|
{ pattern: /(?:^|[/.-])(?:windows|win32|wsl)(?:[/.-]|$)/i, prefix: 'windows-' },
|
||||||
(file) =>
|
{ pattern: /(?:^|[/.-])(?:macos|darwin|posix)(?:[/.-]|$)/i, prefix: 'macos-' },
|
||||||
// A root manifest can move a native dependency on every platform at once.
|
{ 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('/') ||
|
!file.includes('/') ||
|
||||||
PLATFORM_PREFIXES.some((prefix) => file.startsWith(prefix)) ||
|
BUILD_PREFIXES.some((prefix) => file.startsWith(prefix)) ||
|
||||||
/(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun|prebuilds?)(?:[/.-]|$)/i.test(
|
CROSS_HOST_PREFIXES.some((prefix) => file.startsWith(prefix)) ||
|
||||||
file
|
(/(?:^|[/.-])(?:remote|ssh)(?:[/.-]|$)/i.test(file) &&
|
||||||
)
|
PLATFORM_FAMILIES.some(({ pattern }) => pattern.test(file))) ||
|
||||||
)
|
file === 'src/shared/node-runtime-pin.ts' ||
|
||||||
// A pull request qualifies one platform unless the change is platform-flavoured; the push to
|
file === '.github/workflows/node-server-tests.yml' ||
|
||||||
// main re-qualifies all six, so an unescalated miss surfaces minutes after merge, not a day.
|
file.startsWith('config/scripts/node-server-') ||
|
||||||
const full = changedFiles.length === 0 || scope.graphUnavailable === true || platformSpecific
|
/(?:^|[/.-])(?: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 {
|
return {
|
||||||
qualification: full,
|
qualification: full || qualification,
|
||||||
runners: full ? NODE_SERVER_RUNNERS : ['ubuntu-22.04']
|
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/persistence/profile-state/store.ts',
|
||||||
'src/main/sqlite/database.ts',
|
'src/main/sqlite/database.ts',
|
||||||
'src/main/orcad/entry.ts',
|
'src/main/orcad/entry.ts',
|
||||||
'src/main/runtime/windows-terminal.ts',
|
|
||||||
'src/shared/linux-glibc.ts',
|
|
||||||
'src/main/daemon/entry.ts',
|
'src/main/daemon/entry.ts',
|
||||||
'src/relay/index.ts',
|
'src/relay/index.ts',
|
||||||
'src/main/wsl/runner.ts',
|
|
||||||
'config/scripts/build-orcad-prebuilds.mjs',
|
'config/scripts/build-orcad-prebuilds.mjs',
|
||||||
'config/scripts/orcad-prebuild-slot-contents.mjs',
|
'config/scripts/orcad-prebuild-slot-contents.mjs',
|
||||||
'src/shared/node-runtime-pin.ts'
|
'src/shared/node-runtime-pin.ts'
|
||||||
@@ -58,3 +55,85 @@ it('fails closed to every platform when the evidence is incomplete', () => {
|
|||||||
}).qualification
|
}).qualification
|
||||||
).toBe(true)
|
).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 }
|
build_template: { type: 'boolean', default: false }
|
||||||
})
|
})
|
||||||
// A release call shares github.ref with main's push runs; neither may cancel the other.
|
// 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')
|
expect(nodeServer.concurrency.group).toContain('github.run_id')
|
||||||
for (const lane of LANES) {
|
for (const lane of LANES) {
|
||||||
const steps = nodeServer.jobs[lane].steps
|
const steps = nodeServer.jobs[lane].steps
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function context(os, arch, event, ref, template = false) {
|
|||||||
describe('Windows server prebuild cache workflow', () => {
|
describe('Windows server prebuild cache workflow', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['pull_request', 'refs/pull/1/merge', false, true, false],
|
['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],
|
['schedule', 'refs/heads/main', false, false, true],
|
||||||
['workflow_dispatch', 'refs/heads/main', false, false, true],
|
['workflow_dispatch', 'refs/heads/main', false, false, true],
|
||||||
['workflow_call', 'refs/heads/main', false, false, false],
|
['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
|
PR 3 moves the rollup and the decay into `src/shared` and makes all three
|
||||||
call it.
|
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
|
## What does not change
|
||||||
|
|
||||||
- The hook scripts, the OSC 9999 wire format, and the relay protocol.
|
- 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,
|
The [September 28 demand rollout](ci-demand-rollout.md) documents staged checks,
|
||||||
unit-selection evidence, headless runtime qualification, review cancellation and daily occupancy reports.
|
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
|
## October 1 Windows and dependency cache follow-up
|
||||||
|
|
||||||
[PR #24355](https://github.com/stablyai/orca/pull/24355) merged at `197ea3a3`.
|
[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
|
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,
|
consumers. Cache the small server slot separately, using the exact compiler image,
|
||||||
architecture, dependency/patch/runtime inputs and compilation/validation source.
|
architecture, dependency/patch/runtime inputs and compilation/validation source.
|
||||||
Only PR qualification restores it. Main qualification still compiles freshly and
|
PR and main-push qualification restore it. Nightly qualification still compiles
|
||||||
saves after persistence/lifecycle tests and the existing x64 Node 18 handoff.
|
freshly; main saves after persistence/lifecycle tests and the existing x64 Node 18 handoff.
|
||||||
Templates and explicit-ref calls continue to compile freshly.
|
Templates and explicit-ref calls continue to compile freshly.
|
||||||
|
|
||||||
| Hosted runner | Fresh build median | Restore median | Difference |
|
| 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 |
|
| `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-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 |
|
| `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 |
|
| `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-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 |
|
| `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
|
### Placeholders That Fail Open
|
||||||
|
|
||||||
`GitCapabilityCache` records commands Git _rejects_. A `git log --format`
|
`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
|
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.
|
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,
|
Keep the unit tests alongside that matrix. They cover concurrent probes,
|
||||||
native/WSL/SSH/relay isolation, and error-stream shapes that a single real
|
native/WSL/SSH/relay isolation, and error-stream shapes that a single real
|
||||||
binary invocation cannot exercise deterministically.
|
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
|
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`,
|
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.
|
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.**
|
- **Exit codes.**
|
||||||
|
|
||||||
| Code | Meaning | Supervisor should |
|
| 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)
|
expect(labels.slice(firstEscalated).every((l) => l !== 'Connecting…')).toBe(true)
|
||||||
}, 60_000)
|
}, 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 { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { scanAiVaultSessions } from './session-scanner'
|
import { scanAiVaultSessions } from './session-scanner'
|
||||||
|
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
|
||||||
|
|
||||||
let tempRoots: string[] = []
|
let tempRoots: string[] = []
|
||||||
|
|
||||||
@@ -11,10 +12,6 @@ afterEach(async () => {
|
|||||||
tempRoots = []
|
tempRoots = []
|
||||||
})
|
})
|
||||||
|
|
||||||
function jsonLines(records: unknown[]): string {
|
|
||||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('scanAiVaultSessions Codex worker sessions', () => {
|
describe('scanAiVaultSessions Codex worker sessions', () => {
|
||||||
it('hides Codex worker transcripts from session history', async () => {
|
it('hides Codex worker transcripts from session history', async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-workers-'))
|
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-workers-'))
|
||||||
@@ -198,28 +195,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const result = await scanAiVaultSessions({
|
const result = await scanAiVaultSessions({
|
||||||
claudeProjectsDir: join(root, 'claude-projects'),
|
...isolatedScanRoots(root),
|
||||||
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'),
|
|
||||||
platform: 'darwin'
|
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 status = await vi.waitFor(async () => {
|
||||||
const value = await searchStatus()
|
const value = await searchStatus()
|
||||||
expect(value.filesIndexed).toBeGreaterThan(0)
|
expect(value.filesIndexed).toBeGreaterThan(0)
|
||||||
|
expect(value.phase).toBe('current')
|
||||||
|
expect(value.generation).toBeGreaterThan(0)
|
||||||
return value
|
return value
|
||||||
})
|
})
|
||||||
expect(status.enabled).toBe(true)
|
expect(status.enabled).toBe(true)
|
||||||
expect(status.phase).toBe('current')
|
|
||||||
expect(status.generation).toBeGreaterThan(0)
|
|
||||||
expect(existsSync(harness.databasePath)).toBe(true)
|
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 type { SocketProbeOutcome } from './daemon-endpoint-probe'
|
||||||
import {
|
import {
|
||||||
checkDaemonHealth,
|
checkDaemonHealth,
|
||||||
checkDaemonHealthWithCoverage,
|
|
||||||
E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV,
|
E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV,
|
||||||
healthCheckDaemon
|
healthCheckDaemon
|
||||||
} from './daemon-health'
|
} from './daemon-health'
|
||||||
@@ -106,63 +105,12 @@ describe('daemon health', () => {
|
|||||||
try {
|
try {
|
||||||
await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('healthy')
|
await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('healthy')
|
||||||
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true)
|
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true)
|
||||||
await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toMatchObject({
|
expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(2)
|
||||||
verdict: 'healthy',
|
|
||||||
coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn',
|
|
||||||
runtimeKind: 'node',
|
|
||||||
runtimeVersion: process.version
|
|
||||||
})
|
|
||||||
expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(3)
|
|
||||||
} finally {
|
} finally {
|
||||||
await server.shutdown()
|
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 () => {
|
it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => {
|
||||||
const server = new DaemonServer({
|
const server = new DaemonServer({
|
||||||
socketPath,
|
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.
|
// also covers a live-but-wedged daemon that simply missed the RPC budget.
|
||||||
export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy'
|
export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy'
|
||||||
|
|
||||||
export type DaemonHealthCheck = {
|
export function checkDaemonHealth(socketPath: string, tokenPath: string): Promise<DaemonHealth> {
|
||||||
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> {
|
|
||||||
return new Promise((resolve) => {
|
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') {
|
if (process.env[E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV] === '1') {
|
||||||
resolveVerdict('unreachable')
|
resolve('unreachable')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform !== 'win32' && !existsSync(socketPath)) {
|
if (process.platform !== 'win32' && !existsSync(socketPath)) {
|
||||||
resolveVerdict('unreachable')
|
resolve('unreachable')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,13 +37,13 @@ export function checkDaemonHealthWithCoverage(
|
|||||||
try {
|
try {
|
||||||
token = readFileSync(tokenPath, 'utf8').trim()
|
token = readFileSync(tokenPath, 'utf8').trim()
|
||||||
} catch {
|
} catch {
|
||||||
resolveVerdict('unreachable')
|
resolve('unreachable')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let settled = false
|
let settled = false
|
||||||
let sock: Socket | null = null
|
let sock: Socket | null = null
|
||||||
const settle = (result: DaemonHealthCheck): void => {
|
const settle = (result: DaemonHealth): void => {
|
||||||
if (settled) {
|
if (settled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,7 +58,7 @@ export function checkDaemonHealthWithCoverage(
|
|||||||
sock?.off('connect', onConnect)
|
sock?.off('connect', onConnect)
|
||||||
sock?.off('data', onData)
|
sock?.off('data', onData)
|
||||||
}
|
}
|
||||||
const onError = (): void => settle({ verdict: 'unreachable', coverage: fallbackCoverage })
|
const onError = (): void => settle('unreachable')
|
||||||
const onConnect = (): void => {
|
const onConnect = (): void => {
|
||||||
const hello: HelloMessage = {
|
const hello: HelloMessage = {
|
||||||
type: 'hello',
|
type: 'hello',
|
||||||
@@ -131,13 +89,13 @@ export function checkDaemonHealthWithCoverage(
|
|||||||
try {
|
try {
|
||||||
message = JSON.parse(line) as Record<string, unknown>
|
message = JSON.parse(line) as Record<string, unknown>
|
||||||
} catch {
|
} catch {
|
||||||
settle({ verdict: 'rejected', coverage: fallbackCoverage })
|
settle('rejected')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'hello') {
|
if (message.type === 'hello') {
|
||||||
if (!(message as HelloResponse).ok) {
|
if (!(message as HelloResponse).ok) {
|
||||||
settle({ verdict: 'rejected', coverage: fallbackCoverage })
|
settle('rejected')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Why: a protocol-live daemon with a stale cwd or node-pty helper
|
// 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') {
|
if (message.id === 'health-1') {
|
||||||
const identity = readRuntimeIdentity(message.payload)
|
settle(message.ok === true ? 'healthy' : 'pty-spawn-unhealthy')
|
||||||
settle({
|
|
||||||
verdict: message.ok === true ? 'healthy' : 'pty-spawn-unhealthy',
|
|
||||||
coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage),
|
|
||||||
...identity
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const timer = setTimeout(
|
const timer = setTimeout(() => settle('unreachable'), HEALTH_CHECK_TIMEOUT_MS)
|
||||||
() => settle({ verdict: 'unreachable', coverage: fallbackCoverage }),
|
|
||||||
HEALTH_CHECK_TIMEOUT_MS
|
|
||||||
)
|
|
||||||
|
|
||||||
sock = connect({ path: socketPath })
|
sock = connect({ path: socketPath })
|
||||||
sock.on('error', onError)
|
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> {
|
export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise<boolean> {
|
||||||
return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy'
|
return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,35 +442,6 @@ describe('current daemon lifecycle retirement', () => {
|
|||||||
adopted.dispose()
|
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 () => {
|
it('does not let repeated authenticated control probes extend the startup deadline', async () => {
|
||||||
await startServer()
|
await startServer()
|
||||||
const healthControl = connect(socketPath)
|
const healthControl = connect(socketPath)
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ export {
|
|||||||
getDaemonEndpointFacts,
|
getDaemonEndpointFacts,
|
||||||
getDaemonProvider,
|
getDaemonProvider,
|
||||||
listLiveDaemonPtyIds,
|
listLiveDaemonPtyIds,
|
||||||
listLiveDaemonSessions,
|
|
||||||
requestIdleDaemonRetirement,
|
|
||||||
releaseDaemonRetirementFence,
|
|
||||||
readDaemonPidRecord,
|
readDaemonPidRecord,
|
||||||
replaceDaemonProvider,
|
replaceDaemonProvider,
|
||||||
shutdownDaemon,
|
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,
|
getMacDaemonTccAttributionHealth,
|
||||||
type MacDaemonTccAttributionHealth
|
type MacDaemonTccAttributionHealth
|
||||||
} from './daemon-tcc-attribution'
|
} from './daemon-tcc-attribution'
|
||||||
import { PROTOCOL_VERSION, type DaemonSessionInfo, type SessionInfo } from './types'
|
import { PROTOCOL_VERSION } from './types'
|
||||||
import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state'
|
|
||||||
|
|
||||||
let spawner: DaemonSpawner | null = null
|
let spawner: DaemonSpawner | null = null
|
||||||
let adapter: DaemonProvider | null = null
|
let adapter: DaemonProvider | null = null
|
||||||
|
|
||||||
export function installDaemonProvider(
|
export function installDaemonProvider(newSpawner: DaemonSpawner, newAdapter: DaemonProvider): void {
|
||||||
newSpawner: DaemonSpawner | null,
|
|
||||||
newAdapter: DaemonProvider
|
|
||||||
): void {
|
|
||||||
spawner = newSpawner
|
spawner = newSpawner
|
||||||
replaceDaemonProvider(newAdapter)
|
replaceDaemonProvider(newAdapter)
|
||||||
}
|
}
|
||||||
@@ -39,12 +35,7 @@ export function getDaemonSpawner(): DaemonSpawner | null {
|
|||||||
* from that state would be advertising recovery for terminals that cannot be recovered.
|
* from that state would be advertising recovery for terminals that cannot be recovered.
|
||||||
*/
|
*/
|
||||||
export function daemonOwnsFreshPersistentPtys(): boolean {
|
export function daemonOwnsFreshPersistentPtys(): boolean {
|
||||||
if (!adapter || adapter instanceof DegradedDaemonPtyProvider) {
|
return adapter !== null && !(adapter instanceof DegradedDaemonPtyProvider)
|
||||||
return false
|
|
||||||
}
|
|
||||||
return adapter instanceof DaemonPtyRouter
|
|
||||||
? !adapter.getAllAdapters().some((entry) => entry.recoveryOnly)
|
|
||||||
: !adapter.recoveryOnly
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Endpoint coordinates of the daemon this process installed, for out-of-band health probes. */
|
/** 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. */
|
/** Returns null unless every daemon generation supplied an authoritative inventory. */
|
||||||
export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
|
export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
@@ -145,54 +115,23 @@ export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns null unless every daemon generation supplied an authoritative session inventory. */
|
// Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference.
|
||||||
export async function listLiveDaemonSessions(): Promise<SessionInfo[] | null> {
|
export function replaceDaemonProvider(newAdapter: DaemonProvider): void {
|
||||||
const sessions = await listLiveDaemonSessionsWithProtocol()
|
adapter = newAdapter
|
||||||
return sessions?.map(({ protocolVersion: _protocolVersion, ...session }) => session) ?? null
|
setLocalPtyProvider(newAdapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Like listLiveDaemonSessions, with the protocol of the daemon generation owning each session. */
|
// Disconnect without killing: the daemon survives app quit so sessions stay warm for reattach.
|
||||||
export async function listLiveDaemonSessionsWithProtocol(): Promise<DaemonSessionInfo[] | null> {
|
// Leave history sessions marked "unclean" so a daemon crash while Orca is closed stays recoverable.
|
||||||
if (!adapter) {
|
export async function disconnectDaemon(): Promise<void> {
|
||||||
return null
|
await adapter?.disconnectOnly()
|
||||||
}
|
adapter = 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 : []
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Atomically fence new daemon terminals and retire only an idle, single-generation daemon. */
|
/** Kill the daemon and all its sessions. Use for full cleanup only. */
|
||||||
export async function requestIdleDaemonRetirement(): Promise<DaemonIdleRetirementResult> {
|
export async function shutdownDaemon(): Promise<void> {
|
||||||
if (!adapter) {
|
adapter?.dispose()
|
||||||
return { state: 'unverifiable' }
|
adapter = null
|
||||||
}
|
await spawner?.shutdown()
|
||||||
if (adapter instanceof DegradedDaemonPtyProvider) {
|
spawner = null
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ import { removeDaemonListener } from './daemon-listener-registry'
|
|||||||
import { emitPtyListeners } from './daemon-pty-listener-emission'
|
import { emitPtyListeners } from './daemon-pty-listener-emission'
|
||||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||||
import { DaemonPtySessionInventory } from './daemon-pty-session-inventory'
|
import { DaemonPtySessionInventory } from './daemon-pty-session-inventory'
|
||||||
import {
|
import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types'
|
||||||
CLEAN_DISCONNECT_PROTOCOL_VERSION,
|
|
||||||
type ListSessionsResult,
|
|
||||||
type ShutdownIfIdleResult
|
|
||||||
} from './types'
|
|
||||||
import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state'
|
|
||||||
import type { PtyBackgroundStreamEvent } from '../providers/types'
|
import type { PtyBackgroundStreamEvent } from '../providers/types'
|
||||||
|
|
||||||
export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory {
|
export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory {
|
||||||
@@ -90,75 +85,6 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent
|
|||||||
this.recordAuthenticatedIdentity()
|
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,
|
// 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.
|
// but still write a final checkpoint so a daemon crash while Orca is closed has recovery data.
|
||||||
async disconnectOnly(): Promise<void> {
|
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.
|
// 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)
|
const { worktreeId } = parsePtySessionId(session.sessionId)
|
||||||
|
|
||||||
if (!this.recoveryOnly && (worktreeId === null || !validWorktreeIds.has(worktreeId))) {
|
if (worktreeId === null || !validWorktreeIds.has(worktreeId)) {
|
||||||
try {
|
try {
|
||||||
await this.client.request('kill', { sessionId: session.sessionId })
|
await this.client.request('kill', { sessionId: session.sessionId })
|
||||||
} catch {
|
} 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 { describe, expect, it, vi } from 'vitest'
|
||||||
import { DaemonPtyRouter } from './daemon-pty-router'
|
import { DaemonPtyRouter } from './daemon-pty-router'
|
||||||
import { stubWriteSettlement } from '../providers/settled-pty-write-stub'
|
|
||||||
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
|
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
|
||||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
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 {
|
import {
|
||||||
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
|
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'
|
} from './types'
|
||||||
import {
|
import {
|
||||||
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
|
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
|
SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION,
|
||||||
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
|
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
|
||||||
} from './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
|
const LARGE_RECONCILE_SESSION_COUNT = 150_000
|
||||||
|
|
||||||
function buildSessionIds(prefix: string, count: number): string[] {
|
function buildSessionIds(prefix: string, count: number): string[] {
|
||||||
@@ -25,6 +34,152 @@ function buildSessionIds(prefix: string, count: number): string[] {
|
|||||||
return ids
|
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', () => {
|
it('forwards dead-endpoint write-unavailable signals from every routed adapter', () => {
|
||||||
// Why revert-sensitive: main subscribes on the ROUTED provider, so if the router
|
// 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
|
// 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('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', () => {
|
it('reports separate conservative resume and fresh-create boundaries', () => {
|
||||||
const current = createAdapter(
|
const current = createAdapter(
|
||||||
'current',
|
'current',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { reconcileDaemonRouterSessions } from './daemon-router-session-reconciliation'
|
|
||||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||||
import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout'
|
import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout'
|
||||||
import type {
|
import type {
|
||||||
@@ -13,8 +12,6 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
|||||||
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
|
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
|
||||||
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
|
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
|
||||||
import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
|
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 { WriteSettlement } from '../../shared/pty-write-settlement'
|
||||||
import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply'
|
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 sessionAdapters = new Map<string, DaemonPtyAdapter>()
|
||||||
private readonly ownerResolver: DaemonSessionOwnerResolver<DaemonPtyAdapter>
|
private readonly ownerResolver: DaemonSessionOwnerResolver<DaemonPtyAdapter>
|
||||||
private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout
|
private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout
|
||||||
private readonly retirement = new DaemonRouterRetirement(() => this.allAdapters())
|
|
||||||
|
|
||||||
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
|
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
|
||||||
this.current = opts.current
|
this.current = opts.current
|
||||||
@@ -44,34 +40,17 @@ export class DaemonPtyRouter implements IPtyProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||||
if (this.retirement.admissionClosed) {
|
if (opts.attachOnly && opts.sessionId) {
|
||||||
throw new Error('Terminal daemon is decommissioning')
|
return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId })
|
||||||
}
|
}
|
||||||
// Why counted: an idle-retirement census must not race a spawn it cannot yet see.
|
const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined
|
||||||
this.retirement.spawnInFlight++
|
const target = adapter ?? this.current
|
||||||
try {
|
const result = await target.spawn(opts)
|
||||||
if (opts.attachOnly && opts.sessionId) {
|
// Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof.
|
||||||
return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId })
|
if (!result.exitedBeforeSpawnReply) {
|
||||||
}
|
this.ownerResolver.recordRoute(result.id, target, result.incarnationId)
|
||||||
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--
|
|
||||||
}
|
}
|
||||||
}
|
return result
|
||||||
|
|
||||||
requestIdleRetirement(): Promise<DaemonIdleRetirementResult> {
|
|
||||||
return this.retirement.requestIdleRetirement()
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseIdleRetirementFence(): void {
|
|
||||||
this.retirement.releaseFence()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
supportsGitCredentialGuardHost(sessionId?: string): boolean {
|
supportsGitCredentialGuardHost(sessionId?: string): boolean {
|
||||||
@@ -278,10 +257,38 @@ export class DaemonPtyRouter implements IPtyProvider {
|
|||||||
this.adapterFor(sessionId).clearTombstone(sessionId)
|
this.adapterFor(sessionId).clearTombstone(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconcileOnStartup(
|
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
|
||||||
validWorktreeIds: Set<string>
|
alive: string[]
|
||||||
): Promise<{ alive: string[]; killed: string[] }> {
|
killed: string[]
|
||||||
return reconcileDaemonRouterSessions(this.allAdapters(), this.ownerResolver, validWorktreeIds)
|
}> {
|
||||||
|
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 {
|
dispose(): void {
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export type DaemonPtyAdapterOptions = {
|
|||||||
historyPath?: string
|
historyPath?: string
|
||||||
runtimeDir?: string
|
runtimeDir?: string
|
||||||
packagedAppVersion?: string | null
|
packagedAppVersion?: string | null
|
||||||
recoveryOnly?: boolean
|
|
||||||
respawn?: (reason: DaemonRespawnReason) => Promise<void | (() => void)>
|
respawn?: (reason: DaemonRespawnReason) => Promise<void | (() => void)>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,15 +71,8 @@ export type DaemonIdentityChangeEvent = {
|
|||||||
current: DaemonEndpointIdentity
|
current: DaemonEndpointIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DaemonIdleRetirementResult =
|
|
||||||
| { state: 'retiring' }
|
|
||||||
| { state: 'busy'; liveSessions: number | null; admissionReopened?: true }
|
|
||||||
| { state: 'unsupported' }
|
|
||||||
| { state: 'unverifiable' }
|
|
||||||
|
|
||||||
export abstract class DaemonPtyRuntimeState {
|
export abstract class DaemonPtyRuntimeState {
|
||||||
readonly protocolVersion: number
|
readonly protocolVersion: number
|
||||||
readonly recoveryOnly: boolean
|
|
||||||
protected socketPath: string
|
protected socketPath: string
|
||||||
protected tokenPath: string
|
protected tokenPath: string
|
||||||
protected pidPath: string | null
|
protected pidPath: string | null
|
||||||
@@ -100,9 +92,6 @@ export abstract class DaemonPtyRuntimeState {
|
|||||||
protected packagedAppVersion: string | null
|
protected packagedAppVersion: string | null
|
||||||
protected pendingRespawnAdoptionRelease: (() => void) | null = null
|
protected pendingRespawnAdoptionRelease: (() => void) | null = null
|
||||||
protected respawnAdoptionClosed = false
|
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 respawnPromise: Promise<void> | null = null
|
||||||
protected staleBundleReplacementPromise: Promise<void> | null = null
|
protected staleBundleReplacementPromise: Promise<void> | null = null
|
||||||
protected writeRecoveryPromise: Promise<void> | null = null
|
protected writeRecoveryPromise: Promise<void> | null = null
|
||||||
@@ -201,7 +190,6 @@ export abstract class DaemonPtyRuntimeState {
|
|||||||
|
|
||||||
constructor(opts: DaemonPtyAdapterOptions) {
|
constructor(opts: DaemonPtyAdapterOptions) {
|
||||||
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
|
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
|
||||||
this.recoveryOnly = opts.recoveryOnly === true
|
|
||||||
this.socketPath = opts.socketPath
|
this.socketPath = opts.socketPath
|
||||||
this.tokenPath = opts.tokenPath
|
this.tokenPath = opts.tokenPath
|
||||||
this.pidPath = opts.pidPath ?? null
|
this.pidPath = opts.pidPath ?? null
|
||||||
@@ -221,7 +209,7 @@ export abstract class DaemonPtyRuntimeState {
|
|||||||
})
|
})
|
||||||
this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null
|
this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null
|
||||||
this.historyReader = opts.historyPath ? new HistoryReader(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.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null
|
||||||
this.packagedAppVersion = opts.packagedAppVersion ?? null
|
this.packagedAppVersion = opts.packagedAppVersion ?? null
|
||||||
this.supportsCheckpoints = this.protocolVersion >= 4
|
this.supportsCheckpoints = this.protocolVersion >= 4
|
||||||
|
|||||||
@@ -22,15 +22,10 @@ import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
|||||||
import { resolveUnixShellPath } from '../providers/local-pty-utils'
|
import { resolveUnixShellPath } from '../providers/local-pty-utils'
|
||||||
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||||
import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history'
|
import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history'
|
||||||
import { assertDaemonRecoverySpawnAdmission } from './daemon-recovery-spawn-admission'
|
|
||||||
import { addWslEnvKeys } from '../wsl-env'
|
import { addWslEnvKeys } from '../wsl-env'
|
||||||
|
|
||||||
export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
|
export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
|
||||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
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 spawnOpts = this.withHistoryIsolation(opts)
|
||||||
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
|
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
|
||||||
const operation: PendingDaemonSpawnOperation = {
|
const operation: PendingDaemonSpawnOperation = {
|
||||||
@@ -110,10 +105,6 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
|
|||||||
operation: PendingDaemonSpawnOperation,
|
operation: PendingDaemonSpawnOperation,
|
||||||
historyRecovery: HistoryRecoveryContext
|
historyRecovery: HistoryRecoveryContext
|
||||||
): Promise<PtySpawnResult> {
|
): Promise<PtySpawnResult> {
|
||||||
assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts)
|
|
||||||
if (this.idleRetirementAdmissionClosed) {
|
|
||||||
throw new Error('Terminal daemon is decommissioning')
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
opts.agentSessionEnsure &&
|
opts.agentSessionEnsure &&
|
||||||
this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
|
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 { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
|
||||||
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
||||||
import type { DaemonTerminalAdmission } from './daemon-terminal-admission'
|
import type { DaemonTerminalAdmission } from './daemon-terminal-admission'
|
||||||
import { readDaemonHealthIdentity } from './daemon-health-identity'
|
|
||||||
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
|
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
|
||||||
import type { TerminalHost } from './terminal-host'
|
import type { TerminalHost } from './terminal-host'
|
||||||
import { SessionNotFoundError, type DaemonRequest } from './types'
|
import { SessionNotFoundError, type DaemonRequest } from './types'
|
||||||
@@ -157,7 +156,7 @@ export class DaemonRequestRouter {
|
|||||||
return { health: await readCurrentProcessMacSystemResolverHealth() }
|
return { health: await readCurrentProcessMacSystemResolverHealth() }
|
||||||
case 'ptySpawnHealth':
|
case 'ptySpawnHealth':
|
||||||
await this.options.ptySpawnHealthCheck()
|
await this.options.ptySpawnHealthCheck()
|
||||||
return { healthy: true, ...readDaemonHealthIdentity() }
|
return { healthy: true }
|
||||||
case 'shutdown':
|
case 'shutdown':
|
||||||
return this.shutdown(clientId, request.id, request.payload.killSessions)
|
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', () => {
|
it('keeps large pending output batched even when an interactive redraw follows', () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -246,8 +246,7 @@ export class DaemonStreamDataBatcher {
|
|||||||
this.maxLineBytes,
|
this.maxLineBytes,
|
||||||
sliceSequenceChars,
|
sliceSequenceChars,
|
||||||
entry.seq,
|
entry.seq,
|
||||||
entry.transformed,
|
entry.transformed
|
||||||
entry.incarnationId
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this.updateBackpressure(clientId, batch)
|
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 { writeStreamDataEvents } from './daemon-stream-data-split'
|
||||||
import { encodeNdjson } from './ndjson'
|
import { encodeNdjson } from './ndjson'
|
||||||
import { accountDaemonStreamEntry } from './daemon-stream-entry-accounting'
|
import { accountDaemonStreamEntry } from './daemon-stream-entry-accounting'
|
||||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
|
||||||
|
|
||||||
export type DaemonStreamEnqueueOptions = {
|
export type DaemonStreamEnqueueOptions = {
|
||||||
flushImmediately?: boolean
|
flushImmediately?: boolean
|
||||||
@@ -10,7 +9,6 @@ export type DaemonStreamEnqueueOptions = {
|
|||||||
rawLength?: number
|
rawLength?: number
|
||||||
transformed?: boolean
|
transformed?: boolean
|
||||||
seq?: number
|
seq?: number
|
||||||
incarnationId?: PtyIncarnationId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendDaemonStreamData(
|
export function appendDaemonStreamData(
|
||||||
@@ -25,8 +23,7 @@ export function appendDaemonStreamData(
|
|||||||
last?.sessionId === sessionId &&
|
last?.sessionId === sessionId &&
|
||||||
!last.control &&
|
!last.control &&
|
||||||
!last.transformed &&
|
!last.transformed &&
|
||||||
options.transformed !== true &&
|
options.transformed !== true
|
||||||
last.incarnationId === options.incarnationId
|
|
||||||
) {
|
) {
|
||||||
last.data += data
|
last.data += data
|
||||||
const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length
|
const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length
|
||||||
@@ -42,8 +39,7 @@ export function appendDaemonStreamData(
|
|||||||
? {}
|
? {}
|
||||||
: { sequenceChars: options.rawLength }),
|
: { sequenceChars: options.rawLength }),
|
||||||
...(options.transformed ? { transformed: true } : {}),
|
...(options.transformed ? { transformed: true } : {}),
|
||||||
...(options.seq === undefined ? {} : { seq: options.seq }),
|
...(options.seq === undefined ? {} : { seq: options.seq })
|
||||||
...(options.incarnationId === undefined ? {} : { incarnationId: options.incarnationId })
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -95,8 +91,7 @@ export function flushDaemonStreamSession(
|
|||||||
maxLineBytes,
|
maxLineBytes,
|
||||||
entry.sequenceChars ?? entry.data.length,
|
entry.sequenceChars ?? entry.data.length,
|
||||||
entry.seq,
|
entry.seq,
|
||||||
entry.transformed,
|
entry.transformed
|
||||||
entry.incarnationId
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ function write(
|
|||||||
rawLength = data.length,
|
rawLength = data.length,
|
||||||
seq?: number,
|
seq?: number,
|
||||||
transformed = false,
|
transformed = false,
|
||||||
sessionId = 'session-1',
|
sessionId = 'session-1'
|
||||||
incarnationId?: string
|
|
||||||
): string[] {
|
): string[] {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
const socket: Pick<Socket, 'write'> = {
|
const socket: Pick<Socket, 'write'> = {
|
||||||
@@ -28,16 +27,7 @@ function write(
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeStreamDataEvents(
|
writeStreamDataEvents(socket, sessionId, data, maxLineBytes, rawLength, seq, transformed)
|
||||||
socket,
|
|
||||||
sessionId,
|
|
||||||
data,
|
|
||||||
maxLineBytes,
|
|
||||||
rawLength,
|
|
||||||
seq,
|
|
||||||
transformed,
|
|
||||||
incarnationId
|
|
||||||
)
|
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,15 +84,6 @@ describe('writeStreamDataEvents serialization budget', () => {
|
|||||||
expect(encodeNdjson).toHaveBeenCalledTimes(1)
|
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', () => {
|
it('does not add a duplicate full-data sizing probe to oversized writes', () => {
|
||||||
const data = '🐙\x1b[0m'.repeat(100)
|
const data = '🐙\x1b[0m'.repeat(100)
|
||||||
const expected = previousWrites(data, 160)
|
const expected = previousWrites(data, 160)
|
||||||
@@ -121,24 +102,6 @@ describe('writeStreamDataEvents serialization budget', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('writeStreamDataEvents wire parity', () => {
|
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', () => {
|
it('preserves exact frames, chunk boundaries and metadata across payloads and caps', () => {
|
||||||
const payloads = [
|
const payloads = [
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -5,15 +5,13 @@
|
|||||||
*/
|
*/
|
||||||
import { resolveSynchronizedOutputSafeSplit } from '../../shared/terminal-synchronized-output-scan'
|
import { resolveSynchronizedOutputSafeSplit } from '../../shared/terminal-synchronized-output-scan'
|
||||||
import { encodeNdjson } from './ndjson'
|
import { encodeNdjson } from './ndjson'
|
||||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
|
||||||
|
|
||||||
export function encodeStreamDataEvent(
|
export function encodeStreamDataEvent(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
data: string,
|
data: string,
|
||||||
rawLength?: number,
|
rawLength?: number,
|
||||||
seq?: number,
|
seq?: number,
|
||||||
transformed?: boolean,
|
transformed?: boolean
|
||||||
incarnationId?: PtyIncarnationId
|
|
||||||
): string {
|
): string {
|
||||||
return encodeNdjson({
|
return encodeNdjson({
|
||||||
type: 'event',
|
type: 'event',
|
||||||
@@ -21,7 +19,6 @@ export function encodeStreamDataEvent(
|
|||||||
sessionId,
|
sessionId,
|
||||||
payload: {
|
payload: {
|
||||||
data,
|
data,
|
||||||
...(incarnationId === undefined ? {} : { incarnationId }),
|
|
||||||
...(seq === undefined ? {} : { seq }),
|
...(seq === undefined ? {} : { seq }),
|
||||||
...(rawLength === undefined ? {} : { rawLength }),
|
...(rawLength === undefined ? {} : { rawLength }),
|
||||||
...(rawLength === undefined ? {} : { sequenceChars: rawLength }),
|
...(rawLength === undefined ? {} : { sequenceChars: rawLength }),
|
||||||
@@ -30,16 +27,8 @@ export function encodeStreamDataEvent(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamDataEventLineBytes(
|
function streamDataEventLineBytes(sessionId: string, data: string, rawLength?: number): number {
|
||||||
sessionId: string,
|
return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, rawLength), 'utf8')
|
||||||
data: string,
|
|
||||||
rawLength?: number,
|
|
||||||
incarnationId?: PtyIncarnationId
|
|
||||||
): number {
|
|
||||||
return Buffer.byteLength(
|
|
||||||
encodeStreamDataEvent(sessionId, data, rawLength, undefined, false, incarnationId),
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHighSurrogate(value: number): boolean {
|
function isHighSurrogate(value: number): boolean {
|
||||||
@@ -90,28 +79,20 @@ export function splitStreamDataForNdjson(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
data: string,
|
data: string,
|
||||||
maxLineBytes: number,
|
maxLineBytes: number,
|
||||||
sequenceChars?: number,
|
sequenceChars?: number
|
||||||
incarnationId?: PtyIncarnationId
|
|
||||||
): string[] {
|
): string[] {
|
||||||
if (streamDataEventLineBytes(sessionId, data, sequenceChars, incarnationId) <= maxLineBytes) {
|
if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) {
|
||||||
return [data]
|
return [data]
|
||||||
}
|
}
|
||||||
|
|
||||||
return splitOversizedStreamDataForNdjson(
|
return splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes, sequenceChars)
|
||||||
sessionId,
|
|
||||||
data,
|
|
||||||
maxLineBytes,
|
|
||||||
sequenceChars,
|
|
||||||
incarnationId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitOversizedStreamDataForNdjson(
|
function splitOversizedStreamDataForNdjson(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
data: string,
|
data: string,
|
||||||
maxLineBytes: number,
|
maxLineBytes: number,
|
||||||
sequenceChars?: number,
|
sequenceChars?: number
|
||||||
incarnationId?: PtyIncarnationId
|
|
||||||
): string[] {
|
): string[] {
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
let start = 0
|
let start = 0
|
||||||
@@ -129,8 +110,7 @@ function splitOversizedStreamDataForNdjson(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars, incarnationId) <=
|
streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes
|
||||||
maxLineBytes
|
|
||||||
) {
|
) {
|
||||||
best = mid
|
best = mid
|
||||||
low = rawMid + 1
|
low = rawMid + 1
|
||||||
@@ -154,36 +134,28 @@ export function writeStreamDataEvents(
|
|||||||
maxLineBytes: number,
|
maxLineBytes: number,
|
||||||
rawLength = data.length,
|
rawLength = data.length,
|
||||||
seq?: number,
|
seq?: number,
|
||||||
transformed = false,
|
transformed = false
|
||||||
incarnationId?: PtyIncarnationId
|
|
||||||
): void {
|
): void {
|
||||||
const explicitRawLength = rawLength === data.length ? undefined : rawLength
|
const explicitRawLength = rawLength === data.length ? undefined : rawLength
|
||||||
if (transformed) {
|
if (transformed) {
|
||||||
streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true, incarnationId))
|
streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const carriesMetadata = explicitRawLength !== undefined || seq !== undefined
|
const carriesMetadata = explicitRawLength !== undefined || seq !== undefined
|
||||||
let chunks: string[]
|
let chunks: string[]
|
||||||
if (!carriesMetadata) {
|
if (!carriesMetadata) {
|
||||||
const line = encodeStreamDataEvent(sessionId, data, undefined, undefined, false, incarnationId)
|
const line = encodeStreamDataEvent(sessionId, data)
|
||||||
if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) {
|
if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) {
|
||||||
streamSocket.write(line)
|
streamSocket.write(line)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
chunks = splitOversizedStreamDataForNdjson(
|
chunks = splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes)
|
||||||
sessionId,
|
|
||||||
data,
|
|
||||||
maxLineBytes,
|
|
||||||
undefined,
|
|
||||||
incarnationId
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
chunks = splitStreamDataForNdjson(
|
chunks = splitStreamDataForNdjson(
|
||||||
sessionId,
|
sessionId,
|
||||||
data,
|
data,
|
||||||
Math.max(1, maxLineBytes - 96),
|
Math.max(1, maxLineBytes - 96),
|
||||||
explicitRawLength,
|
explicitRawLength
|
||||||
incarnationId
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
let consumed = 0
|
let consumed = 0
|
||||||
@@ -191,8 +163,6 @@ export function writeStreamDataEvents(
|
|||||||
consumed += chunk.length
|
consumed += chunk.length
|
||||||
const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed)
|
const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed)
|
||||||
const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined
|
const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined
|
||||||
streamSocket.write(
|
streamSocket.write(encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq))
|
||||||
encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq, false, incarnationId)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export type DataEvent = {
|
|||||||
sessionId: string
|
sessionId: string
|
||||||
payload: {
|
payload: {
|
||||||
data: string
|
data: string
|
||||||
incarnationId?: PtyIncarnationId
|
|
||||||
seq?: number
|
seq?: number
|
||||||
rawLength?: number
|
rawLength?: number
|
||||||
transformed?: boolean
|
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,
|
accountDaemonStreamEntry,
|
||||||
releaseDaemonStreamEntry
|
releaseDaemonStreamEntry
|
||||||
} from './daemon-stream-entry-accounting'
|
} from './daemon-stream-entry-accounting'
|
||||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
|
||||||
|
|
||||||
// A control entry carries a whole pre-shaped stream event (background marker,
|
// 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
|
// data gap, transient fact) that must ride at its exact position in the
|
||||||
@@ -28,7 +27,6 @@ export type StreamQueueEntry = {
|
|||||||
sequenceChars?: number
|
sequenceChars?: number
|
||||||
seq?: number
|
seq?: number
|
||||||
transformed?: boolean
|
transformed?: boolean
|
||||||
incarnationId?: PtyIncarnationId
|
|
||||||
control?: DaemonEvent
|
control?: DaemonEvent
|
||||||
retainedBytes?: number
|
retainedBytes?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,32 +173,23 @@ export class DaemonTerminalAdmission {
|
|||||||
clientId: string,
|
clientId: string,
|
||||||
sessionId: () => string
|
sessionId: () => string
|
||||||
): CreateOrAttachOptions['streamClient'] {
|
): 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 {
|
return {
|
||||||
onData,
|
onData: (data, rawLength = data.length, transformed = false, seq) => {
|
||||||
onDataWithIncarnation: onData,
|
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) => {
|
onExit: (code, incarnationId, cause) => {
|
||||||
const routedSessionId = sessionId()
|
const routedSessionId = sessionId()
|
||||||
this.options.log.log('session-exited', {
|
this.options.log.log('session-exited', {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { SessionOutputPlane } from './session-output-plane'
|
import { SessionOutputPlane } from './session-output-plane'
|
||||||
import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier'
|
import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier'
|
||||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
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
|
/** The session's ordered output pipeline: the recovery barrier feeding the
|
||||||
* output plane. Built together because the barrier's owner is what the
|
* output plane. Built together because the barrier's owner is what the
|
||||||
@@ -12,7 +11,6 @@ export function createSessionOutputPipeline(opts: {
|
|||||||
scrollback?: number | undefined
|
scrollback?: number | undefined
|
||||||
wslDistro?: string | undefined
|
wslDistro?: string | undefined
|
||||||
historySeedChunks?: readonly string[] | undefined
|
historySeedChunks?: readonly string[] | undefined
|
||||||
incarnationId?: PtyIncarnationId | undefined
|
|
||||||
subprocess: SubprocessHandle
|
subprocess: SubprocessHandle
|
||||||
isAlive: () => boolean
|
isAlive: () => boolean
|
||||||
}): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } {
|
}): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } {
|
||||||
@@ -24,7 +22,6 @@ export function createSessionOutputPipeline(opts: {
|
|||||||
scrollback: opts.scrollback,
|
scrollback: opts.scrollback,
|
||||||
wslDistro: opts.wslDistro,
|
wslDistro: opts.wslDistro,
|
||||||
historySeedChunks: opts.historySeedChunks,
|
historySeedChunks: opts.historySeedChunks,
|
||||||
incarnationId: opts.incarnationId,
|
|
||||||
getTerminalOwner: () => barrier?.getOwner()
|
getTerminalOwner: () => barrier?.getOwner()
|
||||||
})
|
})
|
||||||
const recoveryBarrier = new TerminalShellRecoveryBarrier({
|
const recoveryBarrier = new TerminalShellRecoveryBarrier({
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { normalizePtySize } from './daemon-pty-size'
|
|||||||
import type { PtyIngressEmission } from '../../shared/pty-startup-ingress'
|
import type { PtyIngressEmission } from '../../shared/pty-startup-ingress'
|
||||||
import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types'
|
import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types'
|
||||||
import type { TerminalOwner } from '../../shared/terminal-owner'
|
import type { TerminalOwner } from '../../shared/terminal-owner'
|
||||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
|
||||||
import type { SubprocessHandle } from './session-subprocess-handle'
|
import type { SubprocessHandle } from './session-subprocess-handle'
|
||||||
import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint'
|
import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint'
|
||||||
|
|
||||||
@@ -16,14 +15,6 @@ const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
|
|||||||
export type AttachedClient = {
|
export type AttachedClient = {
|
||||||
token: symbol
|
token: symbol
|
||||||
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
|
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
|
onExit: (code: number, incarnationId: string, cause?: TerminalExitCause) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +24,6 @@ export type SessionOutputPlaneOptions = {
|
|||||||
scrollback?: number | undefined
|
scrollback?: number | undefined
|
||||||
wslDistro?: string | undefined
|
wslDistro?: string | undefined
|
||||||
historySeedChunks?: readonly string[] | undefined
|
historySeedChunks?: readonly string[] | undefined
|
||||||
incarnationId?: PtyIncarnationId | undefined
|
|
||||||
/** Read from the recovery barrier at snapshot time; the barrier scans bytes
|
/** Read from the recovery barrier at snapshot time; the barrier scans bytes
|
||||||
* before this plane receives them, so its owner never lags the emulator. */
|
* before this plane receives them, so its owner never lags the emulator. */
|
||||||
getTerminalOwner?: (() => TerminalOwner | undefined) | undefined
|
getTerminalOwner?: (() => TerminalOwner | undefined) | undefined
|
||||||
@@ -45,7 +35,6 @@ export class SessionOutputPlane {
|
|||||||
readonly historySeeded: boolean | undefined
|
readonly historySeeded: boolean | undefined
|
||||||
private readonly emulator: HeadlessEmulator
|
private readonly emulator: HeadlessEmulator
|
||||||
private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined
|
private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined
|
||||||
private readonly incarnationId: PtyIncarnationId | undefined
|
|
||||||
private attachedClients: AttachedClient[] = []
|
private attachedClients: AttachedClient[] = []
|
||||||
private pendingOutputRecords: PendingOutputRecord[] = []
|
private pendingOutputRecords: PendingOutputRecord[] = []
|
||||||
private pendingOutputBytes = 0
|
private pendingOutputBytes = 0
|
||||||
@@ -75,7 +64,6 @@ export class SessionOutputPlane {
|
|||||||
? undefined
|
? undefined
|
||||||
: opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk))
|
: opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk))
|
||||||
this.readTerminalOwner = opts.getTerminalOwner
|
this.readTerminalOwner = opts.getTerminalOwner
|
||||||
this.incarnationId = opts.incarnationId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get responderParser(): HeadlessEmulator['responderParser'] {
|
get responderParser(): HeadlessEmulator['responderParser'] {
|
||||||
@@ -209,7 +197,9 @@ export class SessionOutputPlane {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.record({ kind: 'output', data: pending })
|
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 {
|
emit(emission: PtyIngressEmission): void {
|
||||||
@@ -226,24 +216,9 @@ export class SessionOutputPlane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast to attached clients
|
// 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) {
|
for (const client of this.attachedClients) {
|
||||||
if (client.onDataWithIncarnation && this.incarnationId) {
|
if (emission.transformed || rawLength !== data.length) {
|
||||||
client.onDataWithIncarnation(data, rawLength, transformed, seq, this.incarnationId)
|
client.onData(data, rawLength, true, this._outputSequence)
|
||||||
} else if (transformed || rawLength !== undefined || seq !== undefined) {
|
|
||||||
client.onData(data, rawLength, transformed, seq)
|
|
||||||
} else {
|
} else {
|
||||||
client.onData(data)
|
client.onData(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import type { TuiAgent } from '../../shared/tui-agent'
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { PtyStartupIngress } from '../../shared/pty-startup-ingress'
|
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 { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection'
|
||||||
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
|
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
|
||||||
|
|
||||||
@@ -23,7 +28,7 @@ export class Session {
|
|||||||
readonly launchAgent: TuiAgent | null
|
readonly launchAgent: TuiAgent | null
|
||||||
readonly wslDistro: string | null
|
readonly wslDistro: string | null
|
||||||
readonly processNameIsSpawnFile: boolean
|
readonly processNameIsSpawnFile: boolean
|
||||||
private _state: SessionProtocol.SessionState = 'running'
|
private _state: SessionState = 'running'
|
||||||
private _exitCode: number | null = null
|
private _exitCode: number | null = null
|
||||||
private _disposed = false
|
private _disposed = false
|
||||||
private subprocess: SubprocessHandle
|
private subprocess: SubprocessHandle
|
||||||
@@ -50,8 +55,7 @@ export class Session {
|
|||||||
wslDistro: opts.wslDistro,
|
wslDistro: opts.wslDistro,
|
||||||
historySeedChunks: opts.historySeedChunks,
|
historySeedChunks: opts.historySeedChunks,
|
||||||
subprocess: this.subprocess,
|
subprocess: this.subprocess,
|
||||||
isAlive: () => !this._disposed && this._state !== 'exited',
|
isAlive: () => !this._disposed && this._state !== 'exited'
|
||||||
incarnationId: this.incarnationId
|
|
||||||
})
|
})
|
||||||
this.output = pipeline.output
|
this.output = pipeline.output
|
||||||
this.recoveryBarrier = pipeline.recoveryBarrier
|
this.recoveryBarrier = pipeline.recoveryBarrier
|
||||||
@@ -91,11 +95,11 @@ export class Session {
|
|||||||
this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause))
|
this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause))
|
||||||
}
|
}
|
||||||
|
|
||||||
get state(): SessionProtocol.SessionState {
|
get state(): SessionState {
|
||||||
return this._state
|
return this._state
|
||||||
}
|
}
|
||||||
|
|
||||||
get shellState(): SessionProtocol.ShellReadyState {
|
get shellState(): ShellReadyState {
|
||||||
return this.shellReady.state
|
return this.shellReady.state
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +216,7 @@ export class Session {
|
|||||||
this.producerPause.release({ resume: true })
|
this.producerPause.release({ resume: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot(opts: { scrollbackRows?: number } = {}): SessionProtocol.TerminalSnapshot | null {
|
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
|
||||||
this.startupIngress.snapshotBarrier()
|
this.startupIngress.snapshotBarrier()
|
||||||
return this.output.getSnapshot(opts)
|
return this.output.getSnapshot(opts)
|
||||||
}
|
}
|
||||||
@@ -228,7 +232,7 @@ export class Session {
|
|||||||
takePendingOutput(
|
takePendingOutput(
|
||||||
includeSnapshot: boolean,
|
includeSnapshot: boolean,
|
||||||
opts: { teardownSnapshot?: boolean } = {}
|
opts: { teardownSnapshot?: boolean } = {}
|
||||||
): SessionProtocol.TakePendingOutputResult | null {
|
): TakePendingOutputResult | null {
|
||||||
if (this._disposed) {
|
if (this._disposed) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,13 +37,6 @@ export type CreateOrAttachOptions = {
|
|||||||
}
|
}
|
||||||
streamClient: {
|
streamClient: {
|
||||||
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
|
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
|
onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void
|
||||||
}
|
}
|
||||||
/** Lets the daemon route output under the adopted owner's canonical id before
|
/** 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 () => {
|
it('reads either Git auto-maintenance opt-out, and unset keys as consent', async () => {
|
||||||
for (const stdout of [
|
for (const stdout of [
|
||||||
'maintenance.auto false\n',
|
'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 0\n',
|
||||||
|
'gc.auto 0k\n',
|
||||||
'gc.auto 6700\nmaintenance.auto false\n'
|
'gc.auto 6700\nmaintenance.auto false\n'
|
||||||
]) {
|
]) {
|
||||||
gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' })
|
gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' })
|
||||||
@@ -91,10 +96,20 @@ describe('local repo ref maintenance target', () => {
|
|||||||
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false)
|
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false)
|
||||||
|
|
||||||
// `git config --get-regexp` exits non-zero when nothing matches.
|
// `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)
|
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 () => {
|
it('walks the POSIX refs directory for a native repo', async () => {
|
||||||
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
|
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import {
|
|||||||
import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
|
import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
|
||||||
import { withSpan } from '../observability/tracer'
|
import { withSpan } from '../observability/tracer'
|
||||||
import { PackRefsLockOwnership } from './pack-refs-lock-ownership'
|
import { PackRefsLockOwnership } from './pack-refs-lock-ownership'
|
||||||
|
import {
|
||||||
|
clearRepoPackIndexMaintenanceCache,
|
||||||
|
isUnsetGitConfigError,
|
||||||
|
maintainRepoPackIndex
|
||||||
|
} from './repo-pack-index-maintenance'
|
||||||
import { gitExecFileAsync } from './runner'
|
import { gitExecFileAsync } from './runner'
|
||||||
import { readRepoCommonDirFromGit } from './worktree-list-reader'
|
import { readRepoCommonDirFromGit } from './worktree-list-reader'
|
||||||
|
|
||||||
@@ -96,6 +101,7 @@ export function disposeLocalRepoRefMaintenance(): Promise<void> {
|
|||||||
shared?.dispose()
|
shared?.dispose()
|
||||||
shared = null
|
shared = null
|
||||||
repoBusyProbes.clear()
|
repoBusyProbes.clear()
|
||||||
|
clearRepoPackIndexMaintenanceCache()
|
||||||
return settling
|
return settling
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +155,7 @@ export function _resetLocalRepoRefMaintenanceForTests(
|
|||||||
shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null
|
shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null
|
||||||
activityProbe = null
|
activityProbe = null
|
||||||
repoBusyProbes.clear()
|
repoBusyProbes.clear()
|
||||||
|
clearRepoPackIndexMaintenanceCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,8 +181,11 @@ function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | unde
|
|||||||
export function isGitAutoMaintenanceDisabled(configOutput: string): boolean {
|
export function isGitAutoMaintenanceDisabled(configOutput: string): boolean {
|
||||||
return configOutput
|
return configOutput
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => line.trim())
|
.some(
|
||||||
.some((line) => line === 'maintenance.auto false' || line === 'gc.auto 0')
|
(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 }
|
{ cwd: args.repoPath, ...gitOptions, admissionTier: 'background', signal }
|
||||||
)
|
)
|
||||||
return isGitAutoMaintenanceDisabled(stdout)
|
return isGitAutoMaintenanceDisabled(stdout)
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Neither key set is the common case and exits non-zero; that is consent.
|
// An unset key is consent; unreadable or invalid config must fail closed.
|
||||||
return false
|
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) {
|
async packRefs(lock: PackedRefsLockReporter) {
|
||||||
const resolved = await resolveCommonDir()
|
const resolved = await resolveCommonDir()
|
||||||
const owner = resolved
|
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 { 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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { countLooseRefs } from '../../shared/loose-ref-count'
|
import { countLooseRefs } from '../../shared/loose-ref-count'
|
||||||
import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance'
|
import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance'
|
||||||
|
import { PACK_INDEX_MAINTENANCE_COOLDOWN_MS } from '../../shared/repo-pack-index-maintenance-policy'
|
||||||
import {
|
import {
|
||||||
_resetLocalRepoRefMaintenanceForTests,
|
_resetLocalRepoRefMaintenanceForTests,
|
||||||
createLocalRepoRefMaintenanceTarget,
|
createLocalRepoRefMaintenanceTarget,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
setRepoMaintenanceActivityProbe
|
setRepoMaintenanceActivityProbe
|
||||||
} from './local-repo-ref-maintenance'
|
} from './local-repo-ref-maintenance'
|
||||||
import { forceDeleteLocalBranch } from './worktree-branch-removal'
|
import { forceDeleteLocalBranch } from './worktree-branch-removal'
|
||||||
|
import { maintainRepoPackIndex, PACK_INDEX_THRESHOLD } from './repo-pack-index-maintenance'
|
||||||
|
|
||||||
const roots: string[] = []
|
const roots: string[] = []
|
||||||
// Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts
|
// 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 QUIET_MS = 25
|
||||||
const THRESHOLD = 20
|
const THRESHOLD = 20
|
||||||
|
|
||||||
function git(cwd: string, args: string[]): string {
|
function git(cwd: string, args: string[], input?: string): string {
|
||||||
return execFileSync('git', args, {
|
return execFileSync('git', args, {
|
||||||
cwd,
|
cwd,
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
|
input,
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
}).trim()
|
}).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. */
|
/** A repo whose only loose-ref backlog is the one the test asks for. */
|
||||||
async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> {
|
async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-'))
|
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') }
|
return { repoPath, refsDir: join(repoPath, '.git', 'refs') }
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMaintenance(onPackRefs: () => void = () => {}): {
|
function createMaintenance(
|
||||||
|
onPackRefs: () => void = () => {},
|
||||||
|
now?: () => number
|
||||||
|
): {
|
||||||
maintenance: RepoRefMaintenance
|
maintenance: RepoRefMaintenance
|
||||||
arm: (repoPath: string) => void
|
arm: (repoPath: string) => void
|
||||||
} {
|
} {
|
||||||
const maintenance = new RepoRefMaintenance({
|
const maintenance = new RepoRefMaintenance({
|
||||||
quietPeriodMs: QUIET_MS,
|
quietPeriodMs: QUIET_MS,
|
||||||
looseRefThreshold: THRESHOLD
|
looseRefThreshold: THRESHOLD,
|
||||||
|
...(now ? { now } : {})
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
maintenance,
|
maintenance,
|
||||||
@@ -99,6 +142,117 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('idle ref maintenance against real Git', () => {
|
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 () => {
|
it('packs a backlogged repository down to zero loose refs', async () => {
|
||||||
const { repoPath, refsDir } = await createRepo(THRESHOLD + 30)
|
const { repoPath, refsDir } = await createRepo(THRESHOLD + 30)
|
||||||
const { maintenance, arm } = createMaintenance()
|
const { maintenance, arm } = createMaintenance()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type * as WorktreePreparationLock from './worktree-preparation-lock'
|
||||||
|
|
||||||
type GitExec = (
|
type GitExec = (
|
||||||
args: string[],
|
args: string[],
|
||||||
@@ -8,6 +9,12 @@ type GitExec = (
|
|||||||
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn<GitExec>())
|
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn<GitExec>())
|
||||||
|
|
||||||
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
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 { addWorktree } from './worktree-add'
|
||||||
import { listWorktreesSharedStrict } from './worktree-scan-cache'
|
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 () => {
|
it('runs the prepared-checkout finalize at the tier the caller asked for', async () => {
|
||||||
await finalizePreparedWorktree('/repo', '/prepared', '/repo-wt', 'feature', 'main', false, {
|
await finalizePreparedWorktree(
|
||||||
admissionTier: 'interactive'
|
'/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)
|
const options = optionsForCommand(match)
|
||||||
expect(options, match).toHaveLength(1)
|
expect(options, match).toHaveLength(1)
|
||||||
expect(options[0], match).toMatchObject({ admissionTier: 'interactive' })
|
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,
|
prepared.preparedPath,
|
||||||
join(root, 'warm'),
|
join(root, 'warm'),
|
||||||
'warm',
|
'warm',
|
||||||
'main'
|
'main',
|
||||||
|
false,
|
||||||
|
{},
|
||||||
|
prepared.lockReason
|
||||||
)
|
)
|
||||||
expect(await listWorktrees(repo)).toHaveLength(3)
|
expect(await listWorktrees(repo)).toHaveLength(3)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,6 +55,241 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('prepared worktree creation with real Git', () => {
|
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])(
|
it.each([false, true])(
|
||||||
'attaches the prepared HEAD and runs the hook (base advanced: %s)',
|
'attaches the prepared HEAD and runs the hook (base advanced: %s)',
|
||||||
async (advanceBase) => {
|
async (advanceBase) => {
|
||||||
@@ -70,19 +305,24 @@ describe('prepared worktree creation with real Git', () => {
|
|||||||
)
|
)
|
||||||
git(repoPath, ['config', 'core.hooksPath', hooksPath])
|
git(repoPath, ['config', 'core.hooksPath', hooksPath])
|
||||||
git(repoPath, ['config', 'branch.autoSetupMerge', 'always'])
|
git(repoPath, ['config', 'branch.autoSetupMerge', 'always'])
|
||||||
await prepareWorktreeCreateCheckout(
|
const lockReason = createWorktreePreparationLockReason('attach-with-hook')
|
||||||
repoPath,
|
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
|
||||||
preparedPath,
|
|
||||||
'main',
|
|
||||||
createWorktreePreparationLockReason('attach-with-hook')
|
|
||||||
)
|
|
||||||
expect(existsSync(join(preparedPath, 'checkout-hook.txt'))).toBe(false)
|
expect(existsSync(join(preparedPath, 'checkout-hook.txt'))).toBe(false)
|
||||||
if (advanceBase) {
|
if (advanceBase) {
|
||||||
await writeFile(join(repoPath, 'version.txt'), 'advanced\n')
|
await writeFile(join(repoPath, 'version.txt'), 'advanced\n')
|
||||||
git(repoPath, ['commit', '--quiet', '-am', 'advance base'])
|
git(repoPath, ['commit', '--quiet', '-am', 'advance base'])
|
||||||
}
|
}
|
||||||
const targetHead = git(repoPath, ['rev-parse', 'HEAD'])
|
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, ['rev-parse', 'HEAD'])).toBe(targetHead)
|
||||||
expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/attached')
|
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 { repoPath, root } = await createRepo()
|
||||||
const preparedPath = join(root, 'prepared-race')
|
const preparedPath = join(root, 'prepared-race')
|
||||||
const finalPath = join(root, 'final-race')
|
const finalPath = join(root, 'final-race')
|
||||||
await prepareWorktreeCreateCheckout(
|
const lockReason = createWorktreePreparationLockReason('head-race')
|
||||||
repoPath,
|
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
|
||||||
preparedPath,
|
|
||||||
'main',
|
|
||||||
createWorktreePreparationLockReason('head-race')
|
|
||||||
)
|
|
||||||
const expectedHead = git(repoPath, ['rev-parse', 'HEAD'])
|
const expectedHead = git(repoPath, ['rev-parse', 'HEAD'])
|
||||||
git(repoPath, ['checkout', '--quiet', '-b', 'other'])
|
git(repoPath, ['checkout', '--quiet', '-b', 'other'])
|
||||||
await writeFile(join(repoPath, 'version.txt'), 'other\n')
|
await writeFile(join(repoPath, 'version.txt'), 'other\n')
|
||||||
@@ -125,7 +361,16 @@ describe('prepared worktree creation with real Git', () => {
|
|||||||
return original(args, options)
|
return original(args, options)
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/race', 'main')
|
await finalizePreparedWorktree(
|
||||||
|
repoPath,
|
||||||
|
preparedPath,
|
||||||
|
finalPath,
|
||||||
|
'feature/race',
|
||||||
|
'main',
|
||||||
|
false,
|
||||||
|
{},
|
||||||
|
lockReason
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
spy.mockRestore()
|
spy.mockRestore()
|
||||||
}
|
}
|
||||||
@@ -147,15 +392,20 @@ describe('prepared worktree creation with real Git', () => {
|
|||||||
{ mode: 0o755 }
|
{ mode: 0o755 }
|
||||||
)
|
)
|
||||||
git(repoPath, ['config', 'core.hooksPath', hooksPath])
|
git(repoPath, ['config', 'core.hooksPath', hooksPath])
|
||||||
await prepareWorktreeCreateCheckout(
|
const lockReason = createWorktreePreparationLockReason('hook-commit')
|
||||||
repoPath,
|
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
|
||||||
preparedPath,
|
|
||||||
'main',
|
|
||||||
createWorktreePreparationLockReason('hook-commit')
|
|
||||||
)
|
|
||||||
const baseHead = git(repoPath, ['rev-parse', 'HEAD'])
|
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, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/hook-commit')
|
||||||
expect(git(finalPath, ['rev-parse', 'HEAD^'])).toBe(baseHead)
|
expect(git(finalPath, ['rev-parse', 'HEAD^'])).toBe(baseHead)
|
||||||
@@ -172,15 +422,20 @@ describe('prepared worktree creation with real Git', () => {
|
|||||||
await mkdir(hooksPath)
|
await mkdir(hooksPath)
|
||||||
await writeFile(join(hooksPath, 'post-checkout'), '#!/bin/sh\nexit 1\n', { mode: 0o755 })
|
await writeFile(join(hooksPath, 'post-checkout'), '#!/bin/sh\nexit 1\n', { mode: 0o755 })
|
||||||
git(repoPath, ['config', 'core.hooksPath', hooksPath])
|
git(repoPath, ['config', 'core.hooksPath', hooksPath])
|
||||||
await prepareWorktreeCreateCheckout(
|
const lockReason = createWorktreePreparationLockReason('hook-failure')
|
||||||
repoPath,
|
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
|
||||||
preparedPath,
|
|
||||||
'main',
|
|
||||||
createWorktreePreparationLockReason('hook-failure')
|
|
||||||
)
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-failure', 'main')
|
finalizePreparedWorktree(
|
||||||
|
repoPath,
|
||||||
|
preparedPath,
|
||||||
|
finalPath,
|
||||||
|
'feature/hook-failure',
|
||||||
|
'main',
|
||||||
|
false,
|
||||||
|
{},
|
||||||
|
lockReason
|
||||||
|
)
|
||||||
).rejects.toThrow()
|
).rejects.toThrow()
|
||||||
expect(existsSync(finalPath)).toBe(false)
|
expect(existsSync(finalPath)).toBe(false)
|
||||||
expect(git(repoPath, ['branch', '--list', 'feature/hook-failure'])).toBe('')
|
expect(git(repoPath, ['branch', '--list', 'feature/hook-failure'])).toBe('')
|
||||||
@@ -202,7 +457,7 @@ describe('prepared worktree creation with real Git', () => {
|
|||||||
return original(args, options)
|
return original(args, options)
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
await expect(discardPreparedWorktree(repoPath, preparedPath)).rejects.toThrow(
|
await expect(discardPreparedWorktree(repoPath, preparedPath, {}, lockReason)).rejects.toThrow(
|
||||||
'injected removal launch failure'
|
'injected removal launch failure'
|
||||||
)
|
)
|
||||||
const remaining = await listWorktrees(repoPath, { includeCreatePreparations: true })
|
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')
|
expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('one\n')
|
||||||
} finally {
|
} finally {
|
||||||
spy.mockRestore()
|
spy.mockRestore()
|
||||||
await discardPreparedWorktree(repoPath, preparedPath)
|
await discardPreparedWorktree(repoPath, preparedPath, {}, lockReason)
|
||||||
}
|
}
|
||||||
expect(existsSync(preparedPath)).toBe(false)
|
expect(existsSync(preparedPath)).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -265,7 +520,16 @@ describe('prepared worktree creation with real Git', () => {
|
|||||||
expect(entry).toBeDefined()
|
expect(entry).toBeDefined()
|
||||||
takePreparation(entry)
|
takePreparation(entry)
|
||||||
const finalPath = join(root, 'fresh-worktree')
|
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, ['status', '--porcelain'])).toBe('')
|
||||||
expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('fresh')
|
expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('fresh')
|
||||||
expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('one\n')
|
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`)
|
const preparedPath = join(preparationRoot, `${process.pid}-canceled`)
|
||||||
await mkdir(preparationRoot, { recursive: true })
|
await mkdir(preparationRoot, { recursive: true })
|
||||||
|
|
||||||
await prepareWorktreeCreateCheckout(
|
const lockReason = createWorktreePreparationLockReason('canceled-test')
|
||||||
repoPath,
|
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
|
||||||
preparedPath,
|
|
||||||
'main',
|
|
||||||
createWorktreePreparationLockReason('canceled-test')
|
|
||||||
)
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
controller.abort()
|
controller.abort()
|
||||||
await expect(
|
await expect(
|
||||||
discardPreparedWorktree(repoPath, preparedPath, { signal: controller.signal })
|
discardPreparedWorktree(repoPath, preparedPath, { signal: controller.signal }, lockReason)
|
||||||
).resolves.toBeUndefined()
|
).resolves.toBeUndefined()
|
||||||
|
|
||||||
expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1)
|
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)
|
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)
|
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.
|
// 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,
|
preparedPath,
|
||||||
finalPath,
|
finalPath,
|
||||||
'feature/overlap',
|
'feature/overlap',
|
||||||
'refs/remotes/origin/main'
|
'refs/remotes/origin/main',
|
||||||
|
false,
|
||||||
|
{},
|
||||||
|
createWorktreePreparationLockReason('fetch-overlap')
|
||||||
)
|
)
|
||||||
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(refreshed)
|
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(refreshed)
|
||||||
expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('refreshed\n')
|
expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('refreshed\n')
|
||||||
@@ -497,7 +769,9 @@ describe('prepared worktree creation with real Git', () => {
|
|||||||
finalPath,
|
finalPath,
|
||||||
'feature/prepared',
|
'feature/prepared',
|
||||||
'main',
|
'main',
|
||||||
false
|
false,
|
||||||
|
{},
|
||||||
|
createWorktreePreparationLockReason('real-git-test')
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(latestHead)
|
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 { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation'
|
||||||
import { gitExecFileAsync } from './runner'
|
import { gitExecFileAsync } from './runner'
|
||||||
import {
|
import {
|
||||||
discardPreparedWorktree,
|
|
||||||
finalizePreparedWorktree,
|
finalizePreparedWorktree,
|
||||||
prepareWorktreeCreateCheckout
|
prepareWorktreeCreateCheckout
|
||||||
} from './worktree-create-preparation'
|
} 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.
|
// Opt in on Windows with a running distro; all Git commands use the production WSL router.
|
||||||
const wslDistro = process.env.ORCA_TEST_WSL_DISTRO
|
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 writeFile(join(repoPath, 'version.txt'), 'one\n')
|
||||||
await git(repoPath, ['add', 'version.txt'])
|
await git(repoPath, ['add', 'version.txt'])
|
||||||
await git(repoPath, ['commit', '--quiet', '-m', 'initial'])
|
await git(repoPath, ['commit', '--quiet', '-m', 'initial'])
|
||||||
await prepareWorktreeCreateCheckout(
|
const lockReason = createWorktreePreparationLockReason('real-wsl-test')
|
||||||
repoPath,
|
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason, options)
|
||||||
preparedPath,
|
|
||||||
'main',
|
|
||||||
createWorktreePreparationLockReason('real-wsl-test'),
|
|
||||||
options
|
|
||||||
)
|
|
||||||
expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).toContain(
|
expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).toContain(
|
||||||
'locked orca-create-preparation:v1:'
|
'locked orca-create-preparation:v1:'
|
||||||
)
|
)
|
||||||
@@ -55,7 +50,8 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)(
|
|||||||
'feature/routed',
|
'feature/routed',
|
||||||
'main',
|
'main',
|
||||||
false,
|
false,
|
||||||
options
|
options,
|
||||||
|
lockReason
|
||||||
)
|
)
|
||||||
expect(await git(finalPath, ['rev-parse', 'HEAD'])).toBe(target)
|
expect(await git(finalPath, ['rev-parse', 'HEAD'])).toBe(target)
|
||||||
expect(await git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/routed')
|
expect(await git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/routed')
|
||||||
@@ -65,7 +61,7 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)(
|
|||||||
'refs/heads/main'
|
'refs/heads/main'
|
||||||
)
|
)
|
||||||
expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain('locked ')
|
expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain('locked ')
|
||||||
await discardPreparedWorktree(repoPath, finalPath, options)
|
await removeWorktree(repoPath, finalPath, true, options)
|
||||||
expect(
|
expect(
|
||||||
(await git(repoPath, ['worktree', 'list', '--porcelain'])).match(/^worktree /gm)
|
(await git(repoPath, ['worktree', 'list', '--porcelain'])).match(/^worktree /gm)
|
||||||
).toHaveLength(1)
|
).toHaveLength(1)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
|
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
|
||||||
|
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
|
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
|
||||||
import type { AddWorktreeOptions, AddWorktreeResult, GitWorktreeExecOptions } from './worktree'
|
import type { AddWorktreeOptions, AddWorktreeResult, GitWorktreeExecOptions } from './worktree'
|
||||||
import { gitExecOptions, type GitExecOptionsForWorktree } from './worktree-operation-options'
|
import { gitExecOptions } from './worktree-operation-options'
|
||||||
import {
|
import {
|
||||||
configurePushAutoSetupRemote,
|
configurePushAutoSetupRemote,
|
||||||
notifyPreparedWorktreeMutation,
|
notifyPreparedWorktreeMutation,
|
||||||
@@ -10,93 +11,77 @@ import {
|
|||||||
resolveWorktreeAddTimeoutMs,
|
resolveWorktreeAddTimeoutMs,
|
||||||
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
||||||
} from './worktree'
|
} from './worktree'
|
||||||
|
import {
|
||||||
|
gitCleanupOptions,
|
||||||
|
performDiscardPreparedWorktree,
|
||||||
|
removeFailedFinalization
|
||||||
|
} from './worktree-preparation-discard'
|
||||||
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
|
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
|
||||||
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
|
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
|
||||||
import { gitExecFileAsync } from './runner'
|
import { gitExecFileAsync } from './runner'
|
||||||
import { runWithGitReadCacheInvalidation } from './status'
|
import { runWithGitReadCacheInvalidation } from './status'
|
||||||
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
|
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
|
||||||
|
import {
|
||||||
function gitCleanupOptions(
|
unlockWorktreePreparation,
|
||||||
cwd: string,
|
unlockWorktreePreparationAtPath,
|
||||||
options: GitWorktreeExecOptions
|
verifyWorktreePreparationLock,
|
||||||
): GitExecOptionsForWorktree {
|
verifyWorktreePreparationLockAtPath,
|
||||||
// Why: cancellation must not strand a partially moved worktree; cleanup is bounded separately.
|
WorktreePreparationLockOwnershipError
|
||||||
return gitExecOptions(cwd, { ...options, signal: undefined })
|
} from './worktree-preparation-lock'
|
||||||
}
|
import { addLockedWorktreePreparation } from './worktree-preparation-add'
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function prepareWorktreeCreateCheckout(
|
export async function prepareWorktreeCreateCheckout(
|
||||||
repoPath: string,
|
repoPath: string,
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
baseBranch: string,
|
baseBranch: string,
|
||||||
lockReason: string,
|
lockReason: string,
|
||||||
options: GitWorktreeExecOptions = {}
|
options: GitWorktreeExecOptions = {},
|
||||||
|
beforeMaterialization?: Promise<void>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Observe early rejection while registration runs; awaiting still reports the original error.
|
||||||
|
void beforeMaterialization?.catch(() => {})
|
||||||
try {
|
try {
|
||||||
await withRepoRefMaintenancePaused('worktree-prepare', () =>
|
await withRepoRefMaintenancePaused('worktree-prepare', () =>
|
||||||
runWithGitReadCacheInvalidation(async () => {
|
runWithGitReadCacheInvalidation(async () => {
|
||||||
const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
|
const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
|
||||||
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
|
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
|
||||||
)
|
)
|
||||||
|
let lockPath: string | undefined
|
||||||
try {
|
try {
|
||||||
await gitExecFileAsync(
|
lockPath = await addLockedWorktreePreparation(
|
||||||
[
|
repoPath,
|
||||||
...windowsLongPathGitArgs(repoPath),
|
worktreePath,
|
||||||
'worktree',
|
effectiveBase,
|
||||||
'add',
|
lockReason,
|
||||||
'--detach',
|
{
|
||||||
'--no-checkout',
|
...options,
|
||||||
worktreePath,
|
timeout: resolveWorktreeAddTimeoutMs()
|
||||||
effectiveBase
|
}
|
||||||
],
|
|
||||||
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
|
||||||
)
|
)
|
||||||
// The add just wrote the marker; drop any pre-create route before the reset routes Git.
|
await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal)
|
||||||
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
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.
|
// Why: reset materializes files without running user post-checkout hooks before submit.
|
||||||
await gitExecFileAsync(
|
await gitExecFileAsync(
|
||||||
[...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase],
|
[...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', materializationBase],
|
||||||
{ ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
{ ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
||||||
)
|
)
|
||||||
await gitExecFileAsync(
|
await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal)
|
||||||
[
|
|
||||||
...windowsLongPathGitArgs(repoPath),
|
|
||||||
'worktree',
|
|
||||||
'lock',
|
|
||||||
'--reason',
|
|
||||||
lockReason,
|
|
||||||
worktreePath
|
|
||||||
],
|
|
||||||
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {})
|
if (lockPath !== undefined && !(error instanceof WorktreePreparationLockOwnershipError)) {
|
||||||
|
await performDiscardPreparedWorktree(repoPath, worktreePath, options, lockReason).catch(
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -109,11 +94,12 @@ export async function prepareWorktreeCreateCheckout(
|
|||||||
export async function discardPreparedWorktree(
|
export async function discardPreparedWorktree(
|
||||||
repoPath: string,
|
repoPath: string,
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
options: GitWorktreeExecOptions = {}
|
options: GitWorktreeExecOptions = {},
|
||||||
|
expectedLockReason: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await runWithGitReadCacheInvalidation(() =>
|
await runWithGitReadCacheInvalidation(() =>
|
||||||
performDiscardPreparedWorktree(repoPath, worktreePath, options)
|
performDiscardPreparedWorktree(repoPath, worktreePath, options, expectedLockReason)
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
notifyPreparedWorktreeMutation(repoPath)
|
notifyPreparedWorktreeMutation(repoPath)
|
||||||
@@ -123,52 +109,22 @@ export async function discardPreparedWorktree(
|
|||||||
export async function unlockPreparedWorktree(
|
export async function unlockPreparedWorktree(
|
||||||
repoPath: string,
|
repoPath: string,
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
options: GitWorktreeExecOptions = {}
|
options: GitWorktreeExecOptions = {},
|
||||||
|
expectedLockReason: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cleanupGitOptions = {
|
const cleanupGitOptions = {
|
||||||
...gitCleanupOptions(repoPath, options),
|
...gitCleanupOptions(repoPath, options),
|
||||||
timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await runWithGitReadCacheInvalidation(() =>
|
await runWithGitReadCacheInvalidation(async () => {
|
||||||
gitExecFileAsync(
|
await unlockWorktreePreparation(worktreePath, expectedLockReason, cleanupGitOptions)
|
||||||
[...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath],
|
})
|
||||||
cleanupGitOptions
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
notifyPreparedWorktreeMutation(repoPath)
|
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(
|
export async function finalizePreparedWorktree(
|
||||||
repoPath: string,
|
repoPath: string,
|
||||||
preparedPath: string,
|
preparedPath: string,
|
||||||
@@ -176,7 +132,8 @@ export async function finalizePreparedWorktree(
|
|||||||
branch: string,
|
branch: string,
|
||||||
baseBranch: string,
|
baseBranch: string,
|
||||||
refreshLocalBaseRef = false,
|
refreshLocalBaseRef = false,
|
||||||
options: AddWorktreeOptions = {}
|
options: AddWorktreeOptions = {},
|
||||||
|
expectedLockReason: string
|
||||||
): Promise<AddWorktreeResult> {
|
): Promise<AddWorktreeResult> {
|
||||||
const finalizeGitOptions: AddWorktreeOptions = {
|
const finalizeGitOptions: AddWorktreeOptions = {
|
||||||
...options,
|
...options,
|
||||||
@@ -184,6 +141,13 @@ export async function finalizePreparedWorktree(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await runWithGitReadCacheInvalidation(async () => {
|
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([
|
const [targetResult, preparedResult] = await Promise.allSettled([
|
||||||
(async () => {
|
(async () => {
|
||||||
const baseContext = await resolveWorktreeAddBaseContext(
|
const baseContext = await resolveWorktreeAddBaseContext(
|
||||||
@@ -219,6 +183,7 @@ export async function finalizePreparedWorktree(
|
|||||||
}
|
}
|
||||||
const preparedHeadOutput = preparedResult.value.stdout
|
const preparedHeadOutput = preparedResult.value.stdout
|
||||||
if (preparedHeadOutput.trim() !== targetHead) {
|
if (preparedHeadOutput.trim() !== targetHead) {
|
||||||
|
await verifyOwnership()
|
||||||
await gitExecFileAsync(
|
await gitExecFileAsync(
|
||||||
[...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead],
|
[...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead],
|
||||||
gitExecOptions(preparedPath, finalizeGitOptions)
|
gitExecOptions(preparedPath, finalizeGitOptions)
|
||||||
@@ -228,6 +193,7 @@ export async function finalizePreparedWorktree(
|
|||||||
let moved = false
|
let moved = false
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
|
await verifyOwnership()
|
||||||
// Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25).
|
// Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25).
|
||||||
await gitExecFileAsync(
|
await gitExecFileAsync(
|
||||||
[
|
[
|
||||||
@@ -247,6 +213,7 @@ export async function finalizePreparedWorktree(
|
|||||||
invalidateWslLinkedWorktreeGitRouting(preparedPath)
|
invalidateWslLinkedWorktreeGitRouting(preparedPath)
|
||||||
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
invalidateWslLinkedWorktreeGitRouting(worktreePath)
|
||||||
}
|
}
|
||||||
|
await verifyOwnership()
|
||||||
await gitExecFileAsync(
|
await gitExecFileAsync(
|
||||||
[
|
[
|
||||||
...windowsLongPathGitArgs(worktreePath),
|
...windowsLongPathGitArgs(worktreePath),
|
||||||
@@ -258,6 +225,7 @@ export async function finalizePreparedWorktree(
|
|||||||
],
|
],
|
||||||
gitExecOptions(worktreePath, finalizeGitOptions)
|
gitExecOptions(worktreePath, finalizeGitOptions)
|
||||||
)
|
)
|
||||||
|
await verifyOwnership()
|
||||||
await persistWorktreeCreationBase(
|
await persistWorktreeCreationBase(
|
||||||
worktreePath,
|
worktreePath,
|
||||||
branch,
|
branch,
|
||||||
@@ -265,18 +233,22 @@ export async function finalizePreparedWorktree(
|
|||||||
finalizeGitOptions
|
finalizeGitOptions
|
||||||
)
|
)
|
||||||
await configurePushAutoSetupRemote(worktreePath, finalizeGitOptions)
|
await configurePushAutoSetupRemote(worktreePath, finalizeGitOptions)
|
||||||
await gitExecFileAsync(
|
await unlockWorktreePreparationAtPath(
|
||||||
[...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath],
|
lockPath,
|
||||||
gitExecOptions(repoPath, finalizeGitOptions)
|
expectedLockReason,
|
||||||
|
finalizeGitOptions.signal
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await removeFailedFinalization(
|
if (!(error instanceof WorktreePreparationLockOwnershipError)) {
|
||||||
repoPath,
|
await removeFailedFinalization(
|
||||||
moved ? worktreePath : preparedPath,
|
repoPath,
|
||||||
branch,
|
moved ? worktreePath : preparedPath,
|
||||||
moved,
|
branch,
|
||||||
finalizeGitOptions
|
moved,
|
||||||
)
|
finalizeGitOptions,
|
||||||
|
expectedLockReason
|
||||||
|
)
|
||||||
|
}
|
||||||
await baseContext.pendingLocalBaseRefRefresh
|
await baseContext.pendingLocalBaseRefRefresh
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Worktree add/move/remove/rollback rewrite the `.git` marker the WSL Git route was derived from.
|
// 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type * as WorktreeModule from './worktree'
|
import type * as WorktreeModule from './worktree'
|
||||||
|
import type * as WorktreePreparationLock from './worktree-preparation-lock'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
gitExecFileAsyncMock,
|
gitExecFileAsyncMock,
|
||||||
@@ -38,6 +39,14 @@ vi.mock('./worktree-scan-cache', () => ({
|
|||||||
listWorktrees: listWorktreesMock
|
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 { addWorktree } from './worktree-add'
|
||||||
import {
|
import {
|
||||||
discardPreparedWorktree,
|
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 () => {
|
it('drops the route after a prepared worktree is discarded', async () => {
|
||||||
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
||||||
|
|
||||||
await discardPreparedWorktree(REPO, LINKED)
|
await discardPreparedWorktree(REPO, LINKED, {}, 'owner')
|
||||||
|
|
||||||
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -133,11 +142,41 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () =
|
|||||||
expect(hasCachedHostRoute(PREPARED)).toBe(false)
|
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 () => {
|
it('drops both routes after the prepared checkout is moved into place', async () => {
|
||||||
seedWslLinkedWorktreeGitRoutingForTests(PREPARED)
|
seedWslLinkedWorktreeGitRoutingForTests(PREPARED)
|
||||||
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
|
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(PREPARED)).toBe(false)
|
||||||
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
expect(hasCachedHostRoute(LINKED)).toBe(false)
|
||||||
@@ -153,7 +192,7 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () =
|
|||||||
)
|
)
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main')
|
finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main', false, {}, 'owner')
|
||||||
).rejects.toThrow('destination exists')
|
).rejects.toThrow('destination exists')
|
||||||
|
|
||||||
expect(hasCachedHostRoute(PREPARED)).toBe(false)
|
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 { beforeEach, expect, it, vi } from 'vitest'
|
||||||
import type * as WorktreeBaseRefresh from './worktree-base-refresh'
|
import type * as WorktreeBaseRefresh from './worktree-base-refresh'
|
||||||
|
import type * as WorktreePreparationLock from './worktree-preparation-lock'
|
||||||
|
|
||||||
const gitExec = vi.hoisted(() => vi.fn())
|
const gitExec = vi.hoisted(() => vi.fn())
|
||||||
vi.mock('./runner', () => ({ gitExecFileAsync: gitExec }))
|
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) => ({
|
vi.mock('./worktree-base-refresh', async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof WorktreeBaseRefresh>()),
|
...(await importOriginal<typeof WorktreeBaseRefresh>()),
|
||||||
refreshLocalBaseRefForWorktreeCreate: vi.fn(async () => undefined),
|
refreshLocalBaseRefForWorktreeCreate: vi.fn(async () => undefined),
|
||||||
@@ -30,10 +37,16 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('reuses the current base-resolution oid and preserves WSL routing', async () => {
|
it('reuses the current base-resolution oid and preserves WSL routing', async () => {
|
||||||
await finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main', false, {
|
await finalizePreparedWorktree(
|
||||||
wslDistro: 'Ubuntu',
|
'/repo',
|
||||||
timeout: 8000
|
'/prepared',
|
||||||
})
|
'/final',
|
||||||
|
'feature',
|
||||||
|
'main',
|
||||||
|
false,
|
||||||
|
{ wslDistro: 'Ubuntu', timeout: 8000 },
|
||||||
|
'owner'
|
||||||
|
)
|
||||||
const revisions = gitExec.mock.calls.filter(([args]) => args[0] === 'rev-parse')
|
const revisions = gitExec.mock.calls.filter(([args]) => args[0] === 'rev-parse')
|
||||||
expect(revisions.map(([args]) => args)).toEqual([
|
expect(revisions.map(([args]) => args)).toEqual([
|
||||||
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
|
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
|
||||||
@@ -58,7 +71,8 @@ it.each([
|
|||||||
'feature',
|
'feature',
|
||||||
test.base,
|
test.base,
|
||||||
test.refresh,
|
test.refresh,
|
||||||
test.options
|
test.options,
|
||||||
|
'owner'
|
||||||
)
|
)
|
||||||
expect(gitExec).toHaveBeenCalledWith(
|
expect(gitExec).toHaveBeenCalledWith(
|
||||||
['rev-parse', '--verify', 'refs/heads/main^{commit}'],
|
['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
|
let settled = false
|
||||||
const error = new Error('prepared HEAD unreadable')
|
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)
|
const checked = expect(result).rejects.toBe(error)
|
||||||
void result.then(
|
void result.then(
|
||||||
() => (settled = true),
|
() => (settled = true),
|
||||||
|
|||||||
@@ -43,16 +43,28 @@ function nextPreparedPath(label: string): string {
|
|||||||
return join(preparationRoot, `${process.pid}-${label}-${sequence}`)
|
return join(preparationRoot, `${process.pid}-${label}-${sequence}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockReasons = new Map<string, string>()
|
||||||
|
|
||||||
function checkout(preparedPath: string, signal?: AbortSignal): Promise<void> {
|
function checkout(preparedPath: string, signal?: AbortSignal): Promise<void> {
|
||||||
|
const lockReason = createWorktreePreparationLockReason(`bench-${sequence}`)
|
||||||
|
lockReasons.set(preparedPath, lockReason)
|
||||||
return prepareWorktreeCreateCheckout(
|
return prepareWorktreeCreateCheckout(
|
||||||
repoPath,
|
repoPath,
|
||||||
preparedPath,
|
preparedPath,
|
||||||
'main',
|
'main',
|
||||||
createWorktreePreparationLockReason(`bench-${sequence}`),
|
lockReason,
|
||||||
signal ? { signal } : {}
|
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> {
|
async function runTrial(variant: Variant, obsolete: number): Promise<Sample> {
|
||||||
const controllers = Array.from({ length: obsolete }, () => new AbortController())
|
const controllers = Array.from({ length: obsolete }, () => new AbortController())
|
||||||
const obsoletePaths = controllers.map(() => nextPreparedPath('obsolete'))
|
const obsoletePaths = controllers.map(() => nextPreparedPath('obsolete'))
|
||||||
@@ -69,11 +81,7 @@ async function runTrial(variant: Variant, obsolete: number): Promise<Sample> {
|
|||||||
await checkout(freshPath)
|
await checkout(freshPath)
|
||||||
const freshCheckoutMs = performance.now() - started
|
const freshCheckoutMs = performance.now() - started
|
||||||
await Promise.all(obsoleteWork)
|
await Promise.all(obsoleteWork)
|
||||||
await Promise.all(
|
await Promise.all([...obsoletePaths, freshPath].map((path) => discard(path).catch(() => {})))
|
||||||
[...obsoletePaths, freshPath].map((path) =>
|
|
||||||
discardPreparedWorktree(repoPath, path).catch(() => {})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return { variant, obsolete, freshCheckoutMs }
|
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.
|
// Warm the object store and page cache once so the first variant is not penalised.
|
||||||
const warm = nextPreparedPath('warm')
|
const warm = nextPreparedPath('warm')
|
||||||
await checkout(warm)
|
await checkout(warm)
|
||||||
await discardPreparedWorktree(repoPath, warm)
|
await discard(warm)
|
||||||
|
|
||||||
const samples: Sample[] = []
|
const samples: Sample[] = []
|
||||||
for (const obsolete of OBSOLETE_COUNTS) {
|
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()
|
expect(appExitMock).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns the selected macOS input mode before the keyboard layout fallback', async () => {
|
it.each([true, false])(
|
||||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
'prioritizes the selected input mode regardless of record order (%s)',
|
||||||
spawnMock.mockImplementation(() =>
|
async (modeLast) => {
|
||||||
createFakeSpawnChild({
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
||||||
stdout: JSON.stringify([
|
const inputMode = {
|
||||||
{ 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' },
|
'Bundle ID': 'com.apple.inputmethod.SCIM',
|
||||||
{
|
'Input Mode': 'com.apple.inputmethod.SCIM.ITABC',
|
||||||
'Bundle ID': 'com.apple.inputmethod.SCIM',
|
InputSourceKind: 'Input Mode'
|
||||||
'Input Mode': 'com.apple.inputmethod.SCIM.ITABC',
|
}
|
||||||
InputSourceKind: 'Input Mode'
|
const keyboardLayout = {
|
||||||
}
|
InputSourceKind: 'Keyboard Layout',
|
||||||
])
|
'KeyboardLayout Name': 'ABC',
|
||||||
})
|
'KeyboardLayout ID': 252
|
||||||
)
|
}
|
||||||
registerAppHandlers({} as never)
|
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(
|
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
|
||||||
'com.apple.inputmethod.SCIM.ITABC'
|
'com.apple.inputmethod.SCIM.ITABC'
|
||||||
)
|
)
|
||||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||||
// Why: macOS 15's `plutil -extract <key> json` aborts on the input-source
|
// 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
|
// 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.
|
// the bug with an xml1 extract before converting the clean subtree to JSON.
|
||||||
// Pin the exact pipeline (absolute paths, stdin markers) so dropping any
|
// Pin the exact pipeline (absolute paths, stdin markers) so dropping any
|
||||||
// stage silently regressing CJK detection to the fallback fails the test.
|
// stage silently regressing CJK detection to the fallback fails the test.
|
||||||
expect(spawnMock).toHaveBeenCalledWith(
|
expect(spawnMock).toHaveBeenCalledWith(
|
||||||
'/bin/sh',
|
'/bin/sh',
|
||||||
[
|
[
|
||||||
'-c',
|
'-c',
|
||||||
'/usr/bin/defaults export com.apple.HIToolbox - | ' +
|
'/usr/bin/defaults export com.apple.HIToolbox - | ' +
|
||||||
'/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' +
|
'/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' +
|
||||||
'/usr/bin/plutil -convert json -o - -'
|
'/usr/bin/plutil -convert json -o - -'
|
||||||
],
|
],
|
||||||
expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] })
|
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 })
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
||||||
spawnMock
|
spawnMock
|
||||||
.mockImplementationOnce(() =>
|
.mockImplementationOnce(() =>
|
||||||
@@ -321,6 +330,11 @@ describe('registerAppHandlers', () => {
|
|||||||
{
|
{
|
||||||
'Bundle ID': 'com.apple.PressAndHold',
|
'Bundle ID': 'com.apple.PressAndHold',
|
||||||
InputSourceKind: 'Non Keyboard Input Method'
|
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 })
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
||||||
// Why: reproduces macOS 15's `plutil` abort — the pipeline exits non-zero, so
|
spawnMock.mockImplementation(() => createFakeSpawnChild(result))
|
||||||
// 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' }))
|
|
||||||
registerAppHandlers({} as never)
|
registerAppHandlers({} as never)
|
||||||
|
|
||||||
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
|
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBeNull()
|
||||||
'com.apple.keylayout.ABC'
|
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||||
)
|
|
||||||
expect(spawnMock).toHaveBeenCalledTimes(2)
|
|
||||||
expect(spawnMock).toHaveBeenLastCalledWith(
|
|
||||||
'/usr/bin/defaults',
|
|
||||||
['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'],
|
|
||||||
expect.objectContaining({ detached: true })
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the keyboard layout when the selected input source probe fails to spawn', async () => {
|
it('returns unknown and cleans up when the selected-source probe times out', 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 () => {
|
|
||||||
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
|
||||||
spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true }))
|
spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true }))
|
||||||
registerAppHandlers({} as never)
|
registerAppHandlers({} as never)
|
||||||
@@ -391,9 +397,8 @@ describe('registerAppHandlers', () => {
|
|||||||
|
|
||||||
expect(settled).toBe(true)
|
expect(settled).toBe(true)
|
||||||
await expect(resultPromise).resolves.toBeNull()
|
await expect(resultPromise).resolves.toBeNull()
|
||||||
// Why: both wedged probes get a process-group SIGKILL (negative pid) so the
|
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||||
// shell and any orphaned `defaults`/`plutil` stages are reaped on timeout.
|
expect(processKillSpy).toHaveBeenCalledTimes(1)
|
||||||
expect(processKillSpy).toHaveBeenCalledTimes(2)
|
|
||||||
expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL')
|
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
|
let records: unknown
|
||||||
try {
|
try {
|
||||||
records = JSON.parse(stdout)
|
records = JSON.parse(stdout)
|
||||||
@@ -194,54 +196,62 @@ function readSelectedInputSourceIdFromJson(stdout: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasSelectedKeyboardLayout = false
|
||||||
for (const record of records.slice().toReversed()) {
|
for (const record of records.slice().toReversed()) {
|
||||||
if (!record || typeof record !== 'object') {
|
if (!record || typeof record !== 'object') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const fields = record as Record<string, unknown>
|
const kind =
|
||||||
const kind = typeof fields.InputSourceKind === 'string' ? fields.InputSourceKind : ''
|
'InputSourceKind' in record && typeof record.InputSourceKind === 'string'
|
||||||
if (kind.toLowerCase().includes('non keyboard')) {
|
? record.InputSourceKind.trim().toLowerCase()
|
||||||
|
: ''
|
||||||
|
if (kind === 'keyboard layout') {
|
||||||
|
hasSelectedKeyboardLayout = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const inputMode = fields['Input Mode']
|
if (kind.includes('non keyboard')) {
|
||||||
if (typeof inputMode === 'string' && inputMode.trim()) {
|
continue
|
||||||
return inputMode.trim()
|
|
||||||
}
|
}
|
||||||
const bundleId = fields['Bundle ID']
|
if (kind !== 'input mode' && kind !== 'keyboard input method') {
|
||||||
if (typeof bundleId === 'string' && bundleId.trim()) {
|
return null
|
||||||
return bundleId.trim()
|
|
||||||
}
|
}
|
||||||
|
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 {
|
try {
|
||||||
const stdout = await readCommandStdout(
|
const stdout = await readCommandStdout(
|
||||||
'/bin/sh',
|
'/bin/sh',
|
||||||
['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND],
|
['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND],
|
||||||
'Selected keyboard input source probe timed out'
|
'Selected keyboard input source probe timed out'
|
||||||
)
|
)
|
||||||
return readSelectedInputSourceIdFromJson(stdout)
|
return readSelectedInputSourceFromJson(stdout)
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
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> {
|
async function readKeyboardInputSourceId(): Promise<string | null> {
|
||||||
const selectedInputSourceId = await readSelectedKeyboardInputSourceId()
|
const selectedInputSource = await readSelectedKeyboardInputSource()
|
||||||
if (selectedInputSourceId) {
|
if (selectedInputSource?.kind === 'inputSource') {
|
||||||
return selectedInputSourceId
|
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 {
|
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('pwsh:isAvailable', (): Promise<boolean> => isPwshAvailableAsync())
|
||||||
ipcMain.handle('gitBash:isAvailable', (): boolean => isGitBashAvailable())
|
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> => {
|
ipcMain.handle('app:getKeyboardInputSourceId', async (): Promise<string | null> => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
return null
|
return null
|
||||||
@@ -281,7 +291,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
|
|||||||
const trimmed = stdout?.trim() ?? ''
|
const trimmed = stdout?.trim() ?? ''
|
||||||
return trimmed.length > 0 ? trimmed : null
|
return trimmed.length > 0 ? trimmed : null
|
||||||
} catch {
|
} 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
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,25 +109,26 @@ describe('resolveCreatedWorktree', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
|
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
|
||||||
).resolves.toEqual({ created: CREATED, worktrees: [MAIN, CREATED], listingComplete: true })
|
).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 () => {
|
it('verifies only the new checkout without listing or probing every existing worktree', async () => {
|
||||||
vi.mocked(listWorktreesSharedStrict).mockRejectedValue(new Error('git timed out.'))
|
|
||||||
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
|
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
|
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
|
||||||
).resolves.toEqual({ created: CREATED, worktrees: [], listingComplete: false })
|
).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 () => {
|
it('uses the whole listing when the direct checkout witness cannot be read', async () => {
|
||||||
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN])
|
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN, CREATED])
|
||||||
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
|
vi.mocked(describeCreatedWorktree).mockRejectedValue(new Error('rev-parse exploded'))
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
|
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 () => {
|
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 () => {
|
it('charges the listing fallback what the direct read left of the budget', async () => {
|
||||||
vi.mocked(listWorktreesSharedStrict).mockImplementation(async () => {
|
vi.mocked(describeCreatedWorktree).mockImplementation(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 60))
|
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')
|
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).toBeGreaterThanOrEqual(5_000)
|
||||||
expect(options?.timeout).toBeLessThan(30_000)
|
expect(options?.timeout).toBeLessThan(30_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("keeps the caller's own deadline instead of the shared budget", async () => {
|
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(listWorktreesSharedStrict).mockResolvedValue([CREATED])
|
||||||
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
|
|
||||||
|
|
||||||
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature', { timeout: 1_234 })
|
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature', { timeout: 1_234 })
|
||||||
|
|
||||||
expect(vi.mocked(describeCreatedWorktree).mock.lastCall?.[3]).toMatchObject({ 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])
|
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED])
|
||||||
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
|
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' })
|
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 = {
|
export type CreatedWorktreeResolution = {
|
||||||
created: GitWorktreeInfo
|
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[]
|
worktrees: readonly GitWorktreeInfo[]
|
||||||
/** Whether `worktrees` is the repo's whole listing, and so usable as its authorized-root set. */
|
/** Whether `worktrees` is the repo's whole listing, and so usable as its authorized-root set. */
|
||||||
listingComplete: boolean
|
listingComplete: boolean
|
||||||
@@ -36,16 +36,10 @@ export function createdWorktreeNotFoundError(worktreePath: string, branchName: s
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A failed direct read still leaves the listing a chance to verify the create. */
|
||||||
* Find the row for a worktree `git worktree add` just created, preferring the repo listing and
|
const MIN_CREATED_WORKTREE_LIST_FALLBACK_MS = 5_000
|
||||||
* 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
|
|
||||||
|
|
||||||
|
/** Verify the new checkout directly; listing every existing worktree is only a fallback. */
|
||||||
export async function resolveCreatedWorktree(
|
export async function resolveCreatedWorktree(
|
||||||
repoPath: string,
|
repoPath: string,
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
@@ -53,11 +47,28 @@ export async function resolveCreatedWorktree(
|
|||||||
options?: GitWorktreeExecOptions
|
options?: GitWorktreeExecOptions
|
||||||
): Promise<CreatedWorktreeResolution> {
|
): Promise<CreatedWorktreeResolution> {
|
||||||
const startedAt = Date.now()
|
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
|
let listingError: Error | undefined
|
||||||
try {
|
try {
|
||||||
const worktrees = options
|
const worktrees = await listWorktreesSharedStrict(repoPath, {
|
||||||
? await listWorktreesSharedStrict(repoPath, options)
|
...options,
|
||||||
: await listWorktreesSharedStrict(repoPath)
|
timeout: options?.timeout ?? remainingMs
|
||||||
|
})
|
||||||
const created = findCreatedWorktree(worktrees, worktreePath, branchName)
|
const created = findCreatedWorktree(worktrees, worktreePath, branchName)
|
||||||
if (created) {
|
if (created) {
|
||||||
return { created, worktrees, listingComplete: true }
|
return { created, worktrees, listingComplete: true }
|
||||||
@@ -65,39 +76,18 @@ export async function resolveCreatedWorktree(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
listingError = err instanceof Error ? err : new Error(String(err))
|
listingError = err instanceof Error ? err : new Error(String(err))
|
||||||
}
|
}
|
||||||
|
if (listingError) {
|
||||||
try {
|
if (directReadError) {
|
||||||
// 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.
|
|
||||||
console.warn('[worktrees:create] created-worktree recovery also failed', {
|
console.warn('[worktrees:create] created-worktree recovery also failed', {
|
||||||
err,
|
err: directReadError,
|
||||||
worktreePath
|
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 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 type { ImportItemResult } from '../../shared/filesystem-import-result-types'
|
||||||
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
|
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
|
||||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||||
import { runSshProviderContinuation } from '../ssh/ssh-provider-continuations'
|
|
||||||
import { remotePathExists } from './filesystem-import-ssh-remote-existence'
|
|
||||||
import {
|
import {
|
||||||
captureLocalUploadRoot,
|
captureLocalUploadRoot,
|
||||||
preScanSshImportDirectory,
|
preScanSshImportDirectory,
|
||||||
@@ -19,12 +17,6 @@ import {
|
|||||||
// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so
|
// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so
|
||||||
// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows.
|
// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows.
|
||||||
export async function importExternalPathsSsh(
|
export async function importExternalPathsSsh(
|
||||||
...args: Parameters<typeof importExternalPathsSshTracked>
|
|
||||||
): Promise<{ results: ImportItemResult[] }> {
|
|
||||||
return runSshProviderContinuation(args[2], () => importExternalPathsSshTracked(...args))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importExternalPathsSshTracked(
|
|
||||||
sourcePaths: string[],
|
sourcePaths: string[],
|
||||||
destDir: string,
|
destDir: string,
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
@@ -277,3 +269,31 @@ async function ensureDropStagingDir(
|
|||||||
assertCurrent?.()
|
assertCurrent?.()
|
||||||
await provider.createDir(destDir)
|
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 { lstat, writeFile } from 'node:fs/promises'
|
||||||
import type { SshMutationExpectation } from '../../../shared/ssh-types'
|
import type { SshMutationExpectation } from '../../../shared/ssh-types'
|
||||||
import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation'
|
import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation'
|
||||||
import { runSshProviderContinuation } from '../../ssh/ssh-provider-continuations'
|
|
||||||
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
|
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
|
||||||
import { tryDeleteWslUncPath } from '../../wsl-unc-delete'
|
import { tryDeleteWslUncPath } from '../../wsl-unc-delete'
|
||||||
import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth'
|
import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth'
|
||||||
@@ -27,9 +26,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
|
|||||||
)
|
)
|
||||||
if (args.connectionId) {
|
if (args.connectionId) {
|
||||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||||
return runSshProviderContinuation(args.connectionId, () =>
|
return provider.writeFile(args.filePath, args.content)
|
||||||
provider.writeFile(args.filePath, args.content)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const filePath = await resolveAuthorizedPath(args.filePath, store)
|
const filePath = await resolveAuthorizedPath(args.filePath, store)
|
||||||
try {
|
try {
|
||||||
@@ -64,9 +61,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
|
|||||||
)
|
)
|
||||||
if (args.connectionId) {
|
if (args.connectionId) {
|
||||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||||
return runSshProviderContinuation(args.connectionId, () =>
|
return provider.deletePath(args.targetPath, args.recursive)
|
||||||
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).
|
// 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, {
|
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
|
export const WATCHER_PROCESS_EXIT_DEADLINE_MS = RUNTIME_FILE_WATCH_EXIT_DEADLINE_MS
|
||||||
|
|
||||||
const physicalExitPromises = new WeakMap<ChildProcess, Promise<void>>()
|
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 {
|
export function registerWatcherChildPhysicalExit(child: ChildProcess): () => void {
|
||||||
let resolveExit: () => void = () => undefined
|
let resolveExit: () => void = () => undefined
|
||||||
@@ -93,10 +86,6 @@ export function terminateWatcherChild(child: ChildProcess): Promise<boolean> {
|
|||||||
hardKillTimer.unref?.()
|
hardKillTimer.unref?.()
|
||||||
const exitDeadlineTimer = setTimeout(() => finish(false), WATCHER_PROCESS_EXIT_DEADLINE_MS)
|
const exitDeadlineTimer = setTimeout(() => finish(false), WATCHER_PROCESS_EXIT_DEADLINE_MS)
|
||||||
exitDeadlineTimer.unref?.()
|
exitDeadlineTimer.unref?.()
|
||||||
if (signalledChildren.has(child)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
signalledChildren.add(child)
|
|
||||||
try {
|
try {
|
||||||
child.kill()
|
child.kill()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -105,10 +94,11 @@ export function terminateWatcherChild(child: ChildProcess): Promise<boolean> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function watcherChildPhysicalExit(child: ChildProcess): Promise<void> {
|
export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure {
|
||||||
return child.exitCode !== null || child.signalCode !== null
|
const physicalExit =
|
||||||
? Promise.resolve()
|
child.exitCode !== null || child.signalCode !== null
|
||||||
: (physicalExitPromises.get(child) ??
|
? Promise.resolve()
|
||||||
|
: (physicalExitPromises.get(child) ??
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
const finish = (): void => {
|
const finish = (): void => {
|
||||||
child.removeListener('exit', finish)
|
child.removeListener('exit', finish)
|
||||||
@@ -118,14 +108,11 @@ export function watcherChildPhysicalExit(child: ChildProcess): Promise<void> {
|
|||||||
child.once('exit', finish)
|
child.once('exit', finish)
|
||||||
child.once('close', finish)
|
child.once('close', finish)
|
||||||
}))
|
}))
|
||||||
}
|
|
||||||
|
|
||||||
export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure {
|
|
||||||
return new WatcherProcessFailure(
|
return new WatcherProcessFailure(
|
||||||
'file watcher process did not exit after termination deadline',
|
'file watcher process did not exit after termination deadline',
|
||||||
'supervisor',
|
'supervisor',
|
||||||
'process_unavailable',
|
'process_unavailable',
|
||||||
watcherChildPhysicalExit(child)
|
physicalExit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,12 +127,10 @@ export async function terminateIdleWatcherChild(
|
|||||||
pendingUnsubscribes: Map<number, PendingWatcherUnsubscribe>,
|
pendingUnsubscribes: Map<number, PendingWatcherUnsubscribe>,
|
||||||
onFinished: (exited: boolean) => void
|
onFinished: (exited: boolean) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Windows directory handles require physical exit, not merely an accepted signal.
|
|
||||||
try {
|
try {
|
||||||
await requireWatcherChildTermination(child)
|
await requireWatcherChildTermination(child)
|
||||||
onFinished(true)
|
onFinished(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Idle children retain capacity but cannot double-watch; the owner may remain reusable.
|
|
||||||
onFinished(false)
|
onFinished(false)
|
||||||
resolvePendingWatcherUnsubscribes(
|
resolvePendingWatcherUnsubscribes(
|
||||||
pendingUnsubscribes,
|
pendingUnsubscribes,
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ import { join } from 'node:path'
|
|||||||
|
|
||||||
type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean }
|
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
|
// 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
|
// 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.
|
// 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