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

This commit is contained in:
Brennan Benson
2026-10-02 02:37:07 -07:00
619 changed files with 13875 additions and 35842 deletions
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, vi, type Mock } from 'vitest'
import type * as WorktreeLogic from '../ipc/worktree-logic'
import type { Store } from '../persistence'
import type { Repo } from '../../shared/repo-types'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
const mocks: {
mkdir: Mock
listWorktreeGraph: Mock
prepareCheckout: Mock
refreshTip: Mock
finalize: Mock
discard: Mock
unlock: Mock
getWorktreeOptions: Mock
computeWorkspaceRoot: Mock
computeWorkspaceRootAsync: Mock
resolveBaseRef: Mock
measureDivergence: Mock
} = vi.hoisted(() => ({
mkdir: vi.fn(),
listWorktreeGraph: vi.fn(),
prepareCheckout: vi.fn(),
refreshTip: vi.fn(),
finalize: vi.fn(),
discard: vi.fn(),
unlock: vi.fn(),
getWorktreeOptions: vi.fn(),
computeWorkspaceRoot: vi.fn(),
computeWorkspaceRootAsync: vi.fn(),
resolveBaseRef: vi.fn(),
measureDivergence: vi.fn()
}))
export { mocks }
vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir }))
vi.mock('../git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph }))
vi.mock('../git/worktree-create-preparation', () => ({
prepareWorktreeCreateCheckout: mocks.prepareCheckout,
finalizePreparedWorktree: mocks.finalize,
discardPreparedWorktree: mocks.discard,
unlockPreparedWorktree: mocks.unlock
}))
vi.mock('../git/worktree-preparation-tip-refresh', () => ({
refreshPreparedWorktreeTip: mocks.refreshTip
}))
vi.mock('../git/worktree-base-ref-probe', () => ({
resolveLocalWorktreeBaseRef: mocks.resolveBaseRef
}))
vi.mock('../git/worktree-base-divergence', () => ({
measureRetargetDivergence: mocks.measureDivergence
}))
vi.mock('../project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions,
getWorktreeMirrorDistro: () => undefined
}))
vi.mock('../ipc/worktree-logic', async (importOriginal) => ({
isOrphanedWorktreeError: (await importOriginal<typeof WorktreeLogic>()).isOrphanedWorktreeError,
computeWorkspaceRoot: mocks.computeWorkspaceRoot,
computeWorkspaceRootAsync: mocks.computeWorkspaceRootAsync,
getWorktreePathSettings: () => ({
workspaceDir: process.platform === 'win32' ? 'C:\\workspace' : '/workspace',
nestWorkspaces: false
})
}))
import { _resetWorktreeCreatePreparationsForTests } from '../worktree-create-preparation'
// Evictions and retries are fire-and-forget, so let them settle before asserting.
export function flushBackgroundWork(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const EXISTING_REFS = new Set([
'refs/heads/main',
'refs/remotes/origin/main',
'refs/remotes/origin/release'
])
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked flow reads only this repository identity.
export const repo = { id: 'repo-1', path: '/repo' } as Repo
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The mocked persistence boundary reads only getSettings.
export const store = { getSettings: () => ({}) } as unknown as Store
beforeEach(() => {
mocks.mkdir.mockReset().mockResolvedValue(undefined)
mocks.listWorktreeGraph.mockReset().mockResolvedValue([])
mocks.prepareCheckout.mockReset().mockResolvedValue(undefined)
mocks.refreshTip.mockReset().mockResolvedValue(undefined)
mocks.finalize.mockReset().mockResolvedValue({})
mocks.discard.mockReset().mockResolvedValue(undefined)
mocks.unlock.mockReset().mockResolvedValue(undefined)
mocks.getWorktreeOptions.mockReset().mockReturnValue({})
mocks.measureDivergence.mockReset().mockResolvedValue('within')
mocks.resolveBaseRef
.mockReset()
.mockImplementation((_repoPath: string, baseRef: string) =>
resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate))
)
mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => {
throw new Error('synchronous workspace-root lookup must not run on the main thread')
})
mocks.computeWorkspaceRootAsync
.mockReset()
.mockImplementation(async (repoPath: string) =>
process.platform === 'win32' && /^[A-Za-z]:[\\/]/.test(repoPath)
? 'C:\\workspace'
: '/workspace'
)
})
afterEach(async () => {
await _resetWorktreeCreatePreparationsForTests()
})
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
let tempRoots: string[] = []
@@ -11,10 +12,6 @@ afterEach(async () => {
tempRoots = []
})
function jsonLines(records: unknown[]): string {
return records.map((record) => JSON.stringify(record)).join('\n')
}
describe('scanAiVaultSessions Codex worker sessions', () => {
it('hides Codex worker transcripts from session history', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-workers-'))
@@ -198,28 +195,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
)
const result = await scanAiVaultSessions({
claudeProjectsDir: join(root, 'claude-projects'),
codexSessionsDir,
geminiSessionsDir: join(root, 'gemini-sessions'),
antigravityBrainDir: join(root, 'antigravity-brain'),
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
opencodeDbPaths: [],
grokSessionsDir: join(root, 'grok-sessions'),
devinTranscriptsDir: join(root, 'devin-transcripts'),
hermesSessionsDir: join(root, 'hermes-sessions'),
rovoSessionsDir: join(root, 'rovo-sessions'),
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
ompSessionsDir: join(root, 'omp-sessions'),
primeAgentSessionsDir: join(root, 'prime-agent-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions'),
museSessionsDir: join(root, 'muse-sessions'),
zcodeDbPath: join(root, 'zcode-db.sqlite'),
...isolatedScanRoots(root),
platform: 'darwin'
})
@@ -117,11 +117,11 @@ it('reports the indexer phase and a live generation over the protocol', async ()
const status = await vi.waitFor(async () => {
const value = await searchStatus()
expect(value.filesIndexed).toBeGreaterThan(0)
expect(value.phase).toBe('current')
expect(value.generation).toBeGreaterThan(0)
return value
})
expect(status.enabled).toBe(true)
expect(status.phase).toBe('current')
expect(status.generation).toBeGreaterThan(0)
expect(existsSync(harness.databasePath)).toBe(true)
})
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from 'vitest'
import { withPlatform } from '../window/createMainWindow-test-harness'
import {
createLowCommitOomRecoveryGate,
LOW_COMMIT_REPEAT_OOM_WINDOW_MS
} from './low-commit-oom-recovery-gate'
const OOM: Electron.RenderProcessGoneDetails = { reason: 'oom', exitCode: -536870904 }
const CRASHED: Electron.RenderProcessGoneDetails = { reason: 'crashed', exitCode: 5 }
// Launch 22912 (Scan-30 1790622432/1790622459): OOM at 19:06:54.6, reload OOMed again at 19:07:28.7.
const FIRST_OOM = Date.parse('2026-09-28T19:06:54.600Z')
const RELOAD_OOM = Date.parse('2026-09-28T19:07:28.700Z')
function sample(swapFreeMB: number | undefined, ageMs = 4_000) {
return () => ({
systemMemoryPreGoneSampleAgeMs: ageMs,
...(swapFreeMB === undefined ? {} : { systemMemoryPreGoneSwapFreeMB: swapFreeMB })
})
}
// A gone-time read that resolved no commit field, as off-Electron.
const NO_GONE_TIME_READING = () => ({})
function goneTime(swapFreeMB: number) {
return () => ({ systemMemorySwapFreeMB: swapFreeMB })
}
function observeTwice(
read: ReturnType<typeof sample>,
second = OOM,
gapMs = RELOAD_OOM - FIRST_OOM,
platform: NodeJS.Platform = 'win32',
readGoneTime: () => Record<string, number> = NO_GONE_TIME_READING
) {
return withPlatform(platform, () => {
const gate = createLowCommitOomRecoveryGate(read, readGoneTime)
const first = gate.assess(OOM, FIRST_OOM)
gate.recordRecoveredDeath(OOM, FIRST_OOM)
return [first, gate.assess(second, FIRST_OOM + gapMs)]
})
}
describe('createLowCommitOomRecoveryGate', () => {
it('holds the reload of a repeat OOM with 60 MB of commit left', () => {
expect(observeTwice(sample(60))).toEqual([
null,
{ availableCommitMB: 60, sincePreviousOomMs: 34_100, commitReading: 'pre-gone' }
])
})
it('always lets the first OOM of the launch auto-reload, even with 5 MB left', () => {
expect(observeTwice(sample(5))[0]).toBeNull()
})
it.each([
['commit is healthy (744 MB)', sample(744), OOM, 34_100, 'win32'],
[
'the previous OOM was over 5 minutes ago',
sample(60),
OOM,
LOW_COMMIT_REPEAT_OOM_WINDOW_MS + 1,
'win32'
],
['the death is not an OOM', sample(60), CRASHED, 34_100, 'win32'],
['no commit reading exists', sample(undefined), OOM, 34_100, 'win32'],
['the reading is stale', sample(60, 31_000), OOM, 34_100, 'win32'],
['the host is macOS', sample(60), OOM, 34_100, 'darwin'],
['the host is Linux', sample(60), OOM, 34_100, 'linux']
] as const)('reloads when %s', (_label, read, second, gapMs, platform) => {
expect(observeTwice(read, second, gapMs, platform)[1]).toBeNull()
})
// Launch 13084 (Scan-31): 12:20:37.207 then 12:22:59.975; each OOM restarts the window.
it('measures the window from the most recent OOM', () => {
const verdicts = withPlatform('win32', () => {
const gate = createLowCommitOomRecoveryGate(sample(60, 2_000), NO_GONE_TIME_READING)
return [
'2026-09-29T12:06:19.869Z',
'2026-09-29T12:20:37.207Z',
'2026-09-29T12:20:40.665Z',
'2026-09-29T12:22:59.975Z'
].map((iso) => {
const verdict = gate.assess(OOM, Date.parse(iso))
gate.recordRecoveredDeath(OOM, Date.parse(iso))
return verdict
})
})
expect(verdicts.map((v) => v?.sincePreviousOomMs ?? null)).toEqual([null, null, 3_458, 139_310])
})
// Launch 13084: the repeat OOM came 3.458 s after the previous one, inside one 10 s sampler tick.
describe('when no sampler tick landed since the previous OOM', () => {
const gapMs = 3_458
it.each([
['taken before the previous OOM', 5_000],
['taken exactly at the previous OOM', gapMs],
['stale', 31_000]
])('reads commit at gone time instead of trusting a reading %s', (_label, ageMs) => {
expect(observeTwice(sample(744, ageMs), OOM, gapMs, 'win32', goneTime(60))[1]).toEqual({
availableCommitMB: 60,
sincePreviousOomMs: gapMs,
commitReading: 'gone-time'
})
})
it('reloads when the gone-time reading shows commit recovered (2029 MB)', () => {
expect(observeTwice(sample(60, 5_000), OOM, gapMs, 'win32', goneTime(2_029))[1]).toBeNull()
})
it('prefers a reading taken after the previous OOM over the gone-time one', () => {
expect(observeTwice(sample(60, 1_000), OOM, gapMs, 'win32', goneTime(2_029))[1]).toEqual({
availableCommitMB: 60,
sincePreviousOomMs: gapMs,
commitReading: 'pre-gone'
})
})
it('reads nothing on macOS or Linux', () => {
const readGoneTime = vi.fn(goneTime(60))
for (const platform of ['darwin', 'linux'] as const) {
expect(observeTwice(sample(60, 5_000), OOM, gapMs, platform, readGoneTime)[1]).toBeNull()
}
expect(readGoneTime).not.toHaveBeenCalled()
})
})
it.each([Number.NaN, Infinity, -1])(
'does not block recovery on an invalid commit reading (%s)',
(commitMB) => {
expect(observeTwice(sample(commitMB), OOM, 34_100, 'win32', goneTime(commitMB))[1]).toBeNull()
}
)
it.each([Number.NaN, Infinity, -1])('ignores an invalid sample age (%s)', (ageMs) => {
expect(observeTwice(sample(60, ageMs), OOM, 34_100, 'win32', goneTime(2_029))[1]).toBeNull()
})
it.each([0, -1])('does not block recovery when the clock fails to advance (%s)', (gapMs) => {
expect(observeTwice(sample(60), OOM, gapMs, 'win32', goneTime(60))[1]).toBeNull()
})
it('does not start the repeat window for an OOM that was never recovered', () => {
const verdict = withPlatform('win32', () => {
const gate = createLowCommitOomRecoveryGate(sample(60, 2_000), NO_GONE_TIME_READING)
gate.assess(OOM, FIRST_OOM)
return gate.assess(OOM, RELOAD_OOM)
})
expect(verdict).toBeNull()
})
})
@@ -0,0 +1,94 @@
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
import { preGoneSystemMemoryDetails } from './pre-gone-host-memory'
import { getSystemMemoryDetails, SYSTEM_MEMORY_KEY_PREFIX } from './system-memory-details'
// Why: when Windows commit is exhausted by another program, a recovery reload OOMs again within seconds
// (launch 13084: 3.5 s after the reload; launch 22912: 34 s), so a repeat OOM on a starved host asks the user instead.
export const LOW_COMMIT_REPEAT_OOM_WINDOW_MS = 5 * 60_000
export const LOW_COMMIT_AVAILABLE_MB_THRESHOLD = 512
// Two missed 10 s sampler ticks: an older reading may predate the squeeze or its relief.
const LOW_COMMIT_MAX_SAMPLE_AGE_MS = 30_000
export type LowCommitOomVerdict = {
/** Pre-gone MEMORYSTATUSEX.ullAvailPageFile, i.e. commit still available. */
availableCommitMB: number
sincePreviousOomMs: number
/** 'gone-time' when no sampler tick landed since the previous OOM and the gate read commit itself. */
commitReading: 'pre-gone' | 'gone-time'
}
export type LowCommitOomRecoveryGate = {
/** Read at gone time; returns a verdict only when auto-reload would run straight back into the OOM. */
assess: (details: Electron.RenderProcessGoneDetails, now: number) => LowCommitOomVerdict | null
/** Call only once the death is actually recovered, so a skipped teardown OOM cannot start the repeat window. */
recordRecoveredDeath: (details: Electron.RenderProcessGoneDetails, goneAt: number) => void
}
type MemoryDetails = Record<string, CrashReportDetailValue>
function usablePreGoneCommitMB(sample: MemoryDetails, sincePreviousOomMs: number): number | null {
const availableCommitMB = sample[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSwapFreeMB`]
const sampleAgeMs = sample[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSampleAgeMs`]
if (
typeof availableCommitMB !== 'number' ||
typeof sampleAgeMs !== 'number' ||
!Number.isFinite(availableCommitMB) ||
availableCommitMB < 0 ||
!Number.isFinite(sampleAgeMs) ||
sampleAgeMs < 0 ||
sampleAgeMs > LOW_COMMIT_MAX_SAMPLE_AGE_MS ||
// Why: a reading from before the previous OOM misses the commit that corpse released.
sampleAgeMs >= sincePreviousOomMs
) {
return null
}
return availableCommitMB
}
export function createLowCommitOomRecoveryGate(
readPreGoneDetails: (now: number) => MemoryDetails = preGoneSystemMemoryDetails,
readGoneTimeDetails: () => MemoryDetails = getSystemMemoryDetails
): LowCommitOomRecoveryGate {
let previousOomAt: number | null = null
return {
assess: (details, now) => {
const previous = previousOomAt
if (
// Only win32 swapFree is available commit; elsewhere it is not a verdict.
process.platform !== 'win32' ||
details.reason !== 'oom' ||
previous === null ||
!Number.isFinite(now) ||
now <= previous ||
now - previous > LOW_COMMIT_REPEAT_OOM_WINDOW_MS
) {
return null
}
const sincePreviousOomMs = now - previous
const preGoneMB = usablePreGoneCommitMB(readPreGoneDetails(now), sincePreviousOomMs)
// Why fall back: a 10 s sampler misses most ~3.5 s repeat loops. A gone-time read sees commit the corpse
// already released, so it can only over-report and miss a prompt, never raise a false one.
const goneTimeMB =
preGoneMB === null ? readGoneTimeDetails()[`${SYSTEM_MEMORY_KEY_PREFIX}SwapFreeMB`] : null
const availableCommitMB = preGoneMB ?? goneTimeMB
if (
typeof availableCommitMB !== 'number' ||
!Number.isFinite(availableCommitMB) ||
availableCommitMB < 0 ||
availableCommitMB >= LOW_COMMIT_AVAILABLE_MB_THRESHOLD
) {
return null
}
return {
availableCommitMB,
sincePreviousOomMs,
commitReading: preGoneMB === null ? 'gone-time' : 'pre-gone'
}
},
recordRecoveredDeath: (details, goneAt) => {
if (details.reason === 'oom') {
previousOomAt = goneAt
}
}
}
}
-13
View File
@@ -1,13 +0,0 @@
/** What a `ptySpawnHealth` reply proves about this daemon; every field is optional on the wire. */
export function readDaemonHealthIdentity(): {
coverage: 'pty-spawn' | 'handshake'
runtimeKind: 'node'
runtimeVersion: string
} {
return {
// Why handshake on Windows: preflightPtySpawnHealth skips the spawn probe there.
coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn',
runtimeKind: 'node',
runtimeVersion: process.version
}
}
+1 -53
View File
@@ -11,7 +11,6 @@ import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner'
import type { SocketProbeOutcome } from './daemon-endpoint-probe'
import {
checkDaemonHealth,
checkDaemonHealthWithCoverage,
E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV,
healthCheckDaemon
} from './daemon-health'
@@ -106,63 +105,12 @@ describe('daemon health', () => {
try {
await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('healthy')
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true)
await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toMatchObject({
verdict: 'healthy',
coverage: process.platform === 'win32' ? 'handshake' : 'pty-spawn',
runtimeKind: 'node',
runtimeVersion: process.version
})
expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(3)
expect(ptySpawnHealthCheck).toHaveBeenCalledTimes(2)
} finally {
await server.shutdown()
}
})
it('treats missing coverage from a legacy Windows daemon as handshake-only', async () => {
writeFileSync(tokenPath, 'legacy-token')
const server = createServer((socket) => {
let pending = ''
socket.on('data', (chunk) => {
pending += chunk.toString()
for (;;) {
const newline = pending.indexOf('\n')
if (newline === -1) {
return
}
const message: unknown = JSON.parse(pending.slice(0, newline))
const type =
typeof message === 'object' && message !== null && 'type' in message
? message.type
: undefined
pending = pending.slice(newline + 1)
if (type === 'hello') {
socket.write(`${JSON.stringify({ type: 'hello', ok: true })}\n`)
} else if (type === 'ptySpawnHealth') {
socket.write(
`${JSON.stringify({ id: 'health-1', ok: true, payload: { healthy: true } })}\n`
)
}
}
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(socketPath, resolve)
})
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
await expect(checkDaemonHealthWithCoverage(socketPath, tokenPath)).resolves.toEqual({
verdict: 'healthy',
coverage: 'handshake'
})
} finally {
Object.defineProperty(process, 'platform', platform)
await closeServer(server)
}
})
it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => {
const server = new DaemonServer({
socketPath,
+10 -67
View File
@@ -21,57 +21,15 @@ export const E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV = 'ORCA_E2E_FORCE_DAEMON_HE
// also covers a live-but-wedged daemon that simply missed the RPC budget.
export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy'
export type DaemonHealthCheck = {
verdict: DaemonHealth
coverage: 'pty-spawn' | 'handshake'
/** Optional runtime proof from newer daemons; absent on mixed-version peers. */
runtimeKind?: 'node'
runtimeVersion?: string
}
function readRuntimeIdentity(
payload: unknown
): Pick<DaemonHealthCheck, 'runtimeKind' | 'runtimeVersion'> {
if (typeof payload !== 'object' || payload === null) {
return {}
}
const runtimeKind = 'runtimeKind' in payload ? payload.runtimeKind : undefined
const runtimeVersion = 'runtimeVersion' in payload ? payload.runtimeVersion : undefined
// Why drop other kinds: only a Node daemon reports here; anything else is an unknown peer.
return {
...(runtimeKind === 'node' ? { runtimeKind } : {}),
...(typeof runtimeVersion === 'string' && runtimeVersion.length > 0 ? { runtimeVersion } : {})
}
}
function readPtySpawnHealthCoverage(
payload: unknown,
fallback: DaemonHealthCheck['coverage']
): DaemonHealthCheck['coverage'] {
if (typeof payload !== 'object' || payload === null) {
return fallback
}
const coverage = 'coverage' in payload ? payload.coverage : undefined
return coverage === 'pty-spawn' || coverage === 'handshake' ? coverage : fallback
}
export function checkDaemonHealthWithCoverage(
socketPath: string,
tokenPath: string
): Promise<DaemonHealthCheck> {
export function checkDaemonHealth(socketPath: string, tokenPath: string): Promise<DaemonHealth> {
return new Promise((resolve) => {
// Older Windows daemons answered this RPC without spawning; an absent optional coverage
// field must preserve that weaker meaning during adoption.
const fallbackCoverage = process.platform === 'win32' ? 'handshake' : 'pty-spawn'
const resolveVerdict = (verdict: DaemonHealth): void =>
resolve({ verdict, coverage: fallbackCoverage })
if (process.env[E2E_FORCE_DAEMON_HEALTH_UNREACHABLE_ENV] === '1') {
resolveVerdict('unreachable')
resolve('unreachable')
return
}
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolveVerdict('unreachable')
resolve('unreachable')
return
}
@@ -79,13 +37,13 @@ export function checkDaemonHealthWithCoverage(
try {
token = readFileSync(tokenPath, 'utf8').trim()
} catch {
resolveVerdict('unreachable')
resolve('unreachable')
return
}
let settled = false
let sock: Socket | null = null
const settle = (result: DaemonHealthCheck): void => {
const settle = (result: DaemonHealth): void => {
if (settled) {
return
}
@@ -100,7 +58,7 @@ export function checkDaemonHealthWithCoverage(
sock?.off('connect', onConnect)
sock?.off('data', onData)
}
const onError = (): void => settle({ verdict: 'unreachable', coverage: fallbackCoverage })
const onError = (): void => settle('unreachable')
const onConnect = (): void => {
const hello: HelloMessage = {
type: 'hello',
@@ -131,13 +89,13 @@ export function checkDaemonHealthWithCoverage(
try {
message = JSON.parse(line) as Record<string, unknown>
} catch {
settle({ verdict: 'rejected', coverage: fallbackCoverage })
settle('rejected')
return
}
if (message.type === 'hello') {
if (!(message as HelloResponse).ok) {
settle({ verdict: 'rejected', coverage: fallbackCoverage })
settle('rejected')
return
}
// Why: a protocol-live daemon with a stale cwd or node-pty helper
@@ -148,20 +106,12 @@ export function checkDaemonHealthWithCoverage(
}
if (message.id === 'health-1') {
const identity = readRuntimeIdentity(message.payload)
settle({
verdict: message.ok === true ? 'healthy' : 'pty-spawn-unhealthy',
coverage: readPtySpawnHealthCoverage(message.payload, fallbackCoverage),
...identity
})
settle(message.ok === true ? 'healthy' : 'pty-spawn-unhealthy')
return
}
}
}
const timer = setTimeout(
() => settle({ verdict: 'unreachable', coverage: fallbackCoverage }),
HEALTH_CHECK_TIMEOUT_MS
)
const timer = setTimeout(() => settle('unreachable'), HEALTH_CHECK_TIMEOUT_MS)
sock = connect({ path: socketPath })
sock.on('error', onError)
@@ -172,13 +122,6 @@ export function checkDaemonHealthWithCoverage(
})
}
export async function checkDaemonHealth(
socketPath: string,
tokenPath: string
): Promise<DaemonHealth> {
return (await checkDaemonHealthWithCoverage(socketPath, tokenPath)).verdict
}
export async function healthCheckDaemon(socketPath: string, tokenPath: string): Promise<boolean> {
return (await checkDaemonHealth(socketPath, tokenPath)) === 'healthy'
}
@@ -442,35 +442,6 @@ describe('current daemon lifecycle retirement', () => {
adopted.dispose()
})
it('atomically retires an idle daemon and permanently fences adapter spawns', async () => {
await startServer()
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath })
await expect(adapter.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' })
await expect(
adapter.spawn({ sessionId: 'late-after-decommission', cols: 80, rows: 24 })
).rejects.toThrow('Terminal daemon is decommissioning')
await waitFor(() => onIdleShutdown.mock.calls.length === 1)
adapter.dispose()
})
it('reopens adapter admission when the daemon refuses retirement for a live session', async () => {
await startServer()
const adapter = new DaemonPtyAdapter({ socketPath, tokenPath })
await adapter.spawn({ sessionId: 'already-live', cols: 80, rows: 24 })
await expect(adapter.requestIdleRetirement()).resolves.toEqual({
state: 'busy',
liveSessions: 1,
admissionReopened: true
})
await expect(
adapter.spawn({ sessionId: 'allowed-after-refusal', cols: 80, rows: 24 })
).resolves.toMatchObject({ id: 'allowed-after-refusal' })
expect(onIdleShutdown).not.toHaveBeenCalled()
adapter.dispose()
})
it('does not let repeated authenticated control probes extend the startup deadline', async () => {
await startServer()
const healthControl = connect(socketPath)
-3
View File
@@ -8,9 +8,6 @@ export {
getDaemonEndpointFacts,
getDaemonProvider,
listLiveDaemonPtyIds,
listLiveDaemonSessions,
requestIdleDaemonRetirement,
releaseDaemonRetirementFence,
readDaemonPidRecord,
replaceDaemonProvider,
shutdownDaemon,
@@ -1,54 +0,0 @@
import { afterEach, expect, it, vi } from 'vitest'
vi.mock('../ipc/pty', () => ({ setLocalPtyProvider: vi.fn() }))
import { DaemonPtyRouter } from './daemon-pty-router'
import { createAdapter } from './daemon-pty-router-test-fixture'
import {
disconnectDaemon,
listLiveDaemonSessions,
listLiveDaemonSessionsWithProtocol,
replaceDaemonProvider,
requestIdleDaemonRetirement
} from './daemon-provider-state'
import { PROTOCOL_VERSION } from './types'
afterEach(async () => {
await disconnectDaemon()
})
it('reads a census without an installed daemon as unverifiable, never as empty', async () => {
await expect(listLiveDaemonSessions()).resolves.toBeNull()
await expect(requestIdleDaemonRetirement()).resolves.toEqual({ state: 'unverifiable' })
})
it('reads a census with an unanswered generation as unverifiable', async () => {
const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION)
const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION)
vi.mocked(legacy.listSessions).mockRejectedValue(new Error('daemon unreachable'))
replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] }))
await expect(listLiveDaemonSessions()).resolves.toBeNull()
})
it('labels each live session with the protocol of the generation that owns it', async () => {
const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION)
const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION - 1)
replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] }))
await expect(listLiveDaemonSessionsWithProtocol()).resolves.toEqual([
{ sessionId: 'live-1', isAlive: true, protocolVersion: PROTOCOL_VERSION },
{ sessionId: 'live-2', isAlive: true, protocolVersion: PROTOCOL_VERSION - 1 }
])
})
it('lists every generation when each one answers', async () => {
const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION)
const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION)
replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] }))
await expect(listLiveDaemonSessions()).resolves.toEqual([
{ sessionId: 'live-1', isAlive: true },
{ sessionId: 'live-2', isAlive: true }
])
})
+18 -79
View File
@@ -11,16 +11,12 @@ import {
getMacDaemonTccAttributionHealth,
type MacDaemonTccAttributionHealth
} from './daemon-tcc-attribution'
import { PROTOCOL_VERSION, type DaemonSessionInfo, type SessionInfo } from './types'
import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state'
import { PROTOCOL_VERSION } from './types'
let spawner: DaemonSpawner | null = null
let adapter: DaemonProvider | null = null
export function installDaemonProvider(
newSpawner: DaemonSpawner | null,
newAdapter: DaemonProvider
): void {
export function installDaemonProvider(newSpawner: DaemonSpawner, newAdapter: DaemonProvider): void {
spawner = newSpawner
replaceDaemonProvider(newAdapter)
}
@@ -39,12 +35,7 @@ export function getDaemonSpawner(): DaemonSpawner | null {
* from that state would be advertising recovery for terminals that cannot be recovered.
*/
export function daemonOwnsFreshPersistentPtys(): boolean {
if (!adapter || adapter instanceof DegradedDaemonPtyProvider) {
return false
}
return adapter instanceof DaemonPtyRouter
? !adapter.getAllAdapters().some((entry) => entry.recoveryOnly)
: !adapter.recoveryOnly
return adapter !== null && !(adapter instanceof DegradedDaemonPtyProvider)
}
/** Endpoint coordinates of the daemon this process installed, for out-of-band health probes. */
@@ -104,27 +95,6 @@ export async function getCurrentDaemonMacTccAttributionHealth(): Promise<MacDaem
)
}
// Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference.
export function replaceDaemonProvider(newAdapter: DaemonProvider): void {
adapter = newAdapter
setLocalPtyProvider(newAdapter)
}
// Disconnect without killing: the daemon survives app quit so sessions stay warm for reattach.
// Leave history sessions marked "unclean" so a daemon crash while Orca is closed stays recoverable.
export async function disconnectDaemon(): Promise<void> {
await adapter?.disconnectOnly()
adapter = null
}
/** Kill the daemon and all its sessions. Use for full cleanup only. */
export async function shutdownDaemon(): Promise<void> {
adapter?.dispose()
adapter = null
await spawner?.shutdown()
spawner = null
}
/** Returns null unless every daemon generation supplied an authoritative inventory. */
export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
if (!adapter) {
@@ -145,54 +115,23 @@ export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
)
}
/** Returns null unless every daemon generation supplied an authoritative session inventory. */
export async function listLiveDaemonSessions(): Promise<SessionInfo[] | null> {
const sessions = await listLiveDaemonSessionsWithProtocol()
return sessions?.map(({ protocolVersion: _protocolVersion, ...session }) => session) ?? null
// Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference.
export function replaceDaemonProvider(newAdapter: DaemonProvider): void {
adapter = newAdapter
setLocalPtyProvider(newAdapter)
}
/** Like listLiveDaemonSessions, with the protocol of the daemon generation owning each session. */
export async function listLiveDaemonSessionsWithProtocol(): Promise<DaemonSessionInfo[] | null> {
if (!adapter) {
return null
}
const adapters =
adapter instanceof DaemonPtyRouter || adapter instanceof DegradedDaemonPtyProvider
? adapter.getAllAdapters()
: [adapter]
const inventories = await Promise.allSettled(
adapters.map(async (daemonAdapter) =>
(await daemonAdapter.listSessions()).map((session) => ({
...session,
protocolVersion: daemonAdapter.protocolVersion
}))
)
)
if (inventories.some((inventory) => inventory.status === 'rejected')) {
return null
}
return inventories.flatMap((inventory) =>
inventory.status === 'fulfilled' ? inventory.value : []
)
// Disconnect without killing: the daemon survives app quit so sessions stay warm for reattach.
// Leave history sessions marked "unclean" so a daemon crash while Orca is closed stays recoverable.
export async function disconnectDaemon(): Promise<void> {
await adapter?.disconnectOnly()
adapter = null
}
/** Atomically fence new daemon terminals and retire only an idle, single-generation daemon. */
export async function requestIdleDaemonRetirement(): Promise<DaemonIdleRetirementResult> {
if (!adapter) {
return { state: 'unverifiable' }
}
if (adapter instanceof DegradedDaemonPtyProvider) {
return { state: 'unverifiable' }
}
if (adapter instanceof DaemonPtyRouter) {
return adapter.requestIdleRetirement()
}
return adapter.requestIdleRetirement()
}
/** Reopens terminal admission when an idle-retirement attempt did not retire the daemon. */
export function releaseDaemonRetirementFence(): void {
if (adapter && !(adapter instanceof DegradedDaemonPtyProvider)) {
adapter.releaseIdleRetirementFence()
}
/** Kill the daemon and all its sessions. Use for full cleanup only. */
export async function shutdownDaemon(): Promise<void> {
adapter?.dispose()
adapter = null
await spawner?.shutdown()
spawner = null
}
@@ -3,12 +3,7 @@ import { removeDaemonListener } from './daemon-listener-registry'
import { emitPtyListeners } from './daemon-pty-listener-emission'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import { DaemonPtySessionInventory } from './daemon-pty-session-inventory'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
type ListSessionsResult,
type ShutdownIfIdleResult
} from './types'
import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state'
import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types'
import type { PtyBackgroundStreamEvent } from '../providers/types'
export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory {
@@ -90,75 +85,6 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent
this.recordAuthenticatedIdentity()
}
async requestIdleRetirement(): Promise<DaemonIdleRetirementResult> {
if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) {
return { state: 'unsupported' }
}
if (this.idleRetirementState === 'retiring') {
return { state: 'retiring' }
}
if (this.idleRetirementPromise) {
return this.idleRetirementPromise
}
if (
this.disconnectOnlyPromise ||
(this.respawnAdoptionClosed && this.idleRetirementState === 'open')
) {
return { state: 'unverifiable' }
}
this.idleRetirementAdmissionClosed = true
this.respawnAdoptionClosed = true
this.idleRetirementState = 'checking'
const request = this.finishIdleRetirementRequest().finally(() => {
if (this.idleRetirementPromise === request) {
this.idleRetirementPromise = null
}
})
this.idleRetirementPromise = request
return request
}
private async finishIdleRetirementRequest(): Promise<DaemonIdleRetirementResult> {
try {
await this.client.ensureConnected()
const result = await this.client.request<ShutdownIfIdleResult>('shutdownIfIdle', undefined)
if (result.retiring) {
this.idleRetirementState = 'retiring'
return { state: 'retiring' }
}
let liveSessions: number | null = null
try {
const inventory = await this.client.request<ListSessionsResult>('listSessions', undefined)
liveSessions = inventory.sessions.filter((session) => session.isAlive).length
} catch {
liveSessions = null
}
this.reopenAfterRefusedIdleRetirement()
return {
state: 'busy',
liveSessions,
...(!this.recoveryOnly ? { admissionReopened: true as const } : {})
}
} catch {
// The daemon may have accepted before contact was lost; keep admission and respawn fenced.
this.idleRetirementState = 'unverifiable'
return { state: 'unverifiable' }
}
}
/** Reopens admission an idle-retirement attempt fenced without retiring the daemon. */
releaseIdleRetirementFence(): void {
if (this.idleRetirementState !== 'retiring' && !this.idleRetirementPromise) {
this.reopenAfterRefusedIdleRetirement()
}
}
private reopenAfterRefusedIdleRetirement(): void {
this.idleRetirementState = 'open'
this.idleRetirementAdmissionClosed = false
this.respawnAdoptionClosed = false
}
// Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable,
// but still write a final checkpoint so a daemon crash while Orca is closed has recovery data.
async disconnectOnly(): Promise<void> {
@@ -127,7 +127,7 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot
// Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan.
const { worktreeId } = parsePtySessionId(session.sessionId)
if (!this.recoveryOnly && (worktreeId === null || !validWorktreeIds.has(worktreeId))) {
if (worktreeId === null || !validWorktreeIds.has(worktreeId)) {
try {
await this.client.request('kill', { sessionId: session.sessionId })
} catch {
@@ -1,168 +0,0 @@
import { vi } from 'vitest'
import { settledWriteStub } from '../providers/settled-pty-write-stub'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
} from './types'
import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
type AdapterMock = DaemonPtyAdapter & {
emitData: (id: string, data: string, sequenceChars?: number) => void
emitBackground: (event: PtyBackgroundStreamEvent) => void
emitExit: (id: string, code: number, incarnationId?: string) => void
emitIdentityChange: () => void
triggerWriteUnavailable: (id: string) => void
}
export function createAdapter(
label: string,
sessions: string[] = [],
reconcileResult?: { alive: string[]; killed: string[] },
protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
): AdapterMock {
const writes: { id: string; data: string }[] = []
const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] =
[]
const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
const writeUnavailableListeners: ((payload: { id: string }) => void)[] = []
const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] =
[]
const identityChangeListeners: (() => void)[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the router calls only the adapter members this mock defines.
return {
protocolVersion,
supportsGitCredentialGuardHost: () =>
protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
supportsAgentSessionClaims: () =>
protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
supportsAgentSessionCreateOperations: () =>
protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
providesAgentSessionOwnerListings: () =>
protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
canProvideAuthoritativeBufferSnapshot: () =>
protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION,
spawn: vi.fn(async (opts: PtySpawnOptions): Promise<PtySpawnResult> => {
const id = opts.sessionId ?? `${label}-new`
sessions.push(id)
return { id }
}),
listProcesses: vi.fn(async () =>
sessions.map((id) => ({
id,
cwd: '',
title: label
}))
),
listSessions: vi.fn(async () => sessions.map((sessionId) => ({ sessionId, isAlive: true }))),
requestIdleRetirement: vi.fn(async () => ({ state: 'retiring' as const })),
releaseIdleRetirementFence: vi.fn(),
hasPty: vi.fn((id: string) => sessions.includes(id)),
probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)),
write: vi.fn((id: string, data: string) => {
writes.push({ id, data })
}),
writeWithSettlement: vi.fn(settledWriteStub()),
resize: vi.fn(),
setPtyBackgrounded: vi.fn(),
getBufferSnapshot: vi.fn(async () => null),
shutdown: vi.fn(async (id: string) => {
const idx = sessions.indexOf(id)
if (idx !== -1) {
sessions.splice(idx, 1)
}
}),
attach: vi.fn(async () => {}),
sendSignal: vi.fn(async () => {}),
getCwd: vi.fn(async () => ''),
getInitialCwd: vi.fn(async () => ''),
clearBuffer: vi.fn(async () => {}),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(async () => false),
getForegroundProcess: vi.fn(async () => null),
inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })),
confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`),
serialize: vi.fn(async () => '{}'),
revive: vi.fn(async () => {}),
getDefaultShell: vi.fn(async () => '/bin/zsh'),
getProfiles: vi.fn(async () => []),
onData: vi.fn(
(callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => {
dataListeners.push(callback)
return () => {
const idx = dataListeners.indexOf(callback)
if (idx !== -1) {
dataListeners.splice(idx, 1)
}
}
}
),
onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => {
backgroundListeners.push(callback)
return () => {
const idx = backgroundListeners.indexOf(callback)
if (idx !== -1) {
backgroundListeners.splice(idx, 1)
}
}
}),
onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => {
writeUnavailableListeners.push(callback)
return () => {
const idx = writeUnavailableListeners.indexOf(callback)
if (idx !== -1) {
writeUnavailableListeners.splice(idx, 1)
}
}
}),
onExit: vi.fn(
(callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => {
exitListeners.push(callback)
return () => {
const idx = exitListeners.indexOf(callback)
if (idx !== -1) {
exitListeners.splice(idx, 1)
}
}
}
),
onDaemonIdentityChanged: vi.fn((callback: () => void) => {
identityChangeListeners.push(callback)
return () => {
const idx = identityChangeListeners.indexOf(callback)
if (idx !== -1) {
identityChangeListeners.splice(idx, 1)
}
}
}),
ackColdRestore: vi.fn(),
clearTombstone: vi.fn(),
reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }),
dispose: vi.fn(),
disconnectOnly: vi.fn(async () => {}),
emitData: (id: string, data: string, sequenceChars?: number) => {
for (const listener of dataListeners) {
listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) })
}
},
emitBackground: (event: PtyBackgroundStreamEvent) => {
for (const listener of backgroundListeners) {
listener(event)
}
},
emitExit: (id: string, code: number, incarnationId?: string) => {
for (const listener of exitListeners) {
listener({ id, code, ...(incarnationId ? { incarnationId } : {}) })
}
},
emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()),
triggerWriteUnavailable: (id: string) => {
for (const listener of writeUnavailableListeners) {
listener({ id })
}
},
_writes: writes
} as unknown as AdapterMock
}
+159 -81
View File
@@ -1,20 +1,29 @@
import { createAdapter } from './daemon-pty-router-test-fixture'
import { describe, expect, it, vi } from 'vitest'
import { DaemonPtyRouter } from './daemon-pty-router'
import { stubWriteSettlement } from '../providers/settled-pty-write-stub'
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { PtySpawnResult } from '../providers/types'
import { settledWriteStub, stubWriteSettlement } from '../providers/settled-pty-write-stub'
import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types'
import {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
} from './types'
import {
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
PROTOCOL_VERSION,
SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION,
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
} from './daemon-protocol-version'
type AdapterMock = DaemonPtyAdapter & {
emitData: (id: string, data: string, sequenceChars?: number) => void
emitBackground: (event: PtyBackgroundStreamEvent) => void
emitExit: (id: string, code: number, incarnationId?: string) => void
emitIdentityChange: () => void
triggerWriteUnavailable: (id: string) => void
}
const LARGE_RECONCILE_SESSION_COUNT = 150_000
function buildSessionIds(prefix: string, count: number): string[] {
@@ -25,6 +34,152 @@ function buildSessionIds(prefix: string, count: number): string[] {
return ids
}
function createAdapter(
label: string,
sessions: string[] = [],
reconcileResult?: { alive: string[]; killed: string[] },
protocolVersion = GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
): AdapterMock {
const writes: { id: string; data: string }[] = []
const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] =
[]
const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = []
const writeUnavailableListeners: ((payload: { id: string }) => void)[] = []
const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] =
[]
const identityChangeListeners: (() => void)[] = []
return {
protocolVersion,
supportsGitCredentialGuardHost: () =>
protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
supportsAgentSessionClaims: () =>
protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
supportsAgentSessionCreateOperations: () =>
protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
providesAgentSessionOwnerListings: () =>
protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
canProvideAuthoritativeBufferSnapshot: () =>
protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION,
spawn: vi.fn(async (opts: PtySpawnOptions): Promise<PtySpawnResult> => {
const id = opts.sessionId ?? `${label}-new`
sessions.push(id)
return { id }
}),
listProcesses: vi.fn(async () =>
sessions.map((id) => ({
id,
cwd: '',
title: label
}))
),
hasPty: vi.fn((id: string) => sessions.includes(id)),
probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)),
write: vi.fn((id: string, data: string) => {
writes.push({ id, data })
}),
writeWithSettlement: vi.fn(settledWriteStub()),
resize: vi.fn(),
setPtyBackgrounded: vi.fn(),
getBufferSnapshot: vi.fn(async () => null),
shutdown: vi.fn(async (id: string) => {
const idx = sessions.indexOf(id)
if (idx !== -1) {
sessions.splice(idx, 1)
}
}),
attach: vi.fn(async () => {}),
sendSignal: vi.fn(async () => {}),
getCwd: vi.fn(async () => ''),
getInitialCwd: vi.fn(async () => ''),
clearBuffer: vi.fn(async () => {}),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(async () => false),
getForegroundProcess: vi.fn(async () => null),
inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })),
confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`),
serialize: vi.fn(async () => '{}'),
revive: vi.fn(async () => {}),
getDefaultShell: vi.fn(async () => '/bin/zsh'),
getProfiles: vi.fn(async () => []),
onData: vi.fn(
(callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => {
dataListeners.push(callback)
return () => {
const idx = dataListeners.indexOf(callback)
if (idx !== -1) {
dataListeners.splice(idx, 1)
}
}
}
),
onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => {
backgroundListeners.push(callback)
return () => {
const idx = backgroundListeners.indexOf(callback)
if (idx !== -1) {
backgroundListeners.splice(idx, 1)
}
}
}),
onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => {
writeUnavailableListeners.push(callback)
return () => {
const idx = writeUnavailableListeners.indexOf(callback)
if (idx !== -1) {
writeUnavailableListeners.splice(idx, 1)
}
}
}),
onExit: vi.fn(
(callback: (payload: { id: string; code: number; incarnationId?: string }) => void) => {
exitListeners.push(callback)
return () => {
const idx = exitListeners.indexOf(callback)
if (idx !== -1) {
exitListeners.splice(idx, 1)
}
}
}
),
onDaemonIdentityChanged: vi.fn((callback: () => void) => {
identityChangeListeners.push(callback)
return () => {
const idx = identityChangeListeners.indexOf(callback)
if (idx !== -1) {
identityChangeListeners.splice(idx, 1)
}
}
}),
ackColdRestore: vi.fn(),
clearTombstone: vi.fn(),
reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }),
dispose: vi.fn(),
disconnectOnly: vi.fn(async () => {}),
emitData: (id: string, data: string, sequenceChars?: number) => {
for (const listener of dataListeners) {
listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) })
}
},
emitBackground: (event: PtyBackgroundStreamEvent) => {
for (const listener of backgroundListeners) {
listener(event)
}
},
emitExit: (id: string, code: number, incarnationId?: string) => {
for (const listener of exitListeners) {
listener({ id, code, ...(incarnationId ? { incarnationId } : {}) })
}
},
emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()),
triggerWriteUnavailable: (id: string) => {
for (const listener of writeUnavailableListeners) {
listener({ id })
}
},
_writes: writes
} as unknown as AdapterMock
}
it('forwards dead-endpoint write-unavailable signals from every routed adapter', () => {
// Why revert-sensitive: main subscribes on the ROUTED provider, so if the router
// does not forward this the STA-2373 fan-out never reaches the renderer and only
@@ -92,83 +247,6 @@ it('forwards the owning legacy daemon sequence from attach', async () => {
})
describe('DaemonPtyRouter', () => {
describe('idle retirement', () => {
it('retires every empty daemon generation and fences subsequent spawns', async () => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' })
expect(current.requestIdleRetirement).toHaveBeenCalledOnce()
expect(legacy.requestIdleRetirement).toHaveBeenCalledOnce()
await expect(router.spawn({ sessionId: 'late', cols: 80, rows: 24 })).rejects.toThrow(
'Terminal daemon is decommissioning'
)
})
it('reports live inventory before retiring any generation and reopens admission', async () => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
const legacy = createAdapter('legacy', ['legacy-live'], undefined, PROTOCOL_VERSION)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await expect(router.requestIdleRetirement()).resolves.toEqual({
state: 'busy',
liveSessions: 1,
admissionReopened: true
})
expect(current.requestIdleRetirement).not.toHaveBeenCalled()
expect(legacy.requestIdleRetirement).not.toHaveBeenCalled()
await expect(
router.spawn({ sessionId: 'after-refusal', cols: 80, rows: 24 })
).resolves.toEqual({
id: 'after-refusal'
})
})
it('does not partially retire when a generation predates clean idle shutdown', async () => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
const legacy = createAdapter('legacy', [], undefined, 23)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'unsupported' })
expect(current.requestIdleRetirement).not.toHaveBeenCalled()
expect(legacy.requestIdleRetirement).not.toHaveBeenCalled()
})
it('keeps admission fenced after a partial multi-generation retirement', async () => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION)
vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({
state: 'busy',
liveSessions: 0
})
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' })
await expect(router.spawn({ sessionId: 'unsafe', cols: 80, rows: 24 })).rejects.toThrow(
'Terminal daemon is decommissioning'
)
})
it('does not certify reopened admission when another generation retired beside live sessions', async () => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION)
vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({
state: 'busy',
liveSessions: 1,
admissionReopened: true
})
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await expect(router.requestIdleRetirement()).resolves.toEqual({
state: 'busy',
liveSessions: 1
})
await expect(
router.spawn({ sessionId: 'unsafe-partial', cols: 80, rows: 24 })
).rejects.toThrow('Terminal daemon is decommissioning')
})
})
it('reports separate conservative resume and fresh-create boundaries', () => {
const current = createAdapter(
'current',
+41 -34
View File
@@ -1,4 +1,3 @@
import { reconcileDaemonRouterSessions } from './daemon-router-session-reconciliation'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout'
import type {
@@ -13,8 +12,6 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state'
import { DaemonRouterRetirement } from './daemon-router-retirement'
import type { WriteSettlement } from '../../shared/pty-write-settlement'
import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply'
@@ -24,7 +21,6 @@ export class DaemonPtyRouter implements IPtyProvider {
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
private readonly ownerResolver: DaemonSessionOwnerResolver<DaemonPtyAdapter>
private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout
private readonly retirement = new DaemonRouterRetirement(() => this.allAdapters())
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
this.current = opts.current
@@ -44,34 +40,17 @@ export class DaemonPtyRouter implements IPtyProvider {
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
if (this.retirement.admissionClosed) {
throw new Error('Terminal daemon is decommissioning')
if (opts.attachOnly && opts.sessionId) {
return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId })
}
// Why counted: an idle-retirement census must not race a spawn it cannot yet see.
this.retirement.spawnInFlight++
try {
if (opts.attachOnly && opts.sessionId) {
return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId })
}
const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined
const target = adapter ?? this.current
const result = await target.spawn(opts)
// Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof.
if (!result.exitedBeforeSpawnReply) {
this.ownerResolver.recordRoute(result.id, target, result.incarnationId)
}
return result
} finally {
this.retirement.spawnInFlight--
const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined
const target = adapter ?? this.current
const result = await target.spawn(opts)
// Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof.
if (!result.exitedBeforeSpawnReply) {
this.ownerResolver.recordRoute(result.id, target, result.incarnationId)
}
}
requestIdleRetirement(): Promise<DaemonIdleRetirementResult> {
return this.retirement.requestIdleRetirement()
}
releaseIdleRetirementFence(): void {
this.retirement.releaseFence()
return result
}
supportsGitCredentialGuardHost(sessionId?: string): boolean {
@@ -278,10 +257,38 @@ export class DaemonPtyRouter implements IPtyProvider {
this.adapterFor(sessionId).clearTombstone(sessionId)
}
async reconcileOnStartup(
validWorktreeIds: Set<string>
): Promise<{ alive: string[]; killed: string[] }> {
return reconcileDaemonRouterSessions(this.allAdapters(), this.ownerResolver, validWorktreeIds)
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
alive: string[]
killed: string[]
}> {
const alive: string[] = []
const killed: string[] = []
const aliveProviders = new Map<string, Set<DaemonPtyAdapter>>()
for (const adapter of this.allAdapters()) {
const result = await adapter.reconcileOnStartup(validWorktreeIds)
// Why: daemon startup can reconcile many restored sessions; spreading
// those arrays into push can exceed JavaScript's argument limit.
for (const id of result.alive) {
alive.push(id)
}
for (const id of result.killed) {
killed.push(id)
}
for (const id of result.alive) {
const providers = aliveProviders.get(id) ?? new Set<DaemonPtyAdapter>()
providers.add(adapter)
aliveProviders.set(id, providers)
}
}
for (const id of new Set([...alive, ...killed])) {
const providers = aliveProviders.get(id)
if (providers?.size === 1) {
this.ownerResolver.recordRoute(id, providers.values().next().value!)
} else {
this.ownerResolver.forgetRoute(id)
}
}
return { alive, killed }
}
dispose(): void {
+1 -13
View File
@@ -57,7 +57,6 @@ export type DaemonPtyAdapterOptions = {
historyPath?: string
runtimeDir?: string
packagedAppVersion?: string | null
recoveryOnly?: boolean
respawn?: (reason: DaemonRespawnReason) => Promise<void | (() => void)>
}
@@ -72,15 +71,8 @@ export type DaemonIdentityChangeEvent = {
current: DaemonEndpointIdentity
}
export type DaemonIdleRetirementResult =
| { state: 'retiring' }
| { state: 'busy'; liveSessions: number | null; admissionReopened?: true }
| { state: 'unsupported' }
| { state: 'unverifiable' }
export abstract class DaemonPtyRuntimeState {
readonly protocolVersion: number
readonly recoveryOnly: boolean
protected socketPath: string
protected tokenPath: string
protected pidPath: string | null
@@ -100,9 +92,6 @@ export abstract class DaemonPtyRuntimeState {
protected packagedAppVersion: string | null
protected pendingRespawnAdoptionRelease: (() => void) | null = null
protected respawnAdoptionClosed = false
protected idleRetirementAdmissionClosed = false
protected idleRetirementState: 'open' | 'checking' | 'retiring' | 'unverifiable' = 'open'
protected idleRetirementPromise: Promise<DaemonIdleRetirementResult> | null = null
protected respawnPromise: Promise<void> | null = null
protected staleBundleReplacementPromise: Promise<void> | null = null
protected writeRecoveryPromise: Promise<void> | null = null
@@ -201,7 +190,6 @@ export abstract class DaemonPtyRuntimeState {
constructor(opts: DaemonPtyAdapterOptions) {
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
this.recoveryOnly = opts.recoveryOnly === true
this.socketPath = opts.socketPath
this.tokenPath = opts.tokenPath
this.pidPath = opts.pidPath ?? null
@@ -221,7 +209,7 @@ export abstract class DaemonPtyRuntimeState {
})
this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null
this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null
this.respawnFn = this.recoveryOnly ? null : (opts.respawn ?? null)
this.respawnFn = opts.respawn ?? null
this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null
this.packagedAppVersion = opts.packagedAppVersion ?? null
this.supportsCheckpoints = this.protocolVersion >= 4
@@ -22,15 +22,10 @@ import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { resolveUnixShellPath } from '../providers/local-pty-utils'
import type { PtySpawnOptions, PtySpawnResult } from '../providers/types'
import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history'
import { assertDaemonRecoverySpawnAdmission } from './daemon-recovery-spawn-admission'
import { addWslEnvKeys } from '../wsl-env'
export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts)
if (this.idleRetirementAdmissionClosed) {
throw new Error('Terminal daemon is decommissioning')
}
const spawnOpts = this.withHistoryIsolation(opts)
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
const operation: PendingDaemonSpawnOperation = {
@@ -110,10 +105,6 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
operation: PendingDaemonSpawnOperation,
historyRecovery: HistoryRecoveryContext
): Promise<PtySpawnResult> {
assertDaemonRecoverySpawnAdmission(this.recoveryOnly, this.protocolVersion, opts)
if (this.idleRetirementAdmissionClosed) {
throw new Error('Terminal daemon is decommissioning')
}
if (
opts.agentSessionEnsure &&
this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
@@ -1,103 +0,0 @@
import { rmSync } from 'node:fs'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import {
createMockSubprocess,
startDaemonAdapterHarness,
waitFor,
type DaemonAdapterHarness
} from './daemon-pty-adapter-test-harness'
import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
import { DaemonPtyRouter } from './daemon-pty-router'
let harness: DaemonAdapterHarness
let recovery: DaemonPtyAdapter
let subprocess: ReturnType<typeof createMockSubprocess>
const spawn = vi.fn(() => subprocess)
const respawn = vi.fn(async () => {})
beforeEach(async () => {
spawn.mockClear()
respawn.mockClear()
subprocess = createMockSubprocess()
harness = await startDaemonAdapterHarness(spawn)
await harness.adapter.spawn({ sessionId: 'existing', cols: 80, rows: 24 })
recovery = new DaemonPtyAdapter({
socketPath: harness.socketPath,
tokenPath: harness.tokenPath,
recoveryOnly: true,
respawn
})
})
afterEach(async () => {
recovery?.dispose()
harness.adapter.dispose()
await harness.server.shutdown()
rmSync(harness.dir, { recursive: true, force: true })
})
it('reattaches and controls existing work without admitting a new process', async () => {
await expect(
recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 })
).resolves.toMatchObject({ id: 'existing', isReattach: true })
recovery.write('existing', 'still live\n')
await waitFor(() => subprocess.write.mock.calls.length > 0)
expect(subprocess.write).toHaveBeenCalledWith('still live\n')
await expect(recovery.spawn({ sessionId: 'fresh', cols: 80, rows: 24 })).rejects.toThrow(
'managed-stop recovery'
)
await expect(
recovery.spawn({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 })
).rejects.toThrow()
expect(spawn).toHaveBeenCalledOnce()
expect(respawn).not.toHaveBeenCalled()
})
it('does not certify fresh admission after a confirmed native stop refusal', async () => {
await expect(recovery.requestIdleRetirement()).resolves.toEqual({
state: 'busy',
liveSessions: 1
})
await expect(
recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 })
).resolves.toMatchObject({ isReattach: true })
await expect(recovery.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery')
expect(spawn).toHaveBeenCalledOnce()
})
it('does not invoke a supplied replacement launcher after endpoint loss', async () => {
await recovery.listProcesses()
await harness.server.shutdown()
await expect(
recovery.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 })
).rejects.toThrow()
expect(respawn).not.toHaveBeenCalled()
})
it('keeps recovery-only admission through routed inventory refusal', async () => {
const router = new DaemonPtyRouter({ current: recovery, legacy: [] })
await expect(router.requestIdleRetirement()).resolves.toEqual({ state: 'busy', liveSessions: 1 })
await expect(
router.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 })
).resolves.toMatchObject({ isReattach: true })
await expect(router.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery')
expect(spawn).toHaveBeenCalledOnce()
})
it('rejects legacy attach emulation before contacting the daemon', async () => {
const legacy = new DaemonPtyAdapter({
socketPath: harness.socketPath,
tokenPath: harness.tokenPath,
protocolVersion: STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION - 1,
recoveryOnly: true
})
try {
await expect(
legacy.spawn({ sessionId: 'existing', attachOnly: true, cols: 80, rows: 24 })
).rejects.toThrow('managed-stop recovery')
expect(spawn).toHaveBeenCalledOnce()
} finally {
legacy.dispose()
}
})
@@ -1,10 +0,0 @@
import { rebindLocalProviderListeners } from '../ipc/pty'
import { getDaemonRuntimeDir, getDaemonHistoryDir } from './daemon-launch-paths'
import { createDaemonRecoveryProvider } from './daemon-recovery-provider'
import { installDaemonProvider } from './daemon-provider-state'
export function initDaemonRecoveryProvider(): void {
const provider = createDaemonRecoveryProvider(getDaemonRuntimeDir(), getDaemonHistoryDir())
installDaemonProvider(null, provider)
rebindLocalProviderListeners()
}
@@ -1,54 +0,0 @@
import { copyFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { createDaemonRecoveryProvider } from './daemon-recovery-provider'
import { getDaemonPidPath, getDaemonTokenPath } from './daemon-spawner'
import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS } from './types'
import {
createMockSubprocess,
startDaemonAdapterHarness,
type DaemonAdapterHarness
} from './daemon-pty-adapter-test-harness'
import type { DaemonPtyRouter } from './daemon-pty-router'
let harness: DaemonAdapterHarness
let provider: DaemonPtyRouter | undefined
beforeEach(async () => {
harness = await startDaemonAdapterHarness(() => createMockSubprocess())
copyFileSync(harness.tokenPath, getDaemonTokenPath(harness.dir))
})
afterEach(async () => {
provider?.dispose()
provider = undefined
harness.adapter.dispose()
await harness.server.shutdown()
rmSync(harness.dir, { recursive: true, force: true })
})
it('recovers live terminals without pruning folder or unknown workspace sessions', async () => {
await harness.adapter.spawn({ sessionId: 'folder-terminal', cols: 80, rows: 24 })
provider = createDaemonRecoveryProvider(harness.dir, join(harness.dir, 'history'))
expect(provider.getAllAdapters().every((entry) => entry.recoveryOnly)).toBe(true)
await expect(provider.reconcileOnStartup(new Set())).resolves.toEqual({
alive: ['folder-terminal'],
killed: []
})
await expect(
provider.spawn({ sessionId: 'folder-terminal', attachOnly: true, cols: 80, rows: 24 })
).resolves.toMatchObject({ isReattach: true })
await expect(provider.spawn({ cols: 80, rows: 24 })).rejects.toThrow('managed-stop recovery')
})
it('retains unreachable legacy generations and their credentials', async () => {
const version = PREVIOUS_DAEMON_PROTOCOL_VERSIONS[0]
const tokenPath = getDaemonTokenPath(harness.dir, version)
const pidPath = getDaemonPidPath(harness.dir, version)
writeFileSync(tokenPath, 'retained-secret')
writeFileSync(pidPath, '{unreadable pid')
provider = createDaemonRecoveryProvider(harness.dir, join(harness.dir, 'history'))
const legacy = provider.getAllAdapters().find((entry) => entry.protocolVersion === version)
expect(legacy?.recoveryOnly).toBe(true)
await expect(legacy!.listSessions()).rejects.toThrow()
expect(readFileSync(tokenPath, 'utf8')).toBe('retained-secret')
expect(readFileSync(pidPath, 'utf8')).toBe('{unreadable pid')
})
@@ -1,41 +0,0 @@
import { lstatSync } from 'node:fs'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyRouter } from './daemon-pty-router'
import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './daemon-spawner'
import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
function hasEndpointEvidence(path: string): boolean {
try {
lstatSync(path)
return true
} catch (error) {
// Unreadable evidence must keep the generation represented as unverifiable.
return !(error instanceof Error && 'code' in error && error.code === 'ENOENT')
}
}
export function createDaemonRecoveryProvider(
runtimeDir: string,
historyPath: string
): DaemonPtyRouter {
const create = (protocolVersion: number): DaemonPtyAdapter =>
new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(runtimeDir, protocolVersion),
tokenPath: getDaemonTokenPath(runtimeDir, protocolVersion),
pidPath: getDaemonPidPath(runtimeDir, protocolVersion),
profileScope: runtimeDir,
runtimeDir,
historyPath,
protocolVersion,
recoveryOnly: true
})
const legacy = PREVIOUS_DAEMON_PROTOCOL_VERSIONS.filter((version) =>
[
getDaemonPidPath(runtimeDir, version),
getDaemonTokenPath(runtimeDir, version),
...(process.platform === 'win32' ? [] : [getDaemonSocketPath(runtimeDir, version)])
].some(hasEndpointEvidence)
).map(create)
// Represent the current endpoint even when absent; missing contact is not an empty census.
return new DaemonPtyRouter({ current: create(PROTOCOL_VERSION), legacy })
}
@@ -1,21 +0,0 @@
import type { PtySpawnOptions } from '../providers/types'
import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
export function assertDaemonRecoverySpawnAdmission(
recoveryOnly: boolean,
protocolVersion: number,
opts: PtySpawnOptions
): void {
if (!recoveryOnly) {
return
}
// Legacy attach emulation can create before rejecting the result.
if (
opts.attachOnly !== true ||
!opts.sessionId ||
opts.agentSessionEnsure ||
protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
) {
throw new Error('Terminal daemon admission is fenced for managed-stop recovery')
}
}
+1 -2
View File
@@ -10,7 +10,6 @@ import type { DaemonSessionBackgroundRouting } from './daemon-session-background
import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe'
import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import type { DaemonTerminalAdmission } from './daemon-terminal-admission'
import { readDaemonHealthIdentity } from './daemon-health-identity'
import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry'
import type { TerminalHost } from './terminal-host'
import { SessionNotFoundError, type DaemonRequest } from './types'
@@ -157,7 +156,7 @@ export class DaemonRequestRouter {
return { health: await readCurrentProcessMacSystemResolverHealth() }
case 'ptySpawnHealth':
await this.options.ptySpawnHealthCheck()
return { healthy: true, ...readDaemonHealthIdentity() }
return { healthy: true }
case 'shutdown':
return this.shutdown(clientId, request.id, request.payload.killSessions)
}
@@ -1,64 +0,0 @@
import { expect, it, vi } from 'vitest'
import { DaemonRouterRetirement } from './daemon-router-retirement'
import { createAdapter } from './daemon-pty-router-test-fixture'
import { PROTOCOL_VERSION } from './types'
it.each(['inventory', 'protocol', 'spawn', 'live'] as const)(
'does not reopen admission on a %s retry after partial retirement',
async (failure) => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION)
let adapters = [current, legacy]
const retirement = new DaemonRouterRetirement(() => adapters)
vi.mocked(legacy.requestIdleRetirement).mockResolvedValueOnce({
state: 'busy',
liveSessions: 0
})
await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' })
expect(retirement.admissionClosed).toBe(true)
if (failure === 'inventory') {
vi.mocked(current.listSessions).mockRejectedValueOnce(new Error('lost connection'))
} else if (failure === 'protocol') {
adapters = [createAdapter('old', [], undefined, 23)]
} else if (failure === 'spawn') {
retirement.spawnInFlight = 1
} else {
adapters = [createAdapter('live', ['existing'], undefined, PROTOCOL_VERSION)]
}
expect(await retirement.requestIdleRetirement()).not.toHaveProperty('admissionReopened')
expect(retirement.admissionClosed).toBe(true)
}
)
it('does not reopen when every native result is busy without reopening proof', async () => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
vi.mocked(current.requestIdleRetirement).mockResolvedValue({ state: 'busy', liveSessions: 0 })
const retirement = new DaemonRouterRetirement(() => [current])
await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' })
expect(retirement.admissionClosed).toBe(true)
})
it('keeps the fence through a lost native reply and failed retry inventory', async () => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
vi.mocked(current.requestIdleRetirement).mockRejectedValueOnce(new Error('lost stop reply'))
const retirement = new DaemonRouterRetirement(() => [current])
await expect(retirement.requestIdleRetirement()).rejects.toThrow('lost stop reply')
vi.mocked(current.listSessions).mockRejectedValueOnce(new Error('lost connection'))
await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' })
expect(retirement.admissionClosed).toBe(true)
})
it('releases a fence left by an incomplete retirement, but never one that retired', async () => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
vi.mocked(current.requestIdleRetirement).mockResolvedValueOnce({ state: 'busy', liveSessions: 0 })
const retirement = new DaemonRouterRetirement(() => [current])
await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'unverifiable' })
expect(retirement.admissionClosed).toBe(true)
retirement.releaseFence()
expect(retirement.admissionClosed).toBe(false)
expect(current.releaseIdleRetirementFence).toHaveBeenCalledOnce()
await expect(retirement.requestIdleRetirement()).resolves.toEqual({ state: 'retiring' })
retirement.releaseFence()
expect(retirement.admissionClosed).toBe(true)
})
@@ -1,87 +0,0 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state'
import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types'
export class DaemonRouterRetirement {
admissionClosed = false
spawnInFlight = 0
private retirementAttempted = false
private retired = false
private idleRetirementPromise: Promise<DaemonIdleRetirementResult> | null = null
constructor(private readonly allAdapters: () => DaemonPtyAdapter[]) {}
/** Reopens a fence left by an attempt that did not retire every generation. */
releaseFence(): void {
if (this.idleRetirementPromise || this.retired) {
return
}
this.admissionClosed = false
for (const adapter of this.allAdapters()) {
adapter.releaseIdleRetirementFence()
}
}
async requestIdleRetirement(): Promise<DaemonIdleRetirementResult> {
if (this.idleRetirementPromise) {
return this.idleRetirementPromise
}
this.admissionClosed = true
const request = this.finishIdleRetirementRequest().finally(() => {
if (this.idleRetirementPromise === request) {
this.idleRetirementPromise = null
}
})
this.idleRetirementPromise = request
return request
}
private async finishIdleRetirementRequest(): Promise<DaemonIdleRetirementResult> {
const adapters = this.allAdapters()
if (this.spawnInFlight > 0) {
this.admissionClosed = this.retirementAttempted
return { state: 'busy', liveSessions: null }
}
if (adapters.some((adapter) => adapter.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION)) {
this.admissionClosed = this.retirementAttempted
return { state: 'unsupported' }
}
const inventories = await Promise.allSettled(adapters.map((adapter) => adapter.listSessions()))
if (inventories.some((inventory) => inventory.status === 'rejected')) {
this.admissionClosed = this.retirementAttempted
return { state: 'unverifiable' }
}
const liveSessions = inventories.reduce(
(count, inventory) =>
count +
(inventory.status === 'fulfilled'
? inventory.value.filter((session) => session.isAlive).length
: 0),
0
)
if (liveSessions > 0) {
this.admissionClosed = this.retirementAttempted
return {
state: 'busy',
liveSessions,
...(!this.retirementAttempted && !adapters.some((adapter) => adapter.recoveryOnly)
? { admissionReopened: true as const }
: {})
}
}
this.retirementAttempted = true
const results = await Promise.all(adapters.map((adapter) => adapter.requestIdleRetirement()))
if (results.every((result) => result.state === 'retiring')) {
this.retired = true
return { state: 'retiring' }
}
const refusedLiveSessions = results.reduce(
(count, result) => count + (result.state === 'busy' ? (result.liveSessions ?? 0) : 0),
0
)
if (refusedLiveSessions > 0) {
return { state: 'busy', liveSessions: refusedLiveSessions }
}
return { state: 'unverifiable' }
}
}
@@ -1,37 +0,0 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
export async function reconcileDaemonRouterSessions(
adapters: readonly DaemonPtyAdapter[],
ownerResolver: DaemonSessionOwnerResolver<DaemonPtyAdapter>,
validWorktreeIds: Set<string>
): Promise<{ alive: string[]; killed: string[] }> {
const alive: string[] = []
const killed: string[] = []
const aliveProviders = new Map<string, Set<DaemonPtyAdapter>>()
for (const adapter of adapters) {
const result = await adapter.reconcileOnStartup(validWorktreeIds)
// Why: daemon startup can reconcile many restored sessions; spreading
// those arrays into push can exceed JavaScript's argument limit.
for (const id of result.alive) {
alive.push(id)
}
for (const id of result.killed) {
killed.push(id)
}
for (const id of result.alive) {
const providers = aliveProviders.get(id) ?? new Set<DaemonPtyAdapter>()
providers.add(adapter)
aliveProviders.set(id, providers)
}
}
for (const id of new Set([...alive, ...killed])) {
const providers = aliveProviders.get(id)
if (providers?.size === 1) {
ownerResolver.recordRoute(id, providers.values().next().value!)
} else {
ownerResolver.forgetRoute(id)
}
}
return { alive, killed }
}
@@ -118,20 +118,6 @@ describe('DaemonStreamDataBatcher', () => {
})
})
it('preserves PTY incarnation identity through stream serialization', () => {
const { batcher, streamSocket } = createBatcher()
batcher.enqueue('client-1', 'session-1', 'output', {
flushImmediately: true,
incarnationId: 'incarnation-1'
})
expect(JSON.parse(String(streamSocket.write.mock.calls[0]?.[0]))).toMatchObject({
event: 'data',
payload: { data: 'output', incarnationId: 'incarnation-1' }
})
})
it('keeps large pending output batched even when an interactive redraw follows', () => {
vi.useFakeTimers()
try {
@@ -246,8 +246,7 @@ export class DaemonStreamDataBatcher {
this.maxLineBytes,
sliceSequenceChars,
entry.seq,
entry.transformed,
entry.incarnationId
entry.transformed
)
}
this.updateBackpressure(clientId, batch)
+3 -8
View File
@@ -2,7 +2,6 @@ import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
import { writeStreamDataEvents } from './daemon-stream-data-split'
import { encodeNdjson } from './ndjson'
import { accountDaemonStreamEntry } from './daemon-stream-entry-accounting'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type DaemonStreamEnqueueOptions = {
flushImmediately?: boolean
@@ -10,7 +9,6 @@ export type DaemonStreamEnqueueOptions = {
rawLength?: number
transformed?: boolean
seq?: number
incarnationId?: PtyIncarnationId
}
export function appendDaemonStreamData(
@@ -25,8 +23,7 @@ export function appendDaemonStreamData(
last?.sessionId === sessionId &&
!last.control &&
!last.transformed &&
options.transformed !== true &&
last.incarnationId === options.incarnationId
options.transformed !== true
) {
last.data += data
const rawLengthBefore = last.sequenceChars ?? last.data.length - data.length
@@ -42,8 +39,7 @@ export function appendDaemonStreamData(
? {}
: { sequenceChars: options.rawLength }),
...(options.transformed ? { transformed: true } : {}),
...(options.seq === undefined ? {} : { seq: options.seq }),
...(options.incarnationId === undefined ? {} : { incarnationId: options.incarnationId })
...(options.seq === undefined ? {} : { seq: options.seq })
})
)
}
@@ -95,8 +91,7 @@ export function flushDaemonStreamSession(
maxLineBytes,
entry.sequenceChars ?? entry.data.length,
entry.seq,
entry.transformed,
entry.incarnationId
entry.transformed
)
}
}
@@ -18,8 +18,7 @@ function write(
rawLength = data.length,
seq?: number,
transformed = false,
sessionId = 'session-1',
incarnationId?: string
sessionId = 'session-1'
): string[] {
const lines: string[] = []
const socket: Pick<Socket, 'write'> = {
@@ -28,16 +27,7 @@ function write(
return true
})
}
writeStreamDataEvents(
socket,
sessionId,
data,
maxLineBytes,
rawLength,
seq,
transformed,
incarnationId
)
writeStreamDataEvents(socket, sessionId, data, maxLineBytes, rawLength, seq, transformed)
return lines
}
@@ -94,15 +84,6 @@ describe('writeStreamDataEvents serialization budget', () => {
expect(encodeNdjson).toHaveBeenCalledTimes(1)
})
it('retains incarnation metadata in the single-encode fast path', () => {
const line = encodeStreamDataEvent('session-1', 'é🐙', undefined, undefined, false, 'epoch-1')
vi.mocked(encodeNdjson).mockClear()
expect(
write('é🐙', Buffer.byteLength(line), 3, undefined, false, 'session-1', 'epoch-1')
).toEqual([line])
expect(encodeNdjson).toHaveBeenCalledTimes(1)
})
it('does not add a duplicate full-data sizing probe to oversized writes', () => {
const data = '🐙\x1b[0m'.repeat(100)
const expected = previousWrites(data, 160)
@@ -121,24 +102,6 @@ describe('writeStreamDataEvents serialization budget', () => {
})
describe('writeStreamDataEvents wire parity', () => {
it.each([undefined, 9000])('budgets incarnation metadata on oversized writes (seq=%s)', (seq) => {
const data = '🐙é中\x1b[0m"\\\n'.repeat(100)
const incarnationId = 'epoch-'.repeat(16)
const lines = write(data, 384, data.length, seq, false, 'session-1', incarnationId)
expect(lines.length).toBeGreaterThan(1)
let consumed = 0
const chunks = lines.map((line) => {
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(384)
const { payload } = JSON.parse(line)
expect(payload.incarnationId).toBe(incarnationId)
expect(typeof payload.data).toBe('string')
consumed += payload.data.length
expect(payload.seq).toBe(seq === undefined ? undefined : seq - data.length + consumed)
return payload.data
})
expect(chunks.join('')).toBe(data)
})
it('preserves exact frames, chunk boundaries and metadata across payloads and caps', () => {
const payloads = [
'',
+14 -44
View File
@@ -5,15 +5,13 @@
*/
import { resolveSynchronizedOutputSafeSplit } from '../../shared/terminal-synchronized-output-scan'
import { encodeNdjson } from './ndjson'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export function encodeStreamDataEvent(
sessionId: string,
data: string,
rawLength?: number,
seq?: number,
transformed?: boolean,
incarnationId?: PtyIncarnationId
transformed?: boolean
): string {
return encodeNdjson({
type: 'event',
@@ -21,7 +19,6 @@ export function encodeStreamDataEvent(
sessionId,
payload: {
data,
...(incarnationId === undefined ? {} : { incarnationId }),
...(seq === undefined ? {} : { seq }),
...(rawLength === undefined ? {} : { rawLength }),
...(rawLength === undefined ? {} : { sequenceChars: rawLength }),
@@ -30,16 +27,8 @@ export function encodeStreamDataEvent(
})
}
function streamDataEventLineBytes(
sessionId: string,
data: string,
rawLength?: number,
incarnationId?: PtyIncarnationId
): number {
return Buffer.byteLength(
encodeStreamDataEvent(sessionId, data, rawLength, undefined, false, incarnationId),
'utf8'
)
function streamDataEventLineBytes(sessionId: string, data: string, rawLength?: number): number {
return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, rawLength), 'utf8')
}
function isHighSurrogate(value: number): boolean {
@@ -90,28 +79,20 @@ export function splitStreamDataForNdjson(
sessionId: string,
data: string,
maxLineBytes: number,
sequenceChars?: number,
incarnationId?: PtyIncarnationId
sequenceChars?: number
): string[] {
if (streamDataEventLineBytes(sessionId, data, sequenceChars, incarnationId) <= maxLineBytes) {
if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) {
return [data]
}
return splitOversizedStreamDataForNdjson(
sessionId,
data,
maxLineBytes,
sequenceChars,
incarnationId
)
return splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes, sequenceChars)
}
function splitOversizedStreamDataForNdjson(
sessionId: string,
data: string,
maxLineBytes: number,
sequenceChars?: number,
incarnationId?: PtyIncarnationId
sequenceChars?: number
): string[] {
const chunks: string[] = []
let start = 0
@@ -129,8 +110,7 @@ function splitOversizedStreamDataForNdjson(
}
if (
streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars, incarnationId) <=
maxLineBytes
streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes
) {
best = mid
low = rawMid + 1
@@ -154,36 +134,28 @@ export function writeStreamDataEvents(
maxLineBytes: number,
rawLength = data.length,
seq?: number,
transformed = false,
incarnationId?: PtyIncarnationId
transformed = false
): void {
const explicitRawLength = rawLength === data.length ? undefined : rawLength
if (transformed) {
streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true, incarnationId))
streamSocket.write(encodeStreamDataEvent(sessionId, data, rawLength, seq, true))
return
}
const carriesMetadata = explicitRawLength !== undefined || seq !== undefined
let chunks: string[]
if (!carriesMetadata) {
const line = encodeStreamDataEvent(sessionId, data, undefined, undefined, false, incarnationId)
const line = encodeStreamDataEvent(sessionId, data)
if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) {
streamSocket.write(line)
return
}
chunks = splitOversizedStreamDataForNdjson(
sessionId,
data,
maxLineBytes,
undefined,
incarnationId
)
chunks = splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes)
} else {
chunks = splitStreamDataForNdjson(
sessionId,
data,
Math.max(1, maxLineBytes - 96),
explicitRawLength,
incarnationId
explicitRawLength
)
}
let consumed = 0
@@ -191,8 +163,6 @@ export function writeStreamDataEvents(
consumed += chunk.length
const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed)
const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined
streamSocket.write(
encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq, false, incarnationId)
)
streamSocket.write(encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq))
}
}
-1
View File
@@ -9,7 +9,6 @@ export type DataEvent = {
sessionId: string
payload: {
data: string
incarnationId?: PtyIncarnationId
seq?: number
rawLength?: number
transformed?: boolean
@@ -1,59 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { appendDaemonStreamData, flushDaemonStreamSession } from './daemon-stream-data-entry'
import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop'
import { SessionOutputPlane } from './session-output-plane'
function batch(): PendingStreamDataBatch {
return {
timer: null,
queue: [],
queuedChars: 0,
queuedCharsBySession: new Map(),
queuedMetadataBytesBySession: new Map(),
droppableQueuedSessionIds: new Set()
}
}
describe('daemon stream incarnation ids', () => {
it('never merges output from two incarnations into one queued entry', () => {
const pending = batch()
appendDaemonStreamData(pending, 'session-1', 'old', { incarnationId: 'incarnation-1' })
appendDaemonStreamData(pending, 'session-1', 'more', { incarnationId: 'incarnation-1' })
appendDaemonStreamData(pending, 'session-1', 'new', { incarnationId: 'incarnation-2' })
expect(pending.queue.map(({ data, incarnationId }) => ({ data, incarnationId }))).toEqual([
{ data: 'oldmore', incarnationId: 'incarnation-1' },
{ data: 'new', incarnationId: 'incarnation-2' }
])
})
it('writes the id on per-session flushes and omits it when absent', () => {
const pending = batch()
appendDaemonStreamData(pending, 'session-1', 'tagged', { incarnationId: 'incarnation-1' })
appendDaemonStreamData(pending, 'session-1', 'legacy', {})
const lines: string[] = []
flushDaemonStreamSession(pending, 'session-1', 64 * 1024, (line) => lines.push(line))
const payloads = lines.map((line) => JSON.parse(line).payload)
expect(payloads).toEqual([
{ data: 'tagged', incarnationId: 'incarnation-1' },
{ data: 'legacy' }
])
})
it('hands the session incarnation only to identity-aware clients', () => {
const plane = new SessionOutputPlane({ cols: 80, rows: 24, incarnationId: 'incarnation-1' })
const legacy = vi.fn()
const aware = vi.fn()
const unused = vi.fn()
plane.attachClient({ onData: legacy, onExit: vi.fn() })
plane.attachClient({ onData: unused, onDataWithIncarnation: aware, onExit: vi.fn() })
plane.emit({ data: 'hi', rawStartSeq: 0, rawEndSeq: 2, transformed: false })
expect(legacy).toHaveBeenCalledWith('hi')
expect(aware).toHaveBeenCalledWith('hi', undefined, undefined, undefined, 'incarnation-1')
expect(unused).not.toHaveBeenCalled()
plane.disposeEmulator()
})
})
@@ -14,7 +14,6 @@ import {
accountDaemonStreamEntry,
releaseDaemonStreamEntry
} from './daemon-stream-entry-accounting'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
// A control entry carries a whole pre-shaped stream event (background marker,
// data gap, transient fact) that must ride at its exact position in the
@@ -28,7 +27,6 @@ export type StreamQueueEntry = {
sequenceChars?: number
seq?: number
transformed?: boolean
incarnationId?: PtyIncarnationId
control?: DaemonEvent
retainedBytes?: number
}
+16 -25
View File
@@ -173,32 +173,23 @@ export class DaemonTerminalAdmission {
clientId: string,
sessionId: () => string
): CreateOrAttachOptions['streamClient'] {
const onData = (
data: string,
rawLength = data.length,
transformed = false,
seq?: number,
incarnationId?: string
): void => {
const routedSessionId = sessionId()
this.options.transientFactRelay.onSessionData(routedSessionId, data)
const lastInputAt = this.options.attachments.lastInputAt(routedSessionId)
const isInteractiveOutput =
data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS &&
lastInputAt !== undefined &&
performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS
this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
flushImmediately: isInteractiveOutput,
flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS,
rawLength,
transformed,
seq,
...(incarnationId === undefined ? {} : { incarnationId })
})
}
return {
onData,
onDataWithIncarnation: onData,
onData: (data, rawLength = data.length, transformed = false, seq) => {
const routedSessionId = sessionId()
this.options.transientFactRelay.onSessionData(routedSessionId, data)
const lastInputAt = this.options.attachments.lastInputAt(routedSessionId)
const isInteractiveOutput =
data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS &&
lastInputAt !== undefined &&
performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS
this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, {
flushImmediately: isInteractiveOutput,
flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS,
rawLength,
transformed,
seq
})
},
onExit: (code, incarnationId, cause) => {
const routedSessionId = sessionId()
this.options.log.log('session-exited', {
@@ -1,7 +1,6 @@
import { SessionOutputPlane } from './session-output-plane'
import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier'
import type { SubprocessHandle } from './session-subprocess-handle'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
/** The session's ordered output pipeline: the recovery barrier feeding the
* output plane. Built together because the barrier's owner is what the
@@ -12,7 +11,6 @@ export function createSessionOutputPipeline(opts: {
scrollback?: number | undefined
wslDistro?: string | undefined
historySeedChunks?: readonly string[] | undefined
incarnationId?: PtyIncarnationId | undefined
subprocess: SubprocessHandle
isAlive: () => boolean
}): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } {
@@ -24,7 +22,6 @@ export function createSessionOutputPipeline(opts: {
scrollback: opts.scrollback,
wslDistro: opts.wslDistro,
historySeedChunks: opts.historySeedChunks,
incarnationId: opts.incarnationId,
getTerminalOwner: () => barrier?.getOwner()
})
const recoveryBarrier = new TerminalShellRecoveryBarrier({
+5 -30
View File
@@ -5,7 +5,6 @@ import { normalizePtySize } from './daemon-pty-size'
import type { PtyIngressEmission } from '../../shared/pty-startup-ingress'
import type { PendingOutputRecord, TakePendingOutputResult, TerminalSnapshot } from './types'
import type { TerminalOwner } from '../../shared/terminal-owner'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { SubprocessHandle } from './session-subprocess-handle'
import { nudgePowerShellPromptRepaint } from './session-powershell-prompt-repaint'
@@ -16,14 +15,6 @@ const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
export type AttachedClient = {
token: symbol
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
/** Identity-bearing callback used by mutation-aware consumers; legacy clients keep the old shape. */
onDataWithIncarnation?: (
data: string,
rawLength: number | undefined,
transformed: boolean | undefined,
seq: number | undefined,
incarnationId: PtyIncarnationId
) => void
onExit: (code: number, incarnationId: string, cause?: TerminalExitCause) => void
}
@@ -33,7 +24,6 @@ export type SessionOutputPlaneOptions = {
scrollback?: number | undefined
wslDistro?: string | undefined
historySeedChunks?: readonly string[] | undefined
incarnationId?: PtyIncarnationId | undefined
/** Read from the recovery barrier at snapshot time; the barrier scans bytes
* before this plane receives them, so its owner never lags the emulator. */
getTerminalOwner?: (() => TerminalOwner | undefined) | undefined
@@ -45,7 +35,6 @@ export class SessionOutputPlane {
readonly historySeeded: boolean | undefined
private readonly emulator: HeadlessEmulator
private readonly readTerminalOwner: (() => TerminalOwner | undefined) | undefined
private readonly incarnationId: PtyIncarnationId | undefined
private attachedClients: AttachedClient[] = []
private pendingOutputRecords: PendingOutputRecord[] = []
private pendingOutputBytes = 0
@@ -75,7 +64,6 @@ export class SessionOutputPlane {
? undefined
: opts.historySeedChunks.every((chunk) => this.emulator.writeSync(chunk))
this.readTerminalOwner = opts.getTerminalOwner
this.incarnationId = opts.incarnationId
}
get responderParser(): HeadlessEmulator['responderParser'] {
@@ -209,7 +197,9 @@ export class SessionOutputPlane {
return
}
this.record({ kind: 'output', data: pending })
this.broadcastData(pending, 0, true, this._outputSequence)
for (const client of this.attachedClients) {
client.onData(pending, 0, true, this._outputSequence)
}
}
emit(emission: PtyIngressEmission): void {
@@ -226,24 +216,9 @@ export class SessionOutputPlane {
}
// Broadcast to attached clients
if (emission.transformed || rawLength !== data.length) {
this.broadcastData(data, rawLength, true, this._outputSequence)
} else {
this.broadcastData(data)
}
}
private broadcastData(
data: string,
rawLength?: number,
transformed?: boolean,
seq?: number
): void {
for (const client of this.attachedClients) {
if (client.onDataWithIncarnation && this.incarnationId) {
client.onDataWithIncarnation(data, rawLength, transformed, seq, this.incarnationId)
} else if (transformed || rawLength !== undefined || seq !== undefined) {
client.onData(data, rawLength, transformed, seq)
if (emission.transformed || rawLength !== data.length) {
client.onData(data, rawLength, true, this._outputSequence)
} else {
client.onData(data)
}
+12 -8
View File
@@ -12,7 +12,12 @@ import type { TuiAgent } from '../../shared/tui-agent'
import { randomUUID } from 'node:crypto'
import { PtyStartupIngress } from '../../shared/pty-startup-ingress'
import type * as SessionProtocol from './types'
import type {
SessionState,
ShellReadyState,
TakePendingOutputResult,
TerminalSnapshot
} from './types'
import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
@@ -23,7 +28,7 @@ export class Session {
readonly launchAgent: TuiAgent | null
readonly wslDistro: string | null
readonly processNameIsSpawnFile: boolean
private _state: SessionProtocol.SessionState = 'running'
private _state: SessionState = 'running'
private _exitCode: number | null = null
private _disposed = false
private subprocess: SubprocessHandle
@@ -50,8 +55,7 @@ export class Session {
wslDistro: opts.wslDistro,
historySeedChunks: opts.historySeedChunks,
subprocess: this.subprocess,
isAlive: () => !this._disposed && this._state !== 'exited',
incarnationId: this.incarnationId
isAlive: () => !this._disposed && this._state !== 'exited'
})
this.output = pipeline.output
this.recoveryBarrier = pipeline.recoveryBarrier
@@ -91,11 +95,11 @@ export class Session {
this.subprocess.onExit((code, cause) => this.handleSubprocessExit(code, cause))
}
get state(): SessionProtocol.SessionState {
get state(): SessionState {
return this._state
}
get shellState(): SessionProtocol.ShellReadyState {
get shellState(): ShellReadyState {
return this.shellReady.state
}
@@ -212,7 +216,7 @@ export class Session {
this.producerPause.release({ resume: true })
}
getSnapshot(opts: { scrollbackRows?: number } = {}): SessionProtocol.TerminalSnapshot | null {
getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null {
this.startupIngress.snapshotBarrier()
return this.output.getSnapshot(opts)
}
@@ -228,7 +232,7 @@ export class Session {
takePendingOutput(
includeSnapshot: boolean,
opts: { teardownSnapshot?: boolean } = {}
): SessionProtocol.TakePendingOutputResult | null {
): TakePendingOutputResult | null {
if (this._disposed) {
return null
}
@@ -37,13 +37,6 @@ export type CreateOrAttachOptions = {
}
streamClient: {
onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void
onDataWithIncarnation?: (
data: string,
rawLength: number | undefined,
transformed: boolean | undefined,
seq: number | undefined,
incarnationId: PtyIncarnationId
) => void
onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void
}
/** Lets the daemon route output under the adopted owner's canonical id before
@@ -77,7 +77,12 @@ describe('local repo ref maintenance target', () => {
it('reads either Git auto-maintenance opt-out, and unset keys as consent', async () => {
for (const stdout of [
'maintenance.auto false\n',
'maintenance.auto NO\n',
'maintenance.auto off\n',
'maintenance.auto 0\n',
'maintenance.auto \n',
'gc.auto 0\n',
'gc.auto 0k\n',
'gc.auto 6700\nmaintenance.auto false\n'
]) {
gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' })
@@ -91,10 +96,20 @@ describe('local repo ref maintenance target', () => {
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false)
// `git config --get-regexp` exits non-zero when nothing matches.
gitExecFileAsyncMock.mockRejectedValue(new Error('exit 1'))
gitExecFileAsyncMock.mockRejectedValue(Object.assign(new Error('key unset'), { code: 1 }))
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false)
})
it('fails closed when the auto-maintenance config cannot be read', async () => {
for (const error of [
new Error('spawn failed'),
Object.assign(new Error('bad config'), { code: 128 })
]) {
gitExecFileAsyncMock.mockRejectedValue(error)
await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(true)
}
})
it('walks the POSIX refs directory for a native repo', async () => {
readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git')
+29 -5
View File
@@ -12,6 +12,11 @@ import {
import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
import { withSpan } from '../observability/tracer'
import { PackRefsLockOwnership } from './pack-refs-lock-ownership'
import {
clearRepoPackIndexMaintenanceCache,
isUnsetGitConfigError,
maintainRepoPackIndex
} from './repo-pack-index-maintenance'
import { gitExecFileAsync } from './runner'
import { readRepoCommonDirFromGit } from './worktree-list-reader'
@@ -96,6 +101,7 @@ export function disposeLocalRepoRefMaintenance(): Promise<void> {
shared?.dispose()
shared = null
repoBusyProbes.clear()
clearRepoPackIndexMaintenanceCache()
return settling
}
@@ -149,6 +155,7 @@ export function _resetLocalRepoRefMaintenanceForTests(
shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null
activityProbe = null
repoBusyProbes.clear()
clearRepoPackIndexMaintenanceCache()
}
/**
@@ -174,8 +181,11 @@ function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | unde
export function isGitAutoMaintenanceDisabled(configOutput: string): boolean {
return configOutput
.split('\n')
.map((line) => line.trim())
.some((line) => line === 'maintenance.auto false' || line === 'gc.auto 0')
.some(
(line) =>
/^\s*maintenance\.auto\s+(?:false|no|off|0)?\s*$/i.test(line) ||
/^\s*gc\.auto\s+[+-]?0+(?:[kmg])?\s*$/i.test(line)
)
}
/**
@@ -237,11 +247,25 @@ export function createLocalRepoRefMaintenanceTarget(
{ cwd: args.repoPath, ...gitOptions, admissionTier: 'background', signal }
)
return isGitAutoMaintenanceDisabled(stdout)
} catch {
// Neither key set is the common case and exits non-zero; that is consent.
return false
} catch (error) {
// An unset key is consent; unreadable or invalid config must fail closed.
return !isUnsetGitConfigError(error)
}
},
async maintainPackIndex(signal, span, canWrite) {
const resolved = await resolveCommonDir(signal)
if (resolved) {
return maintainRepoPackIndex({
repoPath: args.repoPath,
commonDir: gitCommonDirForMainProcess(resolved, args.wslDistro),
...gitOptions,
signal,
span,
canWrite
})
}
return 'failed'
},
async packRefs(lock: PackedRefsLockReporter) {
const resolved = await resolveCommonDir()
const owner = resolved
@@ -0,0 +1,258 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, opendirMock, statMock, openMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
opendirMock: vi.fn(),
statMock: vi.fn(),
openMock: vi.fn()
}))
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
vi.mock('node:fs/promises', () => ({ opendir: opendirMock, stat: statMock, open: openMock }))
import {
maintainRepoPackIndex,
clearRepoPackIndexMaintenanceCache,
PACK_INDEX_FORCE_REFRESH_MS,
PACK_INDEX_THRESHOLD,
PACK_INDEX_TIMEOUT_MS
} from './repo-pack-index-maintenance'
import { PACK_INDEX_PROBE_ENTRY_LIMIT } from './repo-pack-index-state'
function directory(packs: number) {
return {
async *[Symbol.asyncIterator]() {
for (let index = 0; index < packs; index += 1) {
yield { name: `pack-${index}.idx`, isFile: () => true, isSymbolicLink: () => false }
yield { name: `pack-${index}.pack`, isFile: () => true, isSymbolicLink: () => false }
}
}
}
}
function args(wslDistro?: string) {
const attributes: Record<string, unknown> = {}
return {
repoPath: wslDistro ? '//wsl$/Ubuntu/repo' : '/repo',
commonDir: wslDistro ? String.raw`\\wsl.localhost\Ubuntu\repo\.git` : '/repo/.git',
...(wslDistro ? { wslDistro } : {}),
signal: new AbortController().signal,
canWrite: () => true,
span: {
setAttribute: (key: string, value: unknown) => {
attributes[key] = value
}
},
attributes
}
}
beforeEach(() => {
vi.resetAllMocks()
clearRepoPackIndexMaintenanceCache()
gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => {
if (argv[0] === 'config') {
throw Object.assign(new Error('unset'), { code: 1 })
}
return { stdout: '', stderr: '' }
})
opendirMock.mockResolvedValue(directory(PACK_INDEX_THRESHOLD))
statMock.mockResolvedValue({ dev: 1n, ino: 2n, mtimeNs: 3n, ctimeNs: 4n })
openMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
})
describe('idle pack index maintenance', () => {
it('leaves a healthy repository alone', async () => {
const options = args()
opendirMock.mockResolvedValue(directory(PACK_INDEX_THRESHOLD - 1))
await maintainRepoPackIndex(options)
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(options.attributes['git.pack_index_outcome']).toBe('below_threshold')
})
it('writes only the lookup index with background admission and a deadline', async () => {
const options = args()
await maintainRepoPackIndex(options)
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(['multi-pack-index', 'write'], {
cwd: '/repo',
admissionTier: 'background',
timeout: PACK_INDEX_TIMEOUT_MS
})
expect(options.attributes['git.pack_index_outcome']).toBe('written')
})
it('rechecks idle admission after probing and never aborts an admitted writer', async () => {
const options = args()
await maintainRepoPackIndex({ ...options, canWrite: () => false })
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(opendirMock).not.toHaveBeenCalled()
expect(options.attributes['git.pack_index_outcome']).toBe('deferred')
let idle = true
opendirMock.mockResolvedValueOnce({
async *[Symbol.asyncIterator]() {
yield* directory(PACK_INDEX_THRESHOLD)
idle = false
}
})
await expect(maintainRepoPackIndex({ ...args(), canWrite: () => idle })).resolves.toBe(
'deferred'
)
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
await maintainRepoPackIndex(args())
expect(gitExecFileAsyncMock.mock.lastCall?.[1]).not.toHaveProperty('signal')
})
it('caps a directory stream and skips writes when bitmap absence cannot be proved', async () => {
let produced = 0
let closed = false
opendirMock.mockResolvedValue({
async *[Symbol.asyncIterator]() {
try {
while (produced < 100_000) {
produced += 1
yield { name: `pack-${produced}.pack`, isFile: () => true, isSymbolicLink: () => false }
}
} finally {
closed = true
}
}
})
const options = args()
await maintainRepoPackIndex(options)
expect(produced).toBe(PACK_INDEX_PROBE_ENTRY_LIMIT)
expect(closed).toBe(true)
expect(options.attributes['git.pack_index_outcome']).toBe('protected')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('inspects the measured 11,424-pack repository with four directory entries per pack', async () => {
let produced = 0
opendirMock.mockResolvedValue({
async *[Symbol.asyncIterator]() {
for (let index = 0; index < 11_424; index += 1) {
for (const suffix of ['pack', 'idx', 'rev', 'keep']) {
produced += 1
yield {
name: `pack-${index}.${suffix}`,
isFile: () => true,
isSymbolicLink: () => false
}
}
}
}
})
await expect(maintainRepoPackIndex(args())).resolves.toBe('written')
expect(produced).toBe(11_424 * 4)
expect(opendirMock).toHaveBeenCalledOnce()
})
it('honours an explicit multi-pack-index opt-out before walking objects', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'false\n', stderr: '' })
await maintainRepoPackIndex(args())
expect(opendirMock).not.toHaveBeenCalled()
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('fails closed on config errors other than an unset key', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('invalid config'), { code: 128 })
)
const options = args()
await maintainRepoPackIndex(options)
expect(opendirMock).not.toHaveBeenCalled()
expect(options.attributes['git.pack_index_outcome']).toBe('failed')
})
it('does not infer consent from an unreadable boolean or a missing Git binary', async () => {
for (const config of [
() => Promise.resolve({ stdout: 'unexpected', stderr: '' }),
() => Promise.reject(Object.assign(new Error('missing Git'), { code: 'ENOENT' }))
]) {
gitExecFileAsyncMock.mockImplementationOnce(config)
const options = args()
await maintainRepoPackIndex(options)
expect(options.attributes['git.pack_index_outcome']).toBe('failed')
}
expect(opendirMock).not.toHaveBeenCalled()
})
it('records index failures without preventing later ref maintenance', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(Object.assign(new Error('unset'), { code: 1 }))
.mockRejectedValueOnce(new Error('index locked'))
const options = args()
await expect(maintainRepoPackIndex(options)).resolves.toBe('failed')
expect(options.attributes['git.pack_index_outcome']).toBe('failed')
})
it('skips repositories without pack files and cancelled attempts', async () => {
statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
const options = args()
await maintainRepoPackIndex(options)
expect(options.attributes['git.pack_index_outcome']).toBe('below_threshold')
const abort = new AbortController()
abort.abort()
gitExecFileAsyncMock.mockClear()
await maintainRepoPackIndex({ ...args(), signal: abort.signal })
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('walks the WSL share and runs Git on that execution host', async () => {
const options = args('Ubuntu')
await maintainRepoPackIndex(options)
expect(opendirMock).toHaveBeenCalledWith(
String.raw`\\wsl.localhost\Ubuntu\repo\.git\objects\pack`
)
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
['multi-pack-index', 'write'],
expect.objectContaining({ cwd: '//wsl$/Ubuntu/repo', wslDistro: 'Ubuntu' })
)
})
it('skips unchanged directory stamps but refreshes changed packs and periodically rechecks', async () => {
const options = args()
await maintainRepoPackIndex(options)
await expect(maintainRepoPackIndex(options)).resolves.toBe('unchanged')
expect(opendirMock).toHaveBeenCalledOnce()
statMock.mockResolvedValue({ dev: 1n, ino: 2n, mtimeNs: 5n, ctimeNs: 6n })
await expect(maintainRepoPackIndex(options)).resolves.toBe('written')
const now = Date.now()
vi.spyOn(Date, 'now').mockReturnValue(now + PACK_INDEX_FORCE_REFRESH_MS + 1)
await expect(maintainRepoPackIndex(options)).resolves.toBe('written')
vi.restoreAllMocks()
})
it('isolates directory stamps by the execution host', async () => {
const options = args()
await maintainRepoPackIndex(options)
await expect(maintainRepoPackIndex({ ...options, wslDistro: 'Ubuntu' })).resolves.toBe(
'written'
)
})
it('protects metadata discovered after the pack threshold during the final probe', async () => {
opendirMock.mockResolvedValueOnce({
async *[Symbol.asyncIterator]() {
yield* directory(PACK_INDEX_THRESHOLD)
yield { name: 'multi-pack-index-old.bitmap', isFile: () => true }
}
})
const options = args()
await expect(maintainRepoPackIndex(options)).resolves.toBe('protected')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('does not start the writer when the final probe is cancelled', async () => {
const controller = new AbortController()
opendirMock.mockResolvedValueOnce({
async *[Symbol.asyncIterator]() {
yield* directory(PACK_INDEX_THRESHOLD)
controller.abort()
}
})
const options = { ...args(), signal: controller.signal }
await expect(maintainRepoPackIndex(options)).resolves.toBe('deferred')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(options.attributes['git.pack_index_pack_count_floor']).toBe(PACK_INDEX_THRESHOLD)
})
})
+122
View File
@@ -0,0 +1,122 @@
import { posix, win32 } from 'node:path'
import { BoundedMap } from '../../shared/bounded-map'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import type { PackIndexMaintenanceOutcome } from '../../shared/repo-pack-index-maintenance-policy'
import type { RefMaintenanceSpan } from '../../shared/repo-ref-maintenance-policy'
import { probeRepoPackIndexDirectory, readRepoPackDirectoryStamp } from './repo-pack-index-state'
import { gitExecFileAsync } from './runner'
// Git's default auto-GC pack limit is 50; defer indexing until fragmentation is clear.
export const PACK_INDEX_THRESHOLD = 64
export const PACK_INDEX_TIMEOUT_MS = 60_000
export const PACK_INDEX_FORCE_REFRESH_MS = 6 * 60 * 60_000
const indexedDirectories = new BoundedMap<string, { stamp: string; writtenAt: number }>({
maxEntries: 64
})
export function clearRepoPackIndexMaintenanceCache(): void {
indexedDirectories.clear()
}
export function isUnsetGitConfigError(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 1
}
type PackIndexMaintenanceArgs = {
repoPath: string
/** Common directory in the spelling the main process can open. */
commonDir: string
wslDistro?: string
signal: AbortSignal
span: RefMaintenanceSpan
canWrite: () => boolean
}
export async function maintainRepoPackIndex(
args: PackIndexMaintenanceArgs
): Promise<PackIndexMaintenanceOutcome> {
const { signal, span } = args
const outcome = (value: PackIndexMaintenanceOutcome): PackIndexMaintenanceOutcome => {
span.setAttribute('git.pack_index_outcome', value)
return value
}
if (signal.aborted) {
return outcome('deferred')
}
const gitOptions = {
cwd: args.repoPath,
...(args.wslDistro ? { wslDistro: args.wslDistro } : {}),
admissionTier: 'background' as const,
signal
}
try {
try {
const { stdout } = await gitExecFileAsync(
['config', '--bool', '--get', 'core.multiPackIndex'],
gitOptions
)
const configured = stdout.trim()
if (configured === 'false') {
return outcome('opted_out')
}
if (configured !== 'true') {
throw new Error('Unrecognized core.multiPackIndex config')
}
} catch (error) {
if (!isUnsetGitConfigError(error)) {
throw error
}
}
if (signal.aborted) {
return outcome('deferred')
}
const paths = isWindowsAbsolutePathLike(args.commonDir) ? win32 : posix
const directory = paths.join(args.commonDir, 'objects', 'pack')
const stamp = await readRepoPackDirectoryStamp(directory)
if (!stamp) {
return outcome('below_threshold')
}
const key = `${args.wslDistro ? `wsl:${args.wslDistro}` : 'local'}::${args.commonDir}`
const previous = indexedDirectories.get(key)
if (
previous?.stamp === stamp &&
Date.now() - previous.writtenAt < PACK_INDEX_FORCE_REFRESH_MS
) {
return outcome('unchanged')
}
if (signal.aborted || !args.canWrite()) {
return outcome('deferred')
}
const probe = await probeRepoPackIndexDirectory(directory, PACK_INDEX_THRESHOLD, signal)
span.setAttribute('git.pack_index_pack_count_floor', probe.packCountFloor)
if (signal.aborted || !args.canWrite()) {
return outcome('deferred')
}
if (probe.protected) {
return outcome('protected')
}
if (probe.packCountFloor < PACK_INDEX_THRESHOLD) {
return outcome('below_threshold')
}
const startedAt = Date.now()
// Git 2.20+: writes lookup metadata atomically without rewriting or deleting packs.
// Let the writer finish: force-killing it on Windows can strand its index lock.
await gitExecFileAsync(['multi-pack-index', 'write'], {
cwd: args.repoPath,
...(args.wslDistro ? { wslDistro: args.wslDistro } : {}),
admissionTier: 'background',
timeout: PACK_INDEX_TIMEOUT_MS
})
const writtenStamp = await readRepoPackDirectoryStamp(directory).catch(() => undefined)
// A racing new pack remains readable; the forced refresh bounds a missed directory change.
if (writtenStamp) {
indexedDirectories.set(key, { stamp: writtenStamp, writtenAt: Date.now() })
}
span.setAttribute('git.pack_index_write_ms', Date.now() - startedAt)
return outcome('written')
} catch (error) {
span.setAttribute('git.pack_index_error', String(error))
return outcome('failed')
}
}
@@ -0,0 +1,63 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { probeRepoPackIndexDirectory } from './repo-pack-index-state'
const roots: string[] = []
async function directory(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-pack-index-state-'))
roots.push(root)
return root
}
function indexHeader(version = 1, hash = 1): Buffer {
const value = Buffer.alloc(1200)
value.write('MIDX')
value[4] = version
value[5] = hash
value[6] = 4
return value
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('pack-index metadata protection', () => {
it('protects every retained bitmap regardless of its checksum', async () => {
const packs = await directory()
await writeFile(join(packs, 'multi-pack-index'), indexHeader())
await writeFile(join(packs, 'multi-pack-index-old.bitmap'), 'retained bitmap')
await expect(
probeRepoPackIndexDirectory(packs, 64, new AbortController().signal)
).resolves.toMatchObject({ protected: true })
})
it('protects an incremental chain without reading its layers', async () => {
const packs = await directory()
await mkdir(join(packs, 'multi-pack-index.d'))
await expect(
probeRepoPackIndexDirectory(packs, 64, new AbortController().signal)
).resolves.toMatchObject({ protected: true })
})
it.each([indexHeader(2), indexHeader(1, 3), Buffer.from('MIDX'), Buffer.alloc(1200)])(
'fails closed on an unknown or truncated MIDX',
async (header) => {
const packs = await directory()
await writeFile(join(packs, 'multi-pack-index'), header)
await expect(
probeRepoPackIndexDirectory(packs, 64, new AbortController().signal)
).resolves.toMatchObject({ protected: true })
}
)
it.each([1, 2])('accepts the standalone v1 SHA hash format %s', async (hash) => {
const packs = await directory()
await writeFile(join(packs, 'multi-pack-index'), indexHeader(1, hash))
await expect(
probeRepoPackIndexDirectory(packs, 64, new AbortController().signal)
).resolves.toEqual({ protected: false, packCountFloor: 0 })
})
})
+94
View File
@@ -0,0 +1,94 @@
import { open, opendir, stat } from 'node:fs/promises'
import { posix, win32 } from 'node:path'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
export const PACK_INDEX_PROBE_ENTRY_LIMIT = 65_536
export const PACK_INDEX_PROBE_TIMEOUT_MS = 1_000
export function isMissingPackIndexPath(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
}
export async function readRepoPackDirectoryStamp(directory: string): Promise<string | undefined> {
try {
const value = await stat(directory, { bigint: true })
return `${value.dev}:${value.ino}:${value.mtimeNs}:${value.ctimeNs}`
} catch (error) {
if (isMissingPackIndexPath(error)) {
return undefined
}
throw error
}
}
async function hasUnsupportedPackIndex(directory: string): Promise<boolean> {
const paths = isWindowsAbsolutePathLike(directory) ? win32 : posix
const handle = await open(paths.join(directory, 'multi-pack-index'), 'r').catch(
(error: unknown) => {
if (isMissingPackIndexPath(error)) {
return undefined
}
throw error
}
)
if (!handle) {
return false
}
try {
const header = Buffer.alloc(12)
const { bytesRead } = await handle.read(header, 0, header.length, 0)
const hashBytes = header[5] === 1 ? 20 : header[5] === 2 ? 32 : 0
const chunks = header[6] ?? 0
const size = (await handle.stat()).size
if (
bytesRead !== header.length ||
header.subarray(0, 4).toString() !== 'MIDX' ||
header[4] !== 1 ||
!hashBytes ||
chunks < 4 ||
header[7] !== 0 ||
size < 12 + (chunks + 1) * 12 + 1024 + hashBytes
) {
return true
}
const trailer = Buffer.alloc(hashBytes)
return (await handle.read(trailer, 0, hashBytes, size - hashBytes)).bytesRead !== hashBytes
} finally {
await handle.close()
}
}
/** A capped name walk protects every retained bitmap, including an older index's bitmap. */
export async function probeRepoPackIndexDirectory(
directory: string,
packThreshold: number,
signal: AbortSignal
): Promise<{ packCountFloor: number; protected: boolean }> {
const entries = await opendir(directory)
const startedAt = Date.now()
let visited = 0
let packCountFloor = 0
for await (const entry of entries) {
if (signal.aborted) {
return { packCountFloor, protected: true }
}
if (
entry.name === 'multi-pack-index.d' ||
(entry.name.startsWith('multi-pack-index') && entry.name.endsWith('.bitmap')) ||
(entry.name === 'multi-pack-index' && !entry.isFile())
) {
return { packCountFloor, protected: true }
}
if (entry.name.endsWith('.pack') && (entry.isFile() || entry.isSymbolicLink())) {
packCountFloor = Math.min(packThreshold, packCountFloor + 1)
}
visited += 1
if (
visited >= PACK_INDEX_PROBE_ENTRY_LIMIT ||
Date.now() - startedAt >= PACK_INDEX_PROBE_TIMEOUT_MS
) {
return { packCountFloor, protected: true }
}
}
return { packCountFloor, protected: await hasUnsupportedPackIndex(directory) }
}
@@ -1,10 +1,11 @@
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { countLooseRefs } from '../../shared/loose-ref-count'
import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance'
import { PACK_INDEX_MAINTENANCE_COOLDOWN_MS } from '../../shared/repo-pack-index-maintenance-policy'
import {
_resetLocalRepoRefMaintenanceForTests,
createLocalRepoRefMaintenanceTarget,
@@ -12,6 +13,7 @@ import {
setRepoMaintenanceActivityProbe
} from './local-repo-ref-maintenance'
import { forceDeleteLocalBranch } from './worktree-branch-removal'
import { maintainRepoPackIndex, PACK_INDEX_THRESHOLD } from './repo-pack-index-maintenance'
const roots: string[] = []
// Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts
@@ -19,14 +21,51 @@ const roots: string[] = []
const QUIET_MS = 25
const THRESHOLD = 20
function git(cwd: string, args: string[]): string {
function git(cwd: string, args: string[], input?: string): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
input,
stdio: ['pipe', 'pipe', 'pipe']
}).trim()
}
function hasWriteOption(option: string): boolean {
try {
git(process.cwd(), ['multi-pack-index', 'write', '-h'])
} catch (error) {
if (typeof error === 'object' && error !== null) {
return (
('stderr' in error && String(error.stderr).includes(option)) ||
('stdout' in error && String(error.stdout).includes(option))
)
}
}
return false
}
async function createFragmentedPacks(repoPath: string): Promise<string[]> {
const objects = join(repoPath, '.git', 'objects')
const blobs: string[] = []
for (let index = 0; index < PACK_INDEX_THRESHOLD; index += 1) {
const blob = git(repoPath, ['hash-object', '-w', '--stdin'], `packed-${index}\n`)
blobs.push(blob)
git(repoPath, ['pack-objects', join(objects, 'pack', 'pack')], `${blob}\n`)
await rm(join(objects, blob.slice(0, 2), blob.slice(2)))
}
return blobs
}
function maintainIndex(repoPath: string) {
return maintainRepoPackIndex({
repoPath,
commonDir: join(repoPath, '.git'),
signal: new AbortController().signal,
span: { setAttribute: () => {} },
canWrite: () => true
})
}
/** A repo whose only loose-ref backlog is the one the test asks for. */
async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> {
const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-'))
@@ -49,13 +88,17 @@ async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDi
return { repoPath, refsDir: join(repoPath, '.git', 'refs') }
}
function createMaintenance(onPackRefs: () => void = () => {}): {
function createMaintenance(
onPackRefs: () => void = () => {},
now?: () => number
): {
maintenance: RepoRefMaintenance
arm: (repoPath: string) => void
} {
const maintenance = new RepoRefMaintenance({
quietPeriodMs: QUIET_MS,
looseRefThreshold: THRESHOLD
looseRefThreshold: THRESHOLD,
...(now ? { now } : {})
})
return {
maintenance,
@@ -99,6 +142,117 @@ afterEach(async () => {
})
describe('idle ref maintenance against real Git', () => {
it('indexes fragmented packs without rewriting objects or requiring loose-ref debt', async () => {
const { repoPath } = await createRepo(0)
git(repoPath, ['config', 'maintenance.auto', 'true'])
git(repoPath, ['config', 'gc.auto', '6700'])
const objects = join(repoPath, '.git', 'objects')
const packs = join(objects, 'pack')
const blobs = await createFragmentedPacks(repoPath)
const originalPacks = (await readdir(packs)).sort()
const lock = join(packs, 'multi-pack-index.lock')
await writeFile(lock, 'another writer')
const blocked = createMaintenance()
blocked.arm(repoPath)
await settle(blocked.maintenance)
blocked.maintenance.dispose()
await expect(readFile(lock, 'utf8')).resolves.toBe('another writer')
await expect(readFile(join(packs, 'multi-pack-index'))).rejects.toMatchObject({
code: 'ENOENT'
})
await rm(lock)
const { maintenance, arm } = createMaintenance()
arm(repoPath)
await settle(maintenance)
maintenance.dispose()
const index = await readFile(join(packs, 'multi-pack-index'))
expect(index.subarray(0, 4).toString()).toBe('MIDX')
expect((await readdir(packs)).filter((name) => name !== 'multi-pack-index').sort()).toEqual(
originalPacks
)
expect(git(repoPath, ['multi-pack-index', 'verify'])).toBe('')
expect(git(repoPath, ['-c', 'core.multiPackIndex=true', 'cat-file', '-p', blobs[0]])).toBe(
'packed-0'
)
expect(
git(repoPath, [
'-c',
'core.multiPackIndex=true',
'cat-file',
'-p',
blobs[PACK_INDEX_THRESHOLD - 1]
])
).toBe(`packed-${PACK_INDEX_THRESHOLD - 1}`)
await expect(readFile(join(repoPath, '.git', 'packed-refs'))).rejects.toMatchObject({
code: 'ENOENT'
})
})
it('refreshes new packs during ref cooldown and keeps readers working between writes', async () => {
const { repoPath } = await createRepo(0)
const blobs = await createFragmentedPacks(repoPath)
const packs = join(repoPath, '.git', 'objects', 'pack')
let clock = 0
const { maintenance, arm } = createMaintenance(
() => {},
() => clock
)
arm(repoPath)
await settle(maintenance)
expect((await readFile(join(packs, 'multi-pack-index'))).readUInt32BE(8)).toBe(
PACK_INDEX_THRESHOLD
)
const added = git(repoPath, ['hash-object', '-w', '--stdin'], 'new fetched object\n')
git(repoPath, ['pack-objects', join(packs, 'pack')], `${added}\n`)
await rm(join(repoPath, '.git', 'objects', added.slice(0, 2), added.slice(2)))
expect(git(repoPath, ['cat-file', '-p', added])).toBe('new fetched object')
expect(git(repoPath, ['cat-file', '-p', blobs[0]])).toBe('packed-0')
clock = PACK_INDEX_MAINTENANCE_COOLDOWN_MS + 1
arm(repoPath)
await settle(maintenance)
maintenance.dispose()
expect((await readFile(join(packs, 'multi-pack-index'))).readUInt32BE(8)).toBe(
PACK_INDEX_THRESHOLD + 1
)
expect(git(repoPath, ['multi-pack-index', 'verify'])).toBe('')
await expect(readFile(join(repoPath, '.git', 'packed-refs'))).rejects.toMatchObject({
code: 'ENOENT'
})
})
it.skipIf(!hasWriteOption('bitmap'))(
'preserves a real MIDX bitmap and its index byte for byte',
async () => {
const { repoPath } = await createRepo(0)
await createFragmentedPacks(repoPath)
const packs = join(repoPath, '.git', 'objects', 'pack')
git(repoPath, ['pack-objects', '--revs', join(packs, 'pack')], 'HEAD\n')
git(repoPath, ['multi-pack-index', 'write', '--bitmap'])
const metadata = (await readdir(packs)).filter((name) => name.startsWith('multi-pack-index'))
expect(metadata.some((name) => name.endsWith('.bitmap'))).toBe(true)
const before = await Promise.all(metadata.map((name) => readFile(join(packs, name))))
await expect(maintainIndex(repoPath)).resolves.toBe('protected')
const after = await Promise.all(metadata.map((name) => readFile(join(packs, name))))
expect(after).toEqual(before)
}
)
it.skipIf(!hasWriteOption('incremental'))(
'preserves a real incremental MIDX chain and its layers',
async () => {
const { repoPath } = await createRepo(0)
await createFragmentedPacks(repoPath)
const chain = join(repoPath, '.git', 'objects', 'pack', 'multi-pack-index.d')
git(repoPath, ['multi-pack-index', 'write', '--incremental'])
const metadata = (await readdir(chain)).sort()
expect(metadata).toContain('multi-pack-index-chain')
const before = await Promise.all(metadata.map((name) => readFile(join(chain, name))))
await expect(maintainIndex(repoPath)).resolves.toBe('protected')
expect((await readdir(chain)).sort()).toEqual(metadata)
expect(await Promise.all(metadata.map((name) => readFile(join(chain, name))))).toEqual(before)
}
)
it('packs a backlogged repository down to zero loose refs', async () => {
const { repoPath, refsDir } = await createRepo(THRESHOLD + 30)
const { maintenance, arm } = createMaintenance()
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WorktreePreparationLock from './worktree-preparation-lock'
type GitExec = (
args: string[],
@@ -8,6 +9,12 @@ type GitExec = (
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn<GitExec>())
vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
vi.mock('./worktree-preparation-lock', async (importOriginal) => ({
...(await importOriginal<typeof WorktreePreparationLock>()),
verifyWorktreePreparationLock: vi.fn(async () => '/owned-lock'),
verifyWorktreePreparationLockAtPath: vi.fn(),
unlockWorktreePreparationAtPath: vi.fn()
}))
import { addWorktree } from './worktree-add'
import { listWorktreesSharedStrict } from './worktree-scan-cache'
@@ -51,11 +58,18 @@ describe('worktree create admission tier', () => {
})
it('runs the prepared-checkout finalize at the tier the caller asked for', async () => {
await finalizePreparedWorktree('/repo', '/prepared', '/repo-wt', 'feature', 'main', false, {
admissionTier: 'interactive'
})
await finalizePreparedWorktree(
'/repo',
'/prepared',
'/repo-wt',
'feature',
'main',
false,
{ admissionTier: 'interactive' },
'owner'
)
for (const match of ['worktree move', 'checkout --no-track', 'worktree unlock']) {
for (const match of ['worktree move', 'checkout --no-track']) {
const options = optionsForCommand(match)
expect(options, match).toHaveLength(1)
expect(options[0], match).toMatchObject({ admissionTier: 'interactive' })
@@ -81,7 +81,10 @@ it('creates cold and prepared worktrees with real Git while status capacity is o
prepared.preparedPath,
join(root, 'warm'),
'warm',
'main'
'main',
false,
{},
prepared.lockReason
)
expect(await listWorktrees(repo)).toHaveLength(3)
})
@@ -55,6 +55,241 @@ afterEach(async () => {
})
describe('prepared worktree creation with real Git', () => {
it('registers during fetch and materializes its settled tip only once before the final hook', async () => {
const { repoPath, root } = await createRepo()
const preparedPath = join(root, 'prepared-barrier')
const finalPath = join(root, 'final-barrier')
const base = 'refs/remotes/origin/main'
const reason = createWorktreePreparationLockReason('barrier-tip')
const originalHead = git(repoPath, ['rev-parse', 'HEAD'])
git(repoPath, ['update-ref', base, originalHead])
const hooksPath = join(root, 'hooks')
await mkdir(hooksPath)
await writeFile(
join(hooksPath, 'post-checkout'),
'#!/bin/sh\nprintf \'%s\\n\' "$@" >> checkout-hook.txt\n',
{ mode: 0o755 }
)
git(repoPath, ['config', 'core.hooksPath', hooksPath])
let release!: () => void
const barrier = new Promise<void>((resolve) => {
release = resolve
})
const spy = vi.spyOn(gitRunner, 'gitExecFileAsync')
const preparing = prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
base,
reason,
{},
barrier
)
await vi.waitFor(() => expect(existsSync(join(preparedPath, '.git'))).toBe(true))
await expect(readFile(join(preparedPath, 'version.txt'))).rejects.toMatchObject({
code: 'ENOENT'
})
expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false)
await writeFile(join(repoPath, 'version.txt'), 'fetched\n')
git(repoPath, ['commit', '--quiet', '-am', 'fetched tip'])
const fetchedHead = git(repoPath, ['rev-parse', 'HEAD'])
git(repoPath, ['update-ref', base, fetchedHead])
release()
try {
await preparing
expect(git(preparedPath, ['rev-parse', 'HEAD'])).toBe(fetchedHead)
expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('fetched\n')
expect(existsSync(join(preparedPath, 'checkout-hook.txt'))).toBe(false)
await finalizePreparedWorktree(
repoPath,
preparedPath,
finalPath,
'feature/barrier',
base,
false,
{},
reason
)
const resets = spy.mock.calls.filter(([args]) => args.includes('reset'))
expect(resets).toHaveLength(1)
expect(resets[0]?.[0].at(-1)).toBe(fetchedHead)
expect(await readFile(join(finalPath, 'checkout-hook.txt'), 'utf8')).toBe(
`${fetchedHead}\n${fetchedHead}\n1\n`
)
expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/barrier')
await rm(join(finalPath, 'checkout-hook.txt'))
expect(git(finalPath, ['status', '--porcelain'])).toBe('')
} finally {
release()
spy.mockRestore()
}
})
it('cancels a never-settling fetch barrier and cleans the registered checkout before it resolves', async () => {
const { repoPath, root } = await createRepo()
const preparedPath = join(root, 'canceled-barrier')
const reason = createWorktreePreparationLockReason('barrier-cancel')
const controller = new AbortController()
let release!: () => void
const barrier = new Promise<void>((resolve) => {
release = resolve
})
const spy = vi.spyOn(gitRunner, 'gitExecFileAsync')
const preparing = prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
reason,
{ signal: controller.signal },
barrier
)
const assertion = expect(preparing).rejects.toThrow('expired while fetching')
try {
await vi.waitFor(() => expect(existsSync(join(preparedPath, '.git'))).toBe(true))
const lock = git(preparedPath, ['rev-parse', '--git-path', 'locked'])
await vi.waitFor(async () => expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`))
controller.abort(new Error('expired while fetching'))
await assertion
expect(existsSync(preparedPath)).toBe(false)
expect(git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain(preparedPath)
release()
await Promise.resolve()
expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false)
} finally {
release()
spy.mockRestore()
}
})
it('preserves a replacement owner after the fetch barrier before probing or materializing', async () => {
const { repoPath, root } = await createRepo()
const preparedPath = join(root, 'replaced-barrier')
const reason = createWorktreePreparationLockReason('barrier-replacement')
let release!: () => void
const barrier = new Promise<void>((resolve) => {
release = resolve
})
const preparing = prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
reason,
{},
barrier
)
const assertion = expect(preparing).rejects.toThrow('lock owner changed')
await vi.waitFor(() => expect(existsSync(join(preparedPath, '.git'))).toBe(true))
const lock = git(preparedPath, ['rev-parse', '--git-path', 'locked'])
await vi.waitFor(async () => expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`))
await writeFile(lock, 'manual barrier owner\n')
await writeFile(join(preparedPath, 'version.txt'), 'manual content\n')
const spy = vi.spyOn(gitRunner, 'gitExecFileAsync')
try {
release()
await assertion
expect(spy).not.toHaveBeenCalled()
expect(await readFile(lock, 'utf8')).toBe('manual barrier owner\n')
expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('manual content\n')
} finally {
release()
spy.mockRestore()
}
})
it('reports an unrelated barrier failure and removes only its owned registration', async () => {
const { repoPath, root } = await createRepo()
const preparedPath = join(root, 'failed-barrier')
const failure = new Error('unexpected barrier failure')
const spy = vi.spyOn(gitRunner, 'gitExecFileAsync')
try {
await expect(
prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
createWorktreePreparationLockReason('barrier-failure'),
{},
Promise.reject(failure)
)
).rejects.toBe(failure)
expect(existsSync(preparedPath)).toBe(false)
expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false)
expect(git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain(preparedPath)
} finally {
spy.mockRestore()
}
})
it('pins first materialization to the resolved fetched OID when the ref moves before reset', async () => {
const { repoPath, root } = await createRepo()
const preparedPath = join(root, 'moving-barrier-tip')
const originalHead = git(repoPath, ['rev-parse', 'HEAD'])
await writeFile(join(repoPath, 'version.txt'), 'newer\n')
git(repoPath, ['commit', '--quiet', '-am', 'later tip'])
const newerHead = git(repoPath, ['rev-parse', 'HEAD'])
git(repoPath, ['update-ref', 'refs/remotes/origin/main', originalHead])
const run = gitRunner.gitExecFileAsync
const spy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation((args, options) => {
if (args.includes('reset')) {
git(repoPath, ['update-ref', 'refs/remotes/origin/main', newerHead])
}
return run(args, options)
})
try {
await prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'refs/remotes/origin/main',
createWorktreePreparationLockReason('barrier-oid-race'),
{},
Promise.resolve()
)
expect(git(preparedPath, ['rev-parse', 'HEAD'])).toBe(originalHead)
expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('one\n')
expect(spy.mock.calls.find(([args]) => args.includes('reset'))?.[0].at(-1)).toBe(originalHead)
} finally {
spy.mockRestore()
}
})
it('rechecks the barrier marker after reading the fresh commit before the first reset', async () => {
const { repoPath, root } = await createRepo()
const preparedPath = join(root, 'replaced-after-tip-read')
const reason = createWorktreePreparationLockReason('barrier-tip-owner')
const run = gitRunner.gitExecFileAsync
let lock = ''
const spy = vi
.spyOn(gitRunner, 'gitExecFileAsync')
.mockImplementation(async (args, options) => {
const result = await run(args, options)
if (args[0] === 'rev-parse' && args.includes('refs/heads/main^{commit}')) {
lock = git(preparedPath, ['rev-parse', '--git-path', 'locked'])
await writeFile(lock, 'manual after tip read\n')
}
return result
})
try {
await expect(
prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'refs/heads/main',
reason,
{},
Promise.resolve()
)
).rejects.toThrow('lock owner changed')
expect(
spy.mock.calls.some(([args]) => args.includes('reset') || args.includes('remove'))
).toBe(false)
expect(await readFile(lock, 'utf8')).toBe('manual after tip read\n')
await expect(readFile(join(preparedPath, 'version.txt'))).rejects.toMatchObject({
code: 'ENOENT'
})
} finally {
spy.mockRestore()
}
})
it.each([false, true])(
'attaches the prepared HEAD and runs the hook (base advanced: %s)',
async (advanceBase) => {
@@ -70,19 +305,24 @@ describe('prepared worktree creation with real Git', () => {
)
git(repoPath, ['config', 'core.hooksPath', hooksPath])
git(repoPath, ['config', 'branch.autoSetupMerge', 'always'])
await prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
createWorktreePreparationLockReason('attach-with-hook')
)
const lockReason = createWorktreePreparationLockReason('attach-with-hook')
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
expect(existsSync(join(preparedPath, 'checkout-hook.txt'))).toBe(false)
if (advanceBase) {
await writeFile(join(repoPath, 'version.txt'), 'advanced\n')
git(repoPath, ['commit', '--quiet', '-am', 'advance base'])
}
const targetHead = git(repoPath, ['rev-parse', 'HEAD'])
await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/attached', 'main')
await finalizePreparedWorktree(
repoPath,
preparedPath,
finalPath,
'feature/attached',
'main',
false,
{},
lockReason
)
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(targetHead)
expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/attached')
@@ -104,12 +344,8 @@ describe('prepared worktree creation with real Git', () => {
const { repoPath, root } = await createRepo()
const preparedPath = join(root, 'prepared-race')
const finalPath = join(root, 'final-race')
await prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
createWorktreePreparationLockReason('head-race')
)
const lockReason = createWorktreePreparationLockReason('head-race')
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
const expectedHead = git(repoPath, ['rev-parse', 'HEAD'])
git(repoPath, ['checkout', '--quiet', '-b', 'other'])
await writeFile(join(repoPath, 'version.txt'), 'other\n')
@@ -125,7 +361,16 @@ describe('prepared worktree creation with real Git', () => {
return original(args, options)
})
try {
await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/race', 'main')
await finalizePreparedWorktree(
repoPath,
preparedPath,
finalPath,
'feature/race',
'main',
false,
{},
lockReason
)
} finally {
spy.mockRestore()
}
@@ -147,15 +392,20 @@ describe('prepared worktree creation with real Git', () => {
{ mode: 0o755 }
)
git(repoPath, ['config', 'core.hooksPath', hooksPath])
await prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
createWorktreePreparationLockReason('hook-commit')
)
const lockReason = createWorktreePreparationLockReason('hook-commit')
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
const baseHead = git(repoPath, ['rev-parse', 'HEAD'])
await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-commit', 'main')
await finalizePreparedWorktree(
repoPath,
preparedPath,
finalPath,
'feature/hook-commit',
'main',
false,
{},
lockReason
)
expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/hook-commit')
expect(git(finalPath, ['rev-parse', 'HEAD^'])).toBe(baseHead)
@@ -172,15 +422,20 @@ describe('prepared worktree creation with real Git', () => {
await mkdir(hooksPath)
await writeFile(join(hooksPath, 'post-checkout'), '#!/bin/sh\nexit 1\n', { mode: 0o755 })
git(repoPath, ['config', 'core.hooksPath', hooksPath])
await prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
createWorktreePreparationLockReason('hook-failure')
)
const lockReason = createWorktreePreparationLockReason('hook-failure')
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
await expect(
finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/hook-failure', 'main')
finalizePreparedWorktree(
repoPath,
preparedPath,
finalPath,
'feature/hook-failure',
'main',
false,
{},
lockReason
)
).rejects.toThrow()
expect(existsSync(finalPath)).toBe(false)
expect(git(repoPath, ['branch', '--list', 'feature/hook-failure'])).toBe('')
@@ -202,7 +457,7 @@ describe('prepared worktree creation with real Git', () => {
return original(args, options)
})
try {
await expect(discardPreparedWorktree(repoPath, preparedPath)).rejects.toThrow(
await expect(discardPreparedWorktree(repoPath, preparedPath, {}, lockReason)).rejects.toThrow(
'injected removal launch failure'
)
const remaining = await listWorktrees(repoPath, { includeCreatePreparations: true })
@@ -214,7 +469,7 @@ describe('prepared worktree creation with real Git', () => {
expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('one\n')
} finally {
spy.mockRestore()
await discardPreparedWorktree(repoPath, preparedPath)
await discardPreparedWorktree(repoPath, preparedPath, {}, lockReason)
}
expect(existsSync(preparedPath)).toBe(false)
})
@@ -265,7 +520,16 @@ describe('prepared worktree creation with real Git', () => {
expect(entry).toBeDefined()
takePreparation(entry)
const finalPath = join(root, 'fresh-worktree')
await finalizePreparedWorktree(repoPath, entry.preparedPath, finalPath, 'fresh', 'main')
await finalizePreparedWorktree(
repoPath,
entry.preparedPath,
finalPath,
'fresh',
'main',
false,
{},
entry.lockReason
)
expect(git(finalPath, ['status', '--porcelain'])).toBe('')
expect(git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('fresh')
expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('one\n')
@@ -346,17 +610,13 @@ describe('prepared worktree creation with real Git', () => {
const preparedPath = join(preparationRoot, `${process.pid}-canceled`)
await mkdir(preparationRoot, { recursive: true })
await prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
createWorktreePreparationLockReason('canceled-test')
)
const lockReason = createWorktreePreparationLockReason('canceled-test')
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
const controller = new AbortController()
controller.abort()
await expect(
discardPreparedWorktree(repoPath, preparedPath, { signal: controller.signal })
discardPreparedWorktree(repoPath, preparedPath, { signal: controller.signal }, lockReason)
).resolves.toBeUndefined()
expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1)
@@ -393,7 +653,16 @@ describe('prepared worktree creation with real Git', () => {
)
expect(git(preparedPath, ['rev-parse', 'HEAD'])).not.toBe(localMainHead)
await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/retargeted', 'main')
await finalizePreparedWorktree(
repoPath,
preparedPath,
finalPath,
'feature/retargeted',
'main',
false,
{},
createWorktreePreparationLockReason('retarget-test')
)
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead)
// A retarget that left stale files behind would be a wrong checkout, not just a slow one.
@@ -452,7 +721,10 @@ describe('prepared worktree creation with real Git', () => {
preparedPath,
finalPath,
'feature/overlap',
'refs/remotes/origin/main'
'refs/remotes/origin/main',
false,
{},
createWorktreePreparationLockReason('fetch-overlap')
)
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(refreshed)
expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('refreshed\n')
@@ -497,7 +769,9 @@ describe('prepared worktree creation with real Git', () => {
finalPath,
'feature/prepared',
'main',
false
false,
{},
createWorktreePreparationLockReason('real-git-test')
)
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(latestHead)
@@ -4,10 +4,10 @@ import { expect, it } from 'vitest'
import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation'
import { gitExecFileAsync } from './runner'
import {
discardPreparedWorktree,
finalizePreparedWorktree,
prepareWorktreeCreateCheckout
} from './worktree-create-preparation'
import { removeWorktree } from './worktree-removal'
// Opt in on Windows with a running distro; all Git commands use the production WSL router.
const wslDistro = process.env.ORCA_TEST_WSL_DISTRO
@@ -34,13 +34,8 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)(
await writeFile(join(repoPath, 'version.txt'), 'one\n')
await git(repoPath, ['add', 'version.txt'])
await git(repoPath, ['commit', '--quiet', '-m', 'initial'])
await prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
createWorktreePreparationLockReason('real-wsl-test'),
options
)
const lockReason = createWorktreePreparationLockReason('real-wsl-test')
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason, options)
expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).toContain(
'locked orca-create-preparation:v1:'
)
@@ -55,7 +50,8 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)(
'feature/routed',
'main',
false,
options
options,
lockReason
)
expect(await git(finalPath, ['rev-parse', 'HEAD'])).toBe(target)
expect(await git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/routed')
@@ -65,7 +61,7 @@ it.skipIf(process.platform !== 'win32' || !wslDistro)(
'refs/heads/main'
)
expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain('locked ')
await discardPreparedWorktree(repoPath, finalPath, options)
await removeWorktree(repoPath, finalPath, true, options)
expect(
(await git(repoPath, ['worktree', 'list', '--porcelain'])).match(/^worktree /gm)
).toHaveLength(1)
+83 -111
View File
@@ -1,7 +1,8 @@
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
import type { AddWorktreeOptions, AddWorktreeResult, GitWorktreeExecOptions } from './worktree'
import { gitExecOptions, type GitExecOptionsForWorktree } from './worktree-operation-options'
import { gitExecOptions } from './worktree-operation-options'
import {
configurePushAutoSetupRemote,
notifyPreparedWorktreeMutation,
@@ -10,93 +11,77 @@ import {
resolveWorktreeAddTimeoutMs,
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
} from './worktree'
import {
gitCleanupOptions,
performDiscardPreparedWorktree,
removeFailedFinalization
} from './worktree-preparation-discard'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
function gitCleanupOptions(
cwd: string,
options: GitWorktreeExecOptions
): GitExecOptionsForWorktree {
// Why: cancellation must not strand a partially moved worktree; cleanup is bounded separately.
return gitExecOptions(cwd, { ...options, signal: undefined })
}
async function performDiscardPreparedWorktree(
repoPath: string,
worktreePath: string,
options: GitWorktreeExecOptions
): Promise<void> {
const cleanupGitOptions = {
...gitCleanupOptions(repoPath, options),
timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
}
try {
// Preserve the ownership lock if removal cannot start; Git 2.25 supports locked removal.
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'remove',
'--force',
'--force',
worktreePath
],
cleanupGitOptions
)
} finally {
invalidateWslLinkedWorktreeGitRouting(worktreePath)
}
}
import {
unlockWorktreePreparation,
unlockWorktreePreparationAtPath,
verifyWorktreePreparationLock,
verifyWorktreePreparationLockAtPath,
WorktreePreparationLockOwnershipError
} from './worktree-preparation-lock'
import { addLockedWorktreePreparation } from './worktree-preparation-add'
export async function prepareWorktreeCreateCheckout(
repoPath: string,
worktreePath: string,
baseBranch: string,
lockReason: string,
options: GitWorktreeExecOptions = {}
options: GitWorktreeExecOptions = {},
beforeMaterialization?: Promise<void>
): Promise<void> {
// Observe early rejection while registration runs; awaiting still reports the original error.
void beforeMaterialization?.catch(() => {})
try {
await withRepoRefMaintenancePaused('worktree-prepare', () =>
runWithGitReadCacheInvalidation(async () => {
const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
)
let lockPath: string | undefined
try {
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'add',
'--detach',
'--no-checkout',
worktreePath,
effectiveBase
],
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
lockPath = await addLockedWorktreePreparation(
repoPath,
worktreePath,
effectiveBase,
lockReason,
{
...options,
timeout: resolveWorktreeAddTimeoutMs()
}
)
// The add just wrote the marker; drop any pre-create route before the reset routes Git.
invalidateWslLinkedWorktreeGitRouting(worktreePath)
await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal)
let materializationBase = effectiveBase
if (beforeMaterialization) {
await waitForPromiseWithSignal(beforeMaterialization, options.signal)
await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal)
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', `${effectiveBase}^{commit}`],
gitExecOptions(repoPath, options)
)
materializationBase = stdout.trim()
await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal)
}
// Why: reset materializes files without running user post-checkout hooks before submit.
await gitExecFileAsync(
[...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase],
[...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', materializationBase],
{ ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() }
)
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'lock',
'--reason',
lockReason,
worktreePath
],
{ ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() }
)
await verifyWorktreePreparationLockAtPath(lockPath, lockReason, options.signal)
} catch (error) {
await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {})
if (lockPath !== undefined && !(error instanceof WorktreePreparationLockOwnershipError)) {
await performDiscardPreparedWorktree(repoPath, worktreePath, options, lockReason).catch(
() => {}
)
}
throw error
}
})
@@ -109,11 +94,12 @@ export async function prepareWorktreeCreateCheckout(
export async function discardPreparedWorktree(
repoPath: string,
worktreePath: string,
options: GitWorktreeExecOptions = {}
options: GitWorktreeExecOptions = {},
expectedLockReason: string
): Promise<void> {
try {
await runWithGitReadCacheInvalidation(() =>
performDiscardPreparedWorktree(repoPath, worktreePath, options)
performDiscardPreparedWorktree(repoPath, worktreePath, options, expectedLockReason)
)
} finally {
notifyPreparedWorktreeMutation(repoPath)
@@ -123,52 +109,22 @@ export async function discardPreparedWorktree(
export async function unlockPreparedWorktree(
repoPath: string,
worktreePath: string,
options: GitWorktreeExecOptions = {}
options: GitWorktreeExecOptions = {},
expectedLockReason: string
): Promise<void> {
const cleanupGitOptions = {
...gitCleanupOptions(repoPath, options),
timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
}
try {
await runWithGitReadCacheInvalidation(() =>
gitExecFileAsync(
[...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath],
cleanupGitOptions
)
)
await runWithGitReadCacheInvalidation(async () => {
await unlockWorktreePreparation(worktreePath, expectedLockReason, cleanupGitOptions)
})
} finally {
notifyPreparedWorktreeMutation(repoPath)
}
}
async function removeFailedFinalization(
repoPath: string,
cleanupPath: string,
branch: string,
moved: boolean,
options: GitWorktreeExecOptions
): Promise<void> {
let branchAttached = false
if (moved) {
try {
const { stdout } = await gitExecFileAsync(
['symbolic-ref', '--short', 'HEAD'],
gitCleanupOptions(cleanupPath, options)
)
branchAttached = stdout.trim() === branch
} catch {
// Detached or no longer readable.
}
}
await performDiscardPreparedWorktree(repoPath, cleanupPath, options).catch(() => {})
if (branchAttached) {
await gitExecFileAsync(
['branch', '-D', '--', branch],
gitCleanupOptions(repoPath, options)
).catch(() => {})
}
}
export async function finalizePreparedWorktree(
repoPath: string,
preparedPath: string,
@@ -176,7 +132,8 @@ export async function finalizePreparedWorktree(
branch: string,
baseBranch: string,
refreshLocalBaseRef = false,
options: AddWorktreeOptions = {}
options: AddWorktreeOptions = {},
expectedLockReason: string
): Promise<AddWorktreeResult> {
const finalizeGitOptions: AddWorktreeOptions = {
...options,
@@ -184,6 +141,13 @@ export async function finalizePreparedWorktree(
}
try {
return await runWithGitReadCacheInvalidation(async () => {
const lockPath = await verifyWorktreePreparationLock(
preparedPath,
expectedLockReason,
finalizeGitOptions
)
const verifyOwnership = (): Promise<void> =>
verifyWorktreePreparationLockAtPath(lockPath, expectedLockReason, finalizeGitOptions.signal)
const [targetResult, preparedResult] = await Promise.allSettled([
(async () => {
const baseContext = await resolveWorktreeAddBaseContext(
@@ -219,6 +183,7 @@ export async function finalizePreparedWorktree(
}
const preparedHeadOutput = preparedResult.value.stdout
if (preparedHeadOutput.trim() !== targetHead) {
await verifyOwnership()
await gitExecFileAsync(
[...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead],
gitExecOptions(preparedPath, finalizeGitOptions)
@@ -228,6 +193,7 @@ export async function finalizePreparedWorktree(
let moved = false
try {
try {
await verifyOwnership()
// Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25).
await gitExecFileAsync(
[
@@ -247,6 +213,7 @@ export async function finalizePreparedWorktree(
invalidateWslLinkedWorktreeGitRouting(preparedPath)
invalidateWslLinkedWorktreeGitRouting(worktreePath)
}
await verifyOwnership()
await gitExecFileAsync(
[
...windowsLongPathGitArgs(worktreePath),
@@ -258,6 +225,7 @@ export async function finalizePreparedWorktree(
],
gitExecOptions(worktreePath, finalizeGitOptions)
)
await verifyOwnership()
await persistWorktreeCreationBase(
worktreePath,
branch,
@@ -265,18 +233,22 @@ export async function finalizePreparedWorktree(
finalizeGitOptions
)
await configurePushAutoSetupRemote(worktreePath, finalizeGitOptions)
await gitExecFileAsync(
[...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath],
gitExecOptions(repoPath, finalizeGitOptions)
await unlockWorktreePreparationAtPath(
lockPath,
expectedLockReason,
finalizeGitOptions.signal
)
} catch (error) {
await removeFailedFinalization(
repoPath,
moved ? worktreePath : preparedPath,
branch,
moved,
finalizeGitOptions
)
if (!(error instanceof WorktreePreparationLockOwnershipError)) {
await removeFailedFinalization(
repoPath,
moved ? worktreePath : preparedPath,
branch,
moved,
finalizeGitOptions,
expectedLockReason
)
}
await baseContext.pendingLocalBaseRefRefresh
throw error
}
@@ -1,6 +1,7 @@
// Worktree add/move/remove/rollback rewrite the `.git` marker the WSL Git route was derived from.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WorktreeModule from './worktree'
import type * as WorktreePreparationLock from './worktree-preparation-lock'
const {
gitExecFileAsyncMock,
@@ -38,6 +39,14 @@ vi.mock('./worktree-scan-cache', () => ({
listWorktrees: listWorktreesMock
}))
vi.mock('./worktree-preparation-lock', async (importOriginal) => ({
...(await importOriginal<typeof WorktreePreparationLock>()),
lockWorktreePreparation: vi.fn(async () => '/owned-lock'),
verifyWorktreePreparationLock: vi.fn(async () => '/owned-lock'),
verifyWorktreePreparationLockAtPath: vi.fn(),
unlockWorktreePreparationAtPath: vi.fn()
}))
import { addWorktree } from './worktree-add'
import {
discardPreparedWorktree,
@@ -120,7 +129,7 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () =
it('drops the route after a prepared worktree is discarded', async () => {
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
await discardPreparedWorktree(REPO, LINKED)
await discardPreparedWorktree(REPO, LINKED, {}, 'owner')
expect(hasCachedHostRoute(LINKED)).toBe(false)
})
@@ -133,11 +142,41 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () =
expect(hasCachedHostRoute(PREPARED)).toBe(false)
})
it('reads and materializes the barrier tip on the preparation Git host', async () => {
const head = 'b'.repeat(40)
gitExecFileAsyncMock.mockResolvedValue({ stdout: `${head}\n`, stderr: '' })
await prepareWorktreeCreateCheckout(
REPO,
PREPARED,
'refs/remotes/origin/main',
'owner',
{ wslDistro: 'Ubuntu' },
Promise.resolve()
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-parse', '--verify', 'refs/remotes/origin/main^{commit}'],
{ cwd: REPO, wslDistro: 'Ubuntu' }
)
expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith(
['reset', '--hard', head],
expect.objectContaining({ cwd: PREPARED, wslDistro: 'Ubuntu' })
)
})
it('drops both routes after the prepared checkout is moved into place', async () => {
seedWslLinkedWorktreeGitRoutingForTests(PREPARED)
seedWslLinkedWorktreeGitRoutingForTests(LINKED)
await finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main')
await finalizePreparedWorktree(
REPO,
PREPARED,
LINKED,
'feature',
'origin/main',
false,
{},
'owner'
)
expect(hasCachedHostRoute(PREPARED)).toBe(false)
expect(hasCachedHostRoute(LINKED)).toBe(false)
@@ -153,7 +192,7 @@ describe('worktree mutations invalidate the WSL linked-worktree Git route', () =
)
await expect(
finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main')
finalizePreparedWorktree(REPO, PREPARED, LINKED, 'feature', 'origin/main', false, {}, 'owner')
).rejects.toThrow('destination exists')
expect(hasCachedHostRoute(PREPARED)).toBe(false)
@@ -0,0 +1,332 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type * as FilePromises from 'node:fs/promises'
import type * as PreparationLock from './worktree-preparation-lock'
import { isUnsupportedWorktreeAddLockReasonError } from '../../shared/git-worktree-command-capabilities'
const mocks = vi.hoisted(() => ({
git: vi.fn(),
lstat: vi.fn(),
lock: vi.fn(),
verify: vi.fn(),
discard: vi.fn()
}))
vi.mock('./runner', () => ({ gitExecFileAsync: mocks.git }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FilePromises>()),
lstat: mocks.lstat
}))
vi.mock('./worktree-preparation-lock', async (importOriginal) => ({
...(await importOriginal<typeof PreparationLock>()),
lockWorktreePreparation: mocks.lock,
verifyWorktreePreparationLock: mocks.verify
}))
vi.mock('./worktree-preparation-discard', () => ({ performDiscardPreparedWorktree: mocks.discard }))
import { addLockedWorktreePreparation } from './worktree-preparation-add'
import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache } from './git-capability-state'
import { WorktreePreparationLockOwnershipError } from './worktree-preparation-lock'
import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree-operation-options'
import {
resetWslLinkedWorktreeGitRoutingForTests,
seedWslLinkedWorktreeGitRoutingForTests
} from './wsl-linked-worktree-git-routing'
const reason = 'orca-create-preparation:v1:123:atomic'
const unsupported = Object.assign(new Error("error: unknown option 'reason'"), { code: 129 })
const prepare = (path = '/prepared', options = {}) =>
addLockedWorktreePreparation('/repo', path, 'refs/heads/main', reason, options)
beforeEach(() => {
clearGitCapabilityStateForTests()
resetWslLinkedWorktreeGitRoutingForTests()
mocks.git.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
mocks.lstat.mockReset().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
mocks.lock.mockReset().mockResolvedValue('/fallback-lock')
mocks.verify.mockReset().mockResolvedValue('/atomic-lock')
mocks.discard.mockReset().mockResolvedValue(undefined)
})
it('asks Git to create the exact marker atomically and only verifies its ownership', async () => {
const controller = new AbortController()
const options = { signal: controller.signal, timeout: 6000, admissionTier: 'background' as const }
await expect(prepare('/prepared', options)).resolves.toBe('/atomic-lock')
expect(mocks.git).toHaveBeenCalledExactlyOnceWith(
[
'worktree',
'add',
'--detach',
'--no-checkout',
'--lock',
'--reason',
reason,
'/prepared',
'refs/heads/main'
],
{ cwd: '/repo', ...options }
)
expect(mocks.verify).toHaveBeenCalledExactlyOnceWith('/prepared', reason, options)
expect(mocks.lock).not.toHaveBeenCalled()
})
it('falls back once on the old-Git reason rejection and caches the absence', async () => {
mocks.git.mockRejectedValueOnce(unsupported)
await expect(prepare()).resolves.toBe('/fallback-lock')
await expect(prepare('/second')).resolves.toBe('/fallback-lock')
expect(mocks.git.mock.calls.map(([args]) => args.includes('--reason'))).toEqual([
true,
false,
false
])
expect(mocks.lock.mock.calls.map(([path]) => path)).toEqual(['/prepared', '/second'])
expect(mocks.verify).not.toHaveBeenCalled()
expect(mocks.discard).not.toHaveBeenCalled()
})
it.each([
['first', 'cancellation'],
['cached', 'cancellation'],
['first', 'path probe'],
['cached', 'path probe'],
['first', 'marker write'],
['cached', 'marker write']
])('cleans its successful %s fallback add after a %s failure', async (fallback, failureKind) => {
const controller = new AbortController()
const options = {
signal: controller.signal,
timeout: 180_000,
wslDistro: 'Ubuntu',
admissionTier: 'background' as const
}
if (fallback === 'cached') {
getLocalGitCapabilityCache(options).rememberUnsupported('worktree-add-lock-reason')
} else {
mocks.git.mockRejectedValueOnce(unsupported)
}
const failure = new Error(`${failureKind} failed after registration`)
mocks.lock.mockImplementationOnce(async () => {
if (failureKind === 'cancellation') {
controller.abort(failure)
}
throw failure
})
await expect(prepare('/prepared', options)).rejects.toBe(failure)
expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', {
...options,
signal: undefined,
timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
})
expect(mocks.lstat).toHaveBeenCalled()
expect(options.timeout).toBe(180_000)
expect(options.signal).toBe(controller.signal)
})
it('preserves the original lock failure when bounded fallback cleanup also fails', async () => {
getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason')
const failure = new Error('lock path unavailable')
mocks.lock.mockRejectedValueOnce(failure)
mocks.discard.mockRejectedValueOnce(new Error('cleanup unavailable'))
await expect(prepare()).rejects.toBe(failure)
expect(mocks.discard).toHaveBeenCalledOnce()
})
it.each(['first', 'cached'])(
'preserves a pre-existing target after %s fallback locking fails',
async (fallback) => {
if (fallback === 'cached') {
getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason')
} else {
mocks.git.mockRejectedValueOnce(unsupported)
}
mocks.lstat.mockResolvedValue({})
const failure = new Error('marker write denied')
mocks.lock.mockRejectedValueOnce(failure)
await expect(prepare()).rejects.toBe(failure)
expect(mocks.discard).not.toHaveBeenCalled()
}
)
it.each(['first', 'cached'])(
'preserves a competing marker during %s fallback locking',
async (fallback) => {
if (fallback === 'cached') {
getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason')
} else {
mocks.git.mockRejectedValueOnce(unsupported)
}
mocks.lock.mockRejectedValueOnce(new WorktreePreparationLockOwnershipError())
await expect(prepare()).rejects.toThrow('lock owner changed')
expect(mocks.discard).not.toHaveBeenCalled()
}
)
it.each(['first', 'cached'])(
'preserves an incomplete or rejected %s fallback add',
async (fallback) => {
if (fallback === 'cached') {
getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason')
} else {
mocks.git.mockRejectedValueOnce(unsupported)
}
const failure = new Error('add did not complete')
mocks.git.mockRejectedValueOnce(failure)
await expect(prepare()).rejects.toBe(failure)
expect(mocks.lock).not.toHaveBeenCalled()
expect(mocks.discard).not.toHaveBeenCalled()
}
)
it('fails closed before a cached fallback add if the target cannot be inspected', async () => {
getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason')
const failure = Object.assign(new Error('target unavailable'), { code: 'EACCES' })
mocks.lstat.mockRejectedValueOnce(failure)
await expect(prepare()).rejects.toBe(failure)
expect(mocks.git).not.toHaveBeenCalled()
expect(mocks.lock).not.toHaveBeenCalled()
expect(mocks.discard).not.toHaveBeenCalled()
})
it('coalesces concurrent unsupported probes while creating each checkout separately', async () => {
let reject!: (error: unknown) => void
mocks.git.mockImplementationOnce(
() =>
new Promise((_resolve, rejectProbe) => {
reject = rejectProbe
})
)
const first = prepare('/first')
await vi.waitFor(() => expect(mocks.git).toHaveBeenCalledTimes(1))
const second = prepare('/second')
await Promise.resolve()
expect(mocks.git).toHaveBeenCalledTimes(1)
reject(unsupported)
await expect(Promise.all([first, second])).resolves.toEqual(['/fallback-lock', '/fallback-lock'])
expect(mocks.git.mock.calls.filter(([args]) => args.includes('--reason'))).toHaveLength(1)
expect(new Set(mocks.lock.mock.calls.map(([path]) => path))).toEqual(
new Set(['/first', '/second'])
)
})
it('runs each concurrent supported add rather than sharing the first checkout result', async () => {
let resolve!: (value: { stdout: string }) => void
mocks.git.mockImplementationOnce(
() =>
new Promise((resolveProbe) => {
resolve = resolveProbe
})
)
mocks.verify.mockImplementation(async (path: string) => `${path}/owned-lock`)
const first = prepare('/first')
await vi.waitFor(() => expect(mocks.git).toHaveBeenCalledTimes(1))
const second = prepare('/second')
resolve({ stdout: '' })
await expect(Promise.all([first, second])).resolves.toEqual([
'/first/owned-lock',
'/second/owned-lock'
])
expect(mocks.git.mock.calls.every(([args]) => args.includes('--reason'))).toBe(true)
expect(mocks.git).toHaveBeenCalledTimes(2)
})
it('isolates native rejection from individual WSL distros', async () => {
mocks.git.mockRejectedValueOnce(unsupported)
await prepare()
await prepare('/ubuntu', { wslDistro: 'Ubuntu' })
await prepare('/debian', { wslDistro: 'Debian' })
await prepare('/native-again')
expect(mocks.git.mock.calls.map(([args]) => args.includes('--reason'))).toEqual([
true,
false,
true,
true,
false
])
})
it('uses native cached rejection when WSL routing executes the host Git binary', async () => {
getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason')
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
const repoPath = String.raw`C:\repo\linked`
seedWslLinkedWorktreeGitRoutingForTests(repoPath)
await addLockedWorktreePreparation(repoPath, String.raw`C:\prepared`, 'main', reason, {
wslDistro: 'Ubuntu'
})
expect(mocks.git).toHaveBeenCalledTimes(1)
expect(mocks.git.mock.calls[0][0]).not.toContain('--reason')
expect(
getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' }).shouldTry('worktree-add-lock-reason')
).toBe(true)
} finally {
platform.mockRestore()
}
})
it('checks a guest directory through its execution distro rather than the Windows root', async () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
await prepare('/home/prepared', { wslDistro: 'Ubuntu' })
expect(mocks.lstat).toHaveBeenCalledExactlyOnceWith(
String.raw`\\wsl.localhost\Ubuntu\home\prepared`
)
} finally {
platform.mockRestore()
}
})
it('fails closed when the pre-existing target cannot be inspected', async () => {
const failure = Object.assign(new Error('path access denied'), { code: 'EACCES' })
mocks.lstat.mockRejectedValueOnce(failure)
await expect(prepare()).rejects.toBe(failure)
expect(mocks.git).not.toHaveBeenCalled()
expect(mocks.discard).not.toHaveBeenCalled()
})
it('keeps a general add failure visible without poisoning the capability or claiming a marker', async () => {
const failure = new Error('permission denied')
mocks.git.mockRejectedValueOnce(failure)
await expect(prepare()).rejects.toBe(failure)
expect(mocks.lock).not.toHaveBeenCalled()
expect(getLocalGitCapabilityCache().shouldTry('worktree-add-lock-reason')).toBe(true)
expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', {}, reason)
await prepare('/next')
expect(mocks.git.mock.calls.every(([args]) => args.includes('--reason'))).toBe(true)
})
it('never removes a pre-existing checkout after add fails', async () => {
mocks.lstat.mockResolvedValueOnce({})
mocks.git.mockRejectedValueOnce(new Error('path already exists'))
await expect(prepare()).rejects.toThrow('path already exists')
expect(mocks.discard).not.toHaveBeenCalled()
expect(mocks.lock).not.toHaveBeenCalled()
})
it('does not rewrite or discard a generic or competing marker returned after add', async () => {
mocks.verify.mockRejectedValueOnce(new WorktreePreparationLockOwnershipError())
await expect(prepare()).rejects.toThrow('lock owner changed')
expect(mocks.lock).not.toHaveBeenCalled()
expect(mocks.discard).not.toHaveBeenCalled()
})
it('attempts ownership-checked cleanup if cancellation interrupts the newly registered add', async () => {
const controller = new AbortController()
const failure = new Error('add canceled')
mocks.git.mockImplementationOnce(async () => {
controller.abort()
throw failure
})
const options = { signal: controller.signal }
await expect(prepare('/prepared', options)).rejects.toBe(failure)
expect(mocks.discard).toHaveBeenCalledExactlyOnceWith('/repo', '/prepared', options, reason)
expect(mocks.lock).not.toHaveBeenCalled()
})
it.each([
[new Error("unknown option 'reason'"), true],
[{ stderr: "error: unrecognized option '--reason'" }, true],
[{ stdout: 'invalid switch --reason' }, true],
[{ code: 129, stderr: "unknown option 'detach'" }, false],
[{ code: 129 }, false],
[new Error('permission denied while writing lock reason'), false]
])('recognizes only an unsupported lock-reason option: %j', (error, expected) => {
expect(isUnsupportedWorktreeAddLockReasonError(error)).toBe(expected)
})
+119
View File
@@ -0,0 +1,119 @@
import { lstat } from 'node:fs/promises'
import { isUnsupportedWorktreeAddLockReasonError } from '../../shared/git-worktree-command-capabilities'
import { resolveWorktreeHostPath } from '../../shared/git-metadata-path'
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import { toHostFilesystemPath } from '../host-tree-removal'
import { withLocalGitCapabilityCacheForExecution } from './git-capability-state'
import { gitExecFileAsync } from './runner'
import {
getErrorCode,
gitExecOptions,
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS,
type GitWorktreeExecOptions
} from './worktree-operation-options'
import { performDiscardPreparedWorktree } from './worktree-preparation-discard'
import {
lockWorktreePreparation,
verifyWorktreePreparationLock,
WorktreePreparationLockOwnershipError
} from './worktree-preparation-lock'
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
async function isAbsent(worktreePath: string, options: GitWorktreeExecOptions): Promise<boolean> {
const hostPath = resolveWorktreeHostPath(worktreePath, options)
if (!hostPath) {
throw new Error('The prepared worktree path is empty')
}
try {
await lstat(toHostFilesystemPath(hostPath))
return false
} catch (error) {
if (getErrorCode(error) === 'ENOENT') {
return true
}
throw error
}
}
export function addLockedWorktreePreparation(
repoPath: string,
worktreePath: string,
baseRef: string,
lockReason: string,
options: GitWorktreeExecOptions
): Promise<string> {
const add = async (lockArgs: string[]): Promise<void> => {
try {
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'add',
'--detach',
'--no-checkout',
...lockArgs,
worktreePath,
baseRef
],
gitExecOptions(repoPath, options)
)
} finally {
invalidateWslLinkedWorktreeGitRouting(worktreePath)
}
}
return withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal },
async (capabilities) => {
const initiallyAbsent = await isAbsent(worktreePath, options)
return capabilities.runWithFallback(
'worktree-add-lock-reason',
async () => {
let added = false
try {
await add(['--lock', '--reason', lockReason])
added = true
return await verifyWorktreePreparationLock(worktreePath, lockReason, options)
} catch (error) {
// A canceled add can leave its marker; a pre-existing checkout is never ours to remove.
if (
(added || initiallyAbsent) &&
!isUnsupportedWorktreeAddLockReasonError(error) &&
!(error instanceof WorktreePreparationLockOwnershipError)
) {
await performDiscardPreparedWorktree(
repoPath,
worktreePath,
options,
lockReason
).catch(() => {})
}
throw error
}
},
async () => {
let added = false
try {
await add([])
added = true
return await lockWorktreePreparation(worktreePath, lockReason, options)
} catch (error) {
if (
added &&
initiallyAbsent &&
!(error instanceof WorktreePreparationLockOwnershipError)
) {
// Single force reclaims our unlocked add while preserving any competing lock.
await performDiscardPreparedWorktree(repoPath, worktreePath, {
...options,
signal: undefined,
timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
}).catch(() => {})
}
throw error
}
},
isUnsupportedWorktreeAddLockReasonError
)
}
)
}
@@ -1,8 +1,15 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type * as WorktreeBaseRefresh from './worktree-base-refresh'
import type * as WorktreePreparationLock from './worktree-preparation-lock'
const gitExec = vi.hoisted(() => vi.fn())
vi.mock('./runner', () => ({ gitExecFileAsync: gitExec }))
vi.mock('./worktree-preparation-lock', async (importOriginal) => ({
...(await importOriginal<typeof WorktreePreparationLock>()),
verifyWorktreePreparationLock: vi.fn(async () => '/owned-lock'),
verifyWorktreePreparationLockAtPath: vi.fn(),
unlockWorktreePreparationAtPath: vi.fn()
}))
vi.mock('./worktree-base-refresh', async (importOriginal) => ({
...(await importOriginal<typeof WorktreeBaseRefresh>()),
refreshLocalBaseRefForWorktreeCreate: vi.fn(async () => undefined),
@@ -30,10 +37,16 @@ beforeEach(() => {
})
it('reuses the current base-resolution oid and preserves WSL routing', async () => {
await finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main', false, {
wslDistro: 'Ubuntu',
timeout: 8000
})
await finalizePreparedWorktree(
'/repo',
'/prepared',
'/final',
'feature',
'main',
false,
{ wslDistro: 'Ubuntu', timeout: 8000 },
'owner'
)
const revisions = gitExec.mock.calls.filter(([args]) => args[0] === 'rev-parse')
expect(revisions.map(([args]) => args)).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
@@ -58,7 +71,8 @@ it.each([
'feature',
test.base,
test.refresh,
test.options
test.options,
'owner'
)
expect(gitExec).toHaveBeenCalledWith(
['rev-parse', '--verify', 'refs/heads/main^{commit}'],
@@ -84,7 +98,16 @@ it('starts both independent probes before either resolves and settles them befor
})
let settled = false
const error = new Error('prepared HEAD unreadable')
const result = finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main')
const result = finalizePreparedWorktree(
'/repo',
'/prepared',
'/final',
'feature',
'main',
false,
{},
'owner'
)
const checked = expect(result).rejects.toBe(error)
void result.then(
() => (settled = true),
@@ -43,16 +43,28 @@ function nextPreparedPath(label: string): string {
return join(preparationRoot, `${process.pid}-${label}-${sequence}`)
}
const lockReasons = new Map<string, string>()
function checkout(preparedPath: string, signal?: AbortSignal): Promise<void> {
const lockReason = createWorktreePreparationLockReason(`bench-${sequence}`)
lockReasons.set(preparedPath, lockReason)
return prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
createWorktreePreparationLockReason(`bench-${sequence}`),
lockReason,
signal ? { signal } : {}
)
}
function discard(preparedPath: string): Promise<void> {
const lockReason = lockReasons.get(preparedPath)
if (!lockReason) {
throw new Error('The benchmark checkout has no lock reason')
}
return discardPreparedWorktree(repoPath, preparedPath, {}, lockReason)
}
async function runTrial(variant: Variant, obsolete: number): Promise<Sample> {
const controllers = Array.from({ length: obsolete }, () => new AbortController())
const obsoletePaths = controllers.map(() => nextPreparedPath('obsolete'))
@@ -69,11 +81,7 @@ async function runTrial(variant: Variant, obsolete: number): Promise<Sample> {
await checkout(freshPath)
const freshCheckoutMs = performance.now() - started
await Promise.all(obsoleteWork)
await Promise.all(
[...obsoletePaths, freshPath].map((path) =>
discardPreparedWorktree(repoPath, path).catch(() => {})
)
)
await Promise.all([...obsoletePaths, freshPath].map((path) => discard(path).catch(() => {})))
return { variant, obsolete, freshCheckoutMs }
}
@@ -118,7 +126,7 @@ describeBench('obsolete preparation cancellation latency', () => {
// Warm the object store and page cache once so the first variant is not penalised.
const warm = nextPreparedPath('warm')
await checkout(warm)
await discardPreparedWorktree(repoPath, warm)
await discard(warm)
const samples: Sample[] = []
for (const obsolete of OBSOLETE_COUNTS) {
@@ -0,0 +1,89 @@
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import { gitExecFileAsync } from './runner'
import {
gitExecOptions,
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS,
type GitExecOptionsForWorktree,
type GitWorktreeExecOptions
} from './worktree-operation-options'
import { verifyWorktreePreparationLock } from './worktree-preparation-lock'
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
export function gitCleanupOptions(
cwd: string,
options: GitWorktreeExecOptions
): GitExecOptionsForWorktree {
// Why: cancellation must not strand a partially moved worktree; cleanup is bounded separately.
return gitExecOptions(cwd, { ...options, signal: undefined })
}
export async function performDiscardPreparedWorktree(
repoPath: string,
worktreePath: string,
options: GitWorktreeExecOptions,
expectedLockReason?: string
): Promise<void> {
const cleanupGitOptions = {
...gitCleanupOptions(repoPath, options),
timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
}
try {
if (expectedLockReason !== undefined) {
await verifyWorktreePreparationLock(worktreePath, expectedLockReason, cleanupGitOptions)
}
// Double force requires a freshly verified ownership marker.
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'remove',
'--force',
...(expectedLockReason === undefined ? [] : ['--force']),
worktreePath
],
cleanupGitOptions
)
} finally {
invalidateWslLinkedWorktreeGitRouting(worktreePath)
}
}
export async function removeFailedFinalization(
repoPath: string,
cleanupPath: string,
branch: string,
moved: boolean,
options: GitWorktreeExecOptions,
expectedLockReason?: string
): Promise<void> {
let branchAttached = false
if (moved) {
try {
const { stdout } = await gitExecFileAsync(
['symbolic-ref', '--short', 'HEAD'],
gitCleanupOptions(cleanupPath, options)
)
branchAttached = stdout.trim() === branch
} catch {
// Detached or no longer readable.
}
}
const removed = await performDiscardPreparedWorktree(
repoPath,
cleanupPath,
options,
expectedLockReason
).then(
() => true,
() => false
)
if (!removed) {
return
}
if (branchAttached) {
await gitExecFileAsync(
['branch', '-D', '--', branch],
gitCleanupOptions(repoPath, options)
).catch(() => {})
}
}
@@ -0,0 +1,207 @@
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation'
import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache } from './git-capability-state'
import * as runner from './runner'
import { prepareWorktreeCreateCheckout } from './worktree-create-preparation'
import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree-operation-options'
const roots: string[] = []
const unsupported = Object.assign(new Error("error: unknown option 'reason'"), { code: 129 })
beforeEach(() => {
clearGitCapabilityStateForTests()
})
afterEach(async () => {
vi.restoreAllMocks()
clearGitCapabilityStateForTests()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function git(cwd: string, args: string[]): Promise<string> {
return (await runner.gitExecFileAsync(args, { cwd })).stdout.trim()
}
async function fixture() {
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-fallback-cleanup-')))
roots.push(root)
const repo = join(root, 'repo')
const prepared = join(root, 'prepared')
await git(root, ['init', '--quiet', repo])
await git(repo, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
await writeFile(join(repo, 'tracked.txt'), 'original\n')
await git(repo, ['add', 'tracked.txt'])
await git(repo, [
'-c',
'user.name=Test',
'-c',
'user.email=test@example.invalid',
'commit',
'--quiet',
'-m',
'initial'
])
return { repo, prepared }
}
it.each([
['first', 'cancellation'],
['cached', 'cancellation'],
['first', 'path probe'],
['cached', 'path probe']
])('reclaims its registered %s fallback after %s before locking', async (fallback, failureKind) => {
const { repo, prepared } = await fixture()
const reason = createWorktreePreparationLockReason('fallback-cleanup')
const controller = new AbortController()
const failure = new Error(`${failureKind} after completed add`)
const run = runner.gitExecFileAsync
let registered = false
if (fallback === 'cached') {
getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason')
}
const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
if (args.includes('--reason')) {
throw unsupported
}
const result = await run(args, options)
if (args.includes('--no-checkout')) {
registered = true
expect(existsSync(join(prepared, '.git'))).toBe(true)
}
if (registered && options?.cwd === prepared && args.includes('--git-path')) {
expect(existsSync(join(prepared, 'tracked.txt'))).toBe(false)
if (failureKind === 'cancellation') {
controller.abort(failure)
}
throw failure
}
return result
})
await expect(
prepareWorktreeCreateCheckout(repo, prepared, 'main', reason, {
signal: controller.signal,
timeout: 180_000
})
).rejects.toBe(failure)
const removals = spy.mock.calls.filter(([args]) => args.includes('remove'))
expect(removals).toHaveLength(1)
const [removeArgs, removeOptions] = removals[0]!
expect(removeArgs.filter((arg) => arg === '--force')).toHaveLength(1)
expect(removeOptions).toMatchObject({
cwd: repo,
timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
})
expect(removeOptions).not.toHaveProperty('signal')
expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false)
expect(existsSync(prepared)).toBe(false)
expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).not.toContain(
prepared
)
})
it.each(['first', 'cached'])(
'preserves another marker after a successful %s fallback add',
async (fallback) => {
const { repo, prepared } = await fixture()
const reason = createWorktreePreparationLockReason('competing-fallback')
const run = runner.gitExecFileAsync
let lock = ''
if (fallback === 'cached') {
getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason')
}
const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
if (args.includes('--reason')) {
throw unsupported
}
const result = await run(args, options)
if (args.includes('--no-checkout')) {
lock = (await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared })).stdout.trim()
await writeFile(lock, 'manual competing owner\n')
await writeFile(join(prepared, 'user.txt'), 'preserve this file\n')
}
return result
})
await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toThrow(
'lock owner changed'
)
expect(await readFile(lock, 'utf8')).toBe('manual competing owner\n')
expect(await readFile(join(prepared, 'user.txt'), 'utf8')).toBe('preserve this file\n')
expect(spy.mock.calls.some(([args]) => args.includes('remove') || args.includes('reset'))).toBe(
false
)
expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).toContain(
prepared
)
}
)
it.each(['first', 'cached'])(
'lets Git protect a competing lock from %s fallback cleanup after a generic probe failure',
async (fallback) => {
const { repo, prepared } = await fixture()
const reason = createWorktreePreparationLockReason('protected-fallback-cleanup')
const failure = new Error('lock path became unreadable')
const run = runner.gitExecFileAsync
let lock = ''
if (fallback === 'cached') {
getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason')
}
const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
if (args.includes('--reason')) {
throw unsupported
}
if (lock && options?.cwd === prepared && args.includes('--git-path')) {
throw failure
}
const result = await run(args, options)
if (args.includes('--no-checkout')) {
lock = (await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared })).stdout.trim()
await writeFile(lock, 'manual protected owner\n')
await writeFile(join(prepared, 'user.txt'), 'preserve after cleanup attempt\n')
}
return result
})
await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toBe(
failure
)
const removals = spy.mock.calls.filter(([args]) => args.includes('remove'))
expect(removals).toHaveLength(1)
expect(removals[0]![0].filter((arg) => arg === '--force')).toHaveLength(1)
expect(removals[0]![1]).not.toHaveProperty('signal')
expect(removals[0]![1]).toMatchObject({ timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS })
expect(await readFile(lock, 'utf8')).toBe('manual protected owner\n')
expect(await readFile(join(prepared, 'user.txt'), 'utf8')).toBe(
'preserve after cleanup attempt\n'
)
expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false)
expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).toContain(
prepared
)
}
)
it('preserves a pre-existing empty target if its cached fallback lock-path probe fails', async () => {
const { repo, prepared } = await fixture()
await mkdir(prepared)
getLocalGitCapabilityCache().rememberUnsupported('worktree-add-lock-reason')
const failure = new Error('pre-existing target lock path unavailable')
const run = runner.gitExecFileAsync
const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
if (options?.cwd === prepared && args.includes('--git-path')) {
throw failure
}
return run(args, options)
})
await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', 'reason')).rejects.toBe(
failure
)
expect(existsSync(join(prepared, '.git'))).toBe(true)
expect(spy.mock.calls.some(([args]) => args.includes('remove') || args.includes('reset'))).toBe(
false
)
expect((await run(['worktree', 'list', '--porcelain'], { cwd: repo })).stdout).toContain(prepared)
})
@@ -0,0 +1,336 @@
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation'
import * as runner from './runner'
import {
discardPreparedWorktree,
finalizePreparedWorktree,
prepareWorktreeCreateCheckout
} from './worktree-create-preparation'
import { unlockWorktreePreparation } from './worktree-preparation-lock'
import {
_resetPreparationPoolForTests,
listPreparations,
startPreparation,
WORKTREE_CREATE_PREPARATION_TTL_MS
} from '../worktree-create-preparation-pool'
const roots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
await _resetPreparationPoolForTests()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function git(cwd: string, args: string[]): Promise<string> {
return (await runner.gitExecFileAsync(args, { cwd })).stdout.trim()
}
async function fixture(): Promise<{ root: string; repo: string; prepared: string; final: string }> {
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-preparation-lock-')))
roots.push(root)
const repo = join(root, 'repo')
await git(root, ['init', '--quiet', repo])
await git(repo, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
await writeFile(join(repo, 'tracked.txt'), 'original\n')
await git(repo, ['add', 'tracked.txt'])
await git(repo, [
'-c',
'user.name=Test',
'-c',
'user.email=test@example.com',
'commit',
'--quiet',
'-m',
'initial'
])
return { root, repo, prepared: join(root, 'prepared'), final: join(root, 'final') }
}
it('creates and consumes its marker without worktree lock or unlock inventory scans', async () => {
const { repo, prepared, final } = await fixture()
const reason = createWorktreePreparationLockReason('targeted')
const spy = vi.spyOn(runner, 'gitExecFileAsync')
await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)
const lock = await git(prepared, ['rev-parse', '--git-path', 'locked'])
expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`)
spy.mockClear()
await finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason)
expect(spy.mock.calls.filter(([args]) => args.includes('--git-path'))).toHaveLength(1)
expect(spy.mock.calls.filter(([args]) => args.includes('--git-common-dir'))).toHaveLength(1)
expect(await git(final, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature')
expect(await git(final, ['status', '--porcelain'])).toBe('')
expect(await readFile(join(final, 'tracked.txt'), 'utf8')).toBe('original\n')
await expect(readFile(lock, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(spy.mock.calls.some(([args]) => args.includes('lock') || args.includes('unlock'))).toBe(
false
)
})
it('has its exact ownership marker before the atomic add returns', async () => {
const { repo, prepared } = await fixture()
const reason = createWorktreePreparationLockReason('atomic-add')
const run = runner.gitExecFileAsync
let observed = false
vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
const result = await run(args, options)
if (args.includes('--reason')) {
const lock = (
await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared })
).stdout.trim()
expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`)
await expect(readFile(join(prepared, 'tracked.txt'))).rejects.toMatchObject({
code: 'ENOENT'
})
observed = true
}
return result
})
await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)
const version = await git(repo, ['--version'])
const minor = Number(version.match(/git version 2\.(\d+)/)?.[1])
expect(observed).toBe(minor >= 33)
})
it('cleans only its newly registered marker when cancellation follows atomic add', async () => {
const { repo, prepared } = await fixture()
const reason = createWorktreePreparationLockReason('atomic-cancellation')
const controller = new AbortController()
const run = runner.gitExecFileAsync
const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
const result = await run(args, options)
if (args.includes('--no-checkout') && args.includes('--reason')) {
controller.abort()
throw new Error('canceled after atomic add')
}
return result
})
await expect(
prepareWorktreeCreateCheckout(repo, prepared, 'main', reason, { signal: controller.signal })
).rejects.toThrow('canceled after atomic add')
expect(spy.mock.calls.some(([args]) => args.includes('reset'))).toBe(false)
await expect(readFile(join(prepared, '.git'))).rejects.toMatchObject({ code: 'ENOENT' })
expect(await git(repo, ['worktree', 'list', '--porcelain'])).not.toContain(prepared)
})
it('preserves an existing owned checkout when another add fails at its path', async () => {
const { repo, prepared } = await fixture()
const reason = createWorktreePreparationLockReason('existing-add')
await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)
const lock = await git(prepared, ['rev-parse', '--git-path', 'locked'])
const spy = vi.spyOn(runner, 'gitExecFileAsync')
await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toThrow()
expect(spy.mock.calls.some(([args]) => args.includes('remove'))).toBe(false)
expect(await readFile(lock, 'utf8')).toBe(`${reason}\n`)
expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('original\n')
})
it('resolves a relative gitfile and retains a competing unlock marker', async () => {
const { repo, prepared } = await fixture()
const reason = createWorktreePreparationLockReason('relative')
await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)
const adminDir = await git(prepared, ['rev-parse', '--git-dir'])
await writeFile(join(prepared, '.git'), `gitdir: ${relative(prepared, adminDir)}\n`)
const lock = join(adminDir, 'locked')
await writeFile(lock, 'manual user lock\n')
await expect(unlockWorktreePreparation(prepared, reason, {})).rejects.toThrow(
'lock owner changed'
)
expect(await readFile(lock, 'utf8')).toBe('manual user lock\n')
expect(await git(prepared, ['rev-parse', '--verify', 'HEAD'])).toBe(
await git(repo, ['rev-parse', 'HEAD'])
)
})
it('preserves a competing marker and registration through preparation failure and pool reset', async () => {
const { root, repo } = await fixture()
let prepared = ''
let lock = ''
const run = runner.gitExecFileAsync
vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
const result = await run(args, options)
if (args.includes('reset') && options?.cwd) {
prepared = options.cwd
lock = (await run(['rev-parse', '--git-path', 'locked'], options)).stdout.trim()
await writeFile(lock, 'manual competing preparation\n')
}
return result
})
await expect(
startPreparation({
repoPath: repo,
workspaceRoot: root,
baseBranch: 'main',
canonicalBase: 'refs/heads/main',
options: {}
})
).rejects.toThrow('lock owner changed')
await _resetPreparationPoolForTests()
expect(await readFile(lock, 'utf8')).toBe('manual competing preparation\n')
expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('original\n')
expect(await git(repo, ['worktree', 'list', '--porcelain'])).toContain(
'manual competing preparation'
)
})
it('claims the marker before materialization and preserves a competing owner after add', async () => {
const { repo, prepared } = await fixture()
const reason = createWorktreePreparationLockReason('before-materialization')
const run = runner.gitExecFileAsync
let lock = ''
const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
const result = await run(args, options)
if (args.includes('--no-checkout')) {
lock = (await run(['rev-parse', '--git-path', 'locked'], { cwd: prepared })).stdout.trim()
await writeFile(lock, 'manual before materialization\n')
await writeFile(join(prepared, 'tracked.txt'), 'user checkout content\n')
}
return result
})
await expect(prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)).rejects.toThrow(
'lock owner changed'
)
expect(spy.mock.calls.some(([args]) => args.includes('reset') || args.includes('remove'))).toBe(
false
)
expect(await readFile(lock, 'utf8')).toBe('manual before materialization\n')
expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('user checkout content\n')
})
it.each(['checkout', 'push.autoSetupRemote'])(
'preserves the finalized checkout when its marker is replaced during %s',
async (command) => {
const { repo, prepared, final } = await fixture()
const reason = createWorktreePreparationLockReason('replacement')
await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)
const lock = await git(prepared, ['rev-parse', '--git-path', 'locked'])
const run = runner.gitExecFileAsync
vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
if (args.includes(command)) {
await writeFile(lock, 'manual finalized lock\n')
}
return run(args, options)
})
await expect(
finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason)
).rejects.toThrow('lock owner changed')
expect(await readFile(lock, 'utf8')).toBe('manual finalized lock\n')
expect(await readFile(join(final, 'tracked.txt'), 'utf8')).toBe('original\n')
expect(await git(final, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature')
}
)
it('leaves a replacement owner untouched before any reset, move, or branch attachment', async () => {
const { repo, prepared, final } = await fixture()
const reason = createWorktreePreparationLockReason('replaced-before-finalize')
await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)
const lock = await git(prepared, ['rev-parse', '--git-path', 'locked'])
await writeFile(lock, 'manual replacement\n')
await writeFile(join(prepared, 'tracked.txt'), 'user edits\n')
const spy = vi.spyOn(runner, 'gitExecFileAsync')
await expect(
finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason)
).rejects.toThrow('lock owner changed')
expect(
spy.mock.calls.some(
([args]) => args.includes('reset') || args.includes('move') || args.includes('checkout')
)
).toBe(false)
expect(await readFile(lock, 'utf8')).toBe('manual replacement\n')
expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('user edits\n')
expect(await git(repo, ['branch', '--list', 'feature'])).toBe('')
})
it('checks replacement ownership after move before attaching a branch', async () => {
const { repo, prepared, final } = await fixture()
const reason = createWorktreePreparationLockReason('replaced-after-move')
await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)
const lock = await git(prepared, ['rev-parse', '--git-path', 'locked'])
const run = runner.gitExecFileAsync
const spy = vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
const result = await run(args, options)
if (args.includes('move')) {
await writeFile(lock, 'manual moved lock\n')
}
return result
})
await expect(
finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason)
).rejects.toThrow('lock owner changed')
expect(
spy.mock.calls.some(([args]) => args.includes('checkout') || args.includes('remove'))
).toBe(false)
expect(await readFile(lock, 'utf8')).toBe('manual moved lock\n')
expect(await git(final, ['symbolic-ref', '--quiet', 'HEAD']).catch(() => 'detached')).toBe(
'detached'
)
expect(await git(repo, ['branch', '--list', 'feature'])).toBe('')
})
it.each(['replacement', 'missing'])(
'preserves checkout and branch if failure cleanup finds a %s marker',
async (marker) => {
const { repo, prepared, final } = await fixture()
const reason = createWorktreePreparationLockReason('failure-cleanup')
await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)
const lock = await git(prepared, ['rev-parse', '--git-path', 'locked'])
const run = runner.gitExecFileAsync
vi.spyOn(runner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
const result = await run(args, options)
if (args.includes('checkout')) {
await (marker === 'replacement' ? writeFile(lock, 'manual failed lock\n') : rm(lock))
throw new Error('injected checkout failure')
}
return result
})
await expect(
finalizePreparedWorktree(repo, prepared, final, 'feature', 'main', false, {}, reason)
).rejects.toThrow('injected checkout failure')
expect(await readFile(join(final, 'tracked.txt'), 'utf8')).toBe('original\n')
expect(await git(final, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature')
expect(await git(repo, ['branch', '--list', 'feature'])).toContain('feature')
if (marker === 'replacement') {
expect(await readFile(lock, 'utf8')).toBe('manual failed lock\n')
}
}
)
it('refuses a force discard after the marker was replaced', async () => {
const { repo, prepared } = await fixture()
const reason = createWorktreePreparationLockReason('discard-replacement')
await prepareWorktreeCreateCheckout(repo, prepared, 'main', reason)
const lock = await git(prepared, ['rev-parse', '--git-path', 'locked'])
await writeFile(lock, 'manual discard lock\n')
await expect(discardPreparedWorktree(repo, prepared, {}, reason)).rejects.toThrow(
'lock owner changed'
)
expect(await readFile(lock, 'utf8')).toBe('manual discard lock\n')
expect(await readFile(join(prepared, 'tracked.txt'), 'utf8')).toBe('original\n')
})
it.each(['expiry', 'pool reset'])('preserves a replacement marker during %s', async (kind) => {
const { root, repo } = await fixture()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
await startPreparation({
repoPath: repo,
workspaceRoot: root,
baseBranch: 'main',
canonicalBase: 'refs/heads/main',
options: {}
})
const entry = listPreparations()[0]
const lock = await git(entry.preparedPath, ['rev-parse', '--git-path', 'locked'])
await writeFile(lock, 'manual expired lock\n')
if (kind === 'expiry') {
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_PREPARATION_TTL_MS)
}
await _resetPreparationPoolForTests()
expect(await readFile(lock, 'utf8')).toBe('manual expired lock\n')
expect(await readFile(join(entry.preparedPath, 'tracked.txt'), 'utf8')).toBe('original\n')
} finally {
vi.useRealTimers()
}
})
@@ -0,0 +1,193 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { toHostFilesystemPath } from '../host-tree-removal'
import type * as FilePromises from 'node:fs/promises'
const mocks = vi.hoisted(() => ({
git: vi.fn(),
readFile: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn()
}))
vi.mock('./runner', () => ({ gitExecFileAsync: mocks.git }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FilePromises>()),
readFile: mocks.readFile,
writeFile: mocks.writeFile,
unlink: mocks.unlink
}))
import {
lockWorktreePreparation,
resolveWorktreePreparationLockPath,
unlockWorktreePreparation,
unlockWorktreePreparationAtPath
} from './worktree-preparation-lock'
const lockReason = 'orca-create-preparation:v1:123:exact-session'
const commonDir = join(tmpdir(), 'repo', '.git')
const gitLockPath = join(commonDir, 'worktrees', 'prepared', 'locked')
const lockPath = toHostFilesystemPath(gitLockPath)
beforeEach(() => {
mocks.git.mockReset().mockImplementation(async (args: string[]) => ({
stdout: `${args.includes('--git-path') ? gitLockPath : commonDir}\n`
}))
mocks.readFile.mockReset().mockResolvedValue(`${lockReason}\n`)
mocks.writeFile.mockReset().mockResolvedValue(undefined)
mocks.unlink.mockReset().mockResolvedValue(undefined)
})
describe('targeted preparation lock ownership', () => {
it('creates Git’s reason marker exclusively without enumerating worktrees', async () => {
const options = { wslDistro: 'Ubuntu', timeout: 8000, admissionTier: 'interactive' as const }
await lockWorktreePreparation('/prepared', lockReason, options)
expect(mocks.git).toHaveBeenCalledTimes(2)
expect(mocks.git).toHaveBeenCalledWith(['rev-parse', '--git-path', 'locked'], {
cwd: '/prepared',
...options
})
expect(mocks.git).toHaveBeenCalledWith(['rev-parse', '--git-common-dir'], {
cwd: '/prepared',
...options
})
expect(mocks.writeFile).toHaveBeenCalledExactlyOnceWith(lockPath, `${lockReason}\n`, {
flag: 'wx'
})
})
it('preserves an existing lock if exclusive creation fails', async () => {
const error = Object.assign(new Error('lock exists'), { code: 'EEXIST' })
mocks.writeFile.mockRejectedValueOnce(error)
await expect(lockWorktreePreparation('/prepared', lockReason, {})).rejects.toThrow(
'lock owner changed'
)
expect(mocks.unlink).not.toHaveBeenCalled()
})
it('unlinks only the exact reason minted for this checkout', async () => {
await unlockWorktreePreparation('/final', lockReason, {})
expect(mocks.readFile).toHaveBeenCalledExactlyOnceWith(lockPath, 'utf8')
expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith(lockPath)
})
it('reuses a verified administrative path and reads the owner again before unlinking', async () => {
await unlockWorktreePreparationAtPath(lockPath, lockReason)
expect(mocks.git).not.toHaveBeenCalled()
expect(mocks.readFile).toHaveBeenCalledExactlyOnceWith(lockPath, 'utf8')
expect(mocks.unlink).toHaveBeenCalledExactlyOnceWith(lockPath)
})
it.each([
'user lock\n',
'orca-create-preparation:v1:123:another-session\n',
lockReason,
`${lockReason}\n\n`
])('preserves a replacement owner: %s', async (reason) => {
mocks.readFile.mockResolvedValueOnce(reason)
await expect(unlockWorktreePreparation('/final', lockReason, {})).rejects.toThrow(
'lock owner changed'
)
expect(mocks.unlink).not.toHaveBeenCalled()
})
it.each(['ENOENT', 'EACCES'])(
'preserves the checkout when marker ownership cannot be read: %s',
async (code) => {
mocks.readFile.mockRejectedValueOnce(Object.assign(new Error('marker unavailable'), { code }))
await expect(unlockWorktreePreparation('/final', lockReason, {})).rejects.toThrow(
'lock owner changed'
)
expect(mocks.unlink).not.toHaveBeenCalled()
}
)
it('honors cancellation after the path probe before writing a lock', async () => {
const controller = new AbortController()
mocks.git.mockImplementationOnce(async () => {
controller.abort()
return { stdout: `${gitLockPath}\n` }
})
await expect(
lockWorktreePreparation('/prepared', lockReason, { signal: controller.signal })
).rejects.toThrow()
expect(mocks.writeFile).not.toHaveBeenCalled()
})
it('honors cancellation during the ownership read before unlinking', async () => {
const controller = new AbortController()
const cancellation = new Error('unlock canceled')
mocks.readFile.mockImplementationOnce(async () => {
controller.abort(cancellation)
return `${lockReason}\n`
})
await expect(
unlockWorktreePreparationAtPath(lockPath, lockReason, controller.signal)
).rejects.toBe(cancellation)
expect(mocks.unlink).not.toHaveBeenCalled()
})
})
describe('Git preparation lock path resolution', () => {
it.each([
{
path: '/prepared',
lock: '/repo/.git/worktrees/prepared/locked\n',
common: '/repo/.git\n',
expected: '/repo/.git/worktrees/prepared/locked',
options: { platform: 'linux' as const }
},
{
path: '/workspace/prepared',
lock: '../../repo/.git/worktrees/prepared/locked\n',
common: '../../repo/.git\n',
expected: '/repo/.git/worktrees/prepared/locked',
options: { platform: 'linux' as const }
},
{
path: String.raw`C:\workspace\prepared`,
lock: 'C:/repo/.git/worktrees/prepared/locked\n',
common: 'C:/repo/.git\n',
expected: 'C:/repo/.git/worktrees/prepared/locked',
options: { platform: 'win32' as const, wslDistro: 'Ubuntu' }
},
{
path: String.raw`\\wsl.localhost\Ubuntu\home\workspace\prepared`,
lock: '/home/repo/.git/worktrees/prepared/locked\n',
common: '/home/repo/.git\n',
expected: String.raw`\\wsl.localhost\Ubuntu\home\repo\.git\worktrees\prepared\locked`,
options: { platform: 'win32' as const, wslDistro: 'Ubuntu' }
},
{
path: String.raw`C:\workspace\prepared`,
lock: '/mnt/c/repo/.git/worktrees/prepared/locked\n',
common: '/mnt/c/repo/.git\n',
expected: String.raw`C:\repo\.git\worktrees\prepared\locked`,
options: { platform: 'win32' as const, wslDistro: 'Ubuntu' }
},
{
path: '/workspace/new\nline/prepared',
lock: '/repo/new\nline/.git/worktrees/prepared/locked\n',
common: '/repo/new\nline/.git\n',
expected: '/repo/new\nline/.git/worktrees/prepared/locked',
options: { platform: 'linux' as const }
}
])(
'resolves $path in the filesystem namespace that owns Git',
({ path, lock, common, expected, options }) => {
expect(resolveWorktreePreparationLockPath(path, lock, common, options)).toBe(expected)
}
)
it.each([
{ lock: '/repo/.git/locked\n', common: '/repo/.git\n' },
{ lock: '/other/.git/worktrees/prepared/locked\n', common: '/repo/.git\n' },
{ lock: '/repo/.git/worktrees/prepared/nested/locked\n', common: '/repo/.git\n' },
{ lock: '/repo/.git/worktrees/prepared/locked\n', common: '' }
])('rejects a path without one linked administration entry: $lock', ({ lock, common }) => {
expect(() => resolveWorktreePreparationLockPath('/prepared', lock, common)).toThrow(
'linked worktree lock path'
)
})
})
+130
View File
@@ -0,0 +1,130 @@
import { readFile, unlink, writeFile } from 'node:fs/promises'
import { posix, win32 } from 'node:path'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { resolveGitMetadataPath, type GitMetadataPathOptions } from '../../shared/git-metadata-path'
import { toHostFilesystemPath } from '../host-tree-removal'
import { gitExecFileAsync } from './runner'
import {
getErrorCode,
gitExecOptions,
type GitWorktreeExecOptions
} from './worktree-operation-options'
export class WorktreePreparationLockOwnershipError extends Error {
constructor(cause?: unknown) {
super('The prepared worktree lock owner changed', { cause })
}
}
export function resolveWorktreePreparationLockPath(
worktreePath: string,
rawLockPath: string,
rawCommonDir: string,
options: GitMetadataPathOptions = {}
): string {
const lockPath = resolveGitMetadataPath(worktreePath, rawLockPath, options)
const commonDir = resolveGitMetadataPath(worktreePath, rawCommonDir, options)
if (!lockPath || !commonDir) {
throw new Error('Git did not return a linked worktree lock path')
}
const paths = isWindowsAbsolutePathLike(lockPath) ? win32 : posix
const segments = paths.relative(commonDir, lockPath).split(paths.sep)
if (
segments.length !== 3 ||
segments[0] !== 'worktrees' ||
!segments[1] ||
segments[2] !== 'locked'
) {
throw new Error('Git did not return a linked worktree lock path')
}
return lockPath
}
async function readPreparationLockPath(
worktreePath: string,
options: GitWorktreeExecOptions
): Promise<string> {
const results = await Promise.allSettled([
gitExecFileAsync(['rev-parse', '--git-path', 'locked'], gitExecOptions(worktreePath, options)),
gitExecFileAsync(['rev-parse', '--git-common-dir'], gitExecOptions(worktreePath, options))
])
const [lock, common] = results
if (lock.status === 'rejected') {
throw lock.reason
}
if (common.status === 'rejected') {
throw common.reason
}
return toHostFilesystemPath(
resolveWorktreePreparationLockPath(
worktreePath,
lock.value.stdout,
common.value.stdout,
options
)
)
}
export async function lockWorktreePreparation(
worktreePath: string,
lockReason: string,
options: GitWorktreeExecOptions
): Promise<string> {
const lockPath = await readPreparationLockPath(worktreePath, options)
options.signal?.throwIfAborted()
// Git's own lock marker is a reason plus newline; exclusive creation preserves another owner.
try {
await writeFile(lockPath, `${lockReason}\n`, { flag: 'wx' })
} catch (error) {
if (getErrorCode(error) === 'EEXIST') {
throw new WorktreePreparationLockOwnershipError()
}
throw error
}
return lockPath
}
export async function unlockWorktreePreparation(
worktreePath: string,
expectedLockReason: string,
options: GitWorktreeExecOptions
): Promise<void> {
const lockPath = await readPreparationLockPath(worktreePath, options)
await unlockWorktreePreparationAtPath(lockPath, expectedLockReason, options.signal)
}
/** A move preserves the linked administration directory already verified by finalization. */
export async function unlockWorktreePreparationAtPath(
lockPath: string,
expectedLockReason: string,
signal?: AbortSignal
): Promise<void> {
await verifyWorktreePreparationLockAtPath(lockPath, expectedLockReason, signal)
await unlink(lockPath).catch((error: unknown) => {
throw new WorktreePreparationLockOwnershipError(error)
})
}
export async function verifyWorktreePreparationLock(
worktreePath: string,
expectedLockReason: string,
options: GitWorktreeExecOptions
): Promise<string> {
const lockPath = await readPreparationLockPath(worktreePath, options)
await verifyWorktreePreparationLockAtPath(lockPath, expectedLockReason, options.signal)
return lockPath
}
export async function verifyWorktreePreparationLockAtPath(
lockPath: string,
expectedLockReason: string,
signal?: AbortSignal
): Promise<void> {
const lockReason = await readFile(lockPath, 'utf8').catch((error: unknown) => {
throw new WorktreePreparationLockOwnershipError(error)
})
if (lockReason !== `${expectedLockReason}\n`) {
throw new WorktreePreparationLockOwnershipError()
}
signal?.throwIfAborted()
}
@@ -0,0 +1,156 @@
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation'
import * as runner from './runner'
import {
finalizePreparedWorktree,
prepareWorktreeCreateCheckout
} from './worktree-create-preparation'
import { refreshPreparedWorktreeTip } from './worktree-preparation-tip-refresh'
import {
_resetPreparationPoolForTests,
listPreparations,
releasePreparationClaim,
startPreparation,
takePreparation
} from '../worktree-create-preparation-pool'
const roots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
await _resetPreparationPoolForTests()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function git(cwd: string, args: string[]): Promise<string> {
return (await runner.gitExecFileAsync(args, { cwd })).stdout.trim()
}
async function fixture() {
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-fetched-preparation-')))
roots.push(root)
const repo = join(root, 'repo')
const prepared = join(root, 'prepared')
const final = join(root, 'final')
await git(root, ['init', '--quiet', repo])
await git(repo, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
await git(repo, ['config', 'user.name', 'Test'])
await git(repo, ['config', 'user.email', 'test@example.com'])
await writeFile(join(repo, 'version.txt'), 'original\n')
await git(repo, ['add', 'version.txt'])
await git(repo, ['commit', '--quiet', '-m', 'initial'])
const hooks = join(root, 'hooks')
await mkdir(hooks)
await writeFile(join(hooks, 'post-checkout'), '#!/bin/sh\necho checkout >> checkout-hook.txt\n', {
mode: 0o755
})
await git(repo, ['config', 'core.hooksPath', hooks])
const base = 'refs/remotes/origin/main'
await git(repo, ['update-ref', base, 'HEAD'])
const reason = createWorktreePreparationLockReason('fetched-tip')
await prepareWorktreeCreateCheckout(repo, prepared, base, reason)
return { root, repo, prepared, final, base, reason }
}
async function advance(repo: string, base: string, text: string): Promise<string> {
await writeFile(join(repo, 'version.txt'), text)
await git(repo, ['commit', '--quiet', '-am', text.trim()])
const head = await git(repo, ['rev-parse', 'HEAD'])
await git(repo, ['update-ref', base, head])
return head
}
it('moves changed tip work into prefetch while submit still runs exactly one checkout hook', async () => {
const { repo, prepared, final, base, reason } = await fixture()
const target = await advance(repo, base, 'fetched\n')
const spy = vi.spyOn(runner, 'gitExecFileAsync')
await refreshPreparedWorktreeTip(repo, prepared, base, reason)
expect(await readFile(join(prepared, 'version.txt'), 'utf8')).toBe('fetched\n')
expect(await git(prepared, ['rev-parse', 'HEAD'])).toBe(target)
expect(existsSync(join(prepared, 'checkout-hook.txt'))).toBe(false)
expect(spy.mock.calls.filter(([args]) => args.includes('reset'))).toHaveLength(1)
spy.mockClear()
await refreshPreparedWorktreeTip(repo, prepared, base, reason)
await finalizePreparedWorktree(repo, prepared, final, 'feature', base, false, {}, reason)
expect(spy.mock.calls.filter(([args]) => args.includes('reset'))).toHaveLength(0)
expect(await readFile(join(final, 'checkout-hook.txt'), 'utf8')).toBe('checkout\n')
expect(await git(final, ['rev-parse', 'HEAD'])).toBe(target)
expect(await git(final, ['status', '--porcelain', '--untracked-files=no'])).toBe('')
})
it('revalidates a newer fetched tip that arrives after the background refresh', async () => {
const { repo, prepared, final, base, reason } = await fixture()
await advance(repo, base, 'first fetch\n')
await refreshPreparedWorktreeTip(repo, prepared, base, reason)
const newest = await advance(repo, base, 'second fetch\n')
await finalizePreparedWorktree(repo, prepared, final, 'feature', base, false, {}, reason)
expect(await git(final, ['rev-parse', 'HEAD'])).toBe(newest)
expect(await readFile(join(final, 'version.txt'), 'utf8')).toBe('second fetch\n')
expect(await readFile(join(final, 'checkout-hook.txt'), 'utf8')).toBe('checkout\n')
})
it('preserves a competing lock and files when ownership changes before refresh', async () => {
const { repo, prepared, base, reason } = await fixture()
await advance(repo, base, 'new fetch\n')
const lock = await git(prepared, ['rev-parse', '--git-path', 'locked'])
await writeFile(lock, 'manual owner\n')
await expect(refreshPreparedWorktreeTip(repo, prepared, base, reason)).rejects.toThrow(
'lock owner changed'
)
expect(await readFile(lock, 'utf8')).toBe('manual owner\n')
expect(await readFile(join(prepared, 'version.txt'), 'utf8')).toBe('original\n')
expect(await git(repo, ['worktree', 'list', '--porcelain'])).toContain('locked manual owner')
})
it('makes a racing claim wait for one fetched-tip reset on an already ready checkout', async () => {
const { root, repo, final, base } = await fixture()
const args = {
repoPath: repo,
workspaceRoot: root,
baseBranch: base,
canonicalBase: base,
options: {}
}
await startPreparation(args)
const entry = listPreparations()[0]!
let settle!: () => void
const beforeMaterialization = new Promise<void>((resolve) => {
settle = resolve
})
const spy = vi.spyOn(runner, 'gitExecFileAsync')
const refreshing = startPreparation({ ...args, beforeMaterialization })
const claim = takePreparation(entry)
let finalized = false
const create = entry.ready.then(async () => {
await finalizePreparedWorktree(
repo,
entry.preparedPath,
final,
'feature',
base,
false,
{},
entry.lockReason
)
finalized = true
})
try {
await Promise.resolve()
expect(finalized).toBe(false)
expect(spy.mock.calls.filter(([argv]) => argv.includes('reset'))).toHaveLength(0)
const target = await advance(repo, base, 'fetched ready tip\n')
expect(finalized).toBe(false)
settle()
await Promise.all([refreshing, create])
expect(spy.mock.calls.filter(([argv]) => argv.includes('reset'))).toHaveLength(1)
expect(await git(final, ['rev-parse', 'HEAD'])).toBe(target)
expect(await readFile(join(final, 'version.txt'), 'utf8')).toBe('fetched ready tip\n')
expect(await readFile(join(final, 'checkout-hook.txt'), 'utf8')).toBe('checkout\n')
} finally {
settle()
releasePreparationClaim(claim)
}
})
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
git: vi.fn(),
verify: vi.fn(),
verifyAt: vi.fn(),
mutation: vi.fn(),
invalidation: vi.fn()
}))
vi.mock('./runner', () => ({ gitExecFileAsync: mocks.git }))
vi.mock('./worktree', () => ({ notifyPreparedWorktreeMutation: mocks.mutation }))
vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: mocks.invalidation }))
vi.mock('./local-repo-ref-maintenance', () => ({
withRepoRefMaintenancePaused: (_reason: string, run: () => Promise<unknown>) => run()
}))
vi.mock('./worktree-preparation-lock', () => ({
verifyWorktreePreparationLock: mocks.verify,
verifyWorktreePreparationLockAtPath: mocks.verifyAt
}))
import { refreshPreparedWorktreeTip } from './worktree-preparation-tip-refresh'
const OLD = 'a'.repeat(40)
const NEW = 'b'.repeat(40)
const BASE = 'refs/remotes/origin/main'
beforeEach(() => {
mocks.git.mockReset().mockImplementation(async (args: string[], options: { cwd?: string }) => ({
stdout: args[0] === 'rev-parse' && options.cwd === '/repo' ? `${NEW}\n` : `${OLD}\n`
}))
mocks.verify.mockReset().mockResolvedValue('/repo/.git/worktrees/prepared/locked')
mocks.verifyAt.mockReset().mockImplementation(async (_lock, _reason, signal?: AbortSignal) => {
signal?.throwIfAborted()
})
mocks.mutation.mockReset()
mocks.invalidation.mockReset().mockImplementation((run: () => Promise<unknown>) => run())
})
describe('prepared checkout fetched tip materialization', () => {
it('does no index or file mutation when the fetched tip is unchanged', async () => {
mocks.git.mockResolvedValue({ stdout: `${OLD}\n` })
await refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner')
expect(mocks.git.mock.calls.map(([args]) => args)).toEqual([
['rev-parse', '--verify', `${BASE}^{commit}`],
['rev-parse', '--verify', 'HEAD']
])
expect(mocks.invalidation).not.toHaveBeenCalled()
expect(mocks.mutation).not.toHaveBeenCalled()
})
it('resets only to the fetched commit on the owning WSL host without checkout hooks', async () => {
const signal = new AbortController().signal
await refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner', {
wslDistro: 'Ubuntu',
admissionTier: 'status',
signal
})
expect(mocks.git).toHaveBeenLastCalledWith(
['reset', '--hard', NEW],
expect.objectContaining({
cwd: '/prepared',
wslDistro: 'Ubuntu',
admissionTier: 'status',
signal
})
)
expect(mocks.git.mock.calls.some(([args]) => args.includes('checkout'))).toBe(false)
expect(mocks.verifyAt).toHaveBeenCalledTimes(2)
expect(mocks.mutation).toHaveBeenCalledOnce()
})
it('stops before any probe when the stored lock belongs to another owner', async () => {
mocks.verify.mockRejectedValueOnce(new Error('ownership lost'))
await expect(refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner')).rejects.toThrow(
'ownership lost'
)
expect(mocks.git).not.toHaveBeenCalled()
})
it('rechecks ownership after both reads before touching files', async () => {
mocks.verifyAt.mockRejectedValueOnce(new Error('ownership changed during probes'))
await expect(refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner')).rejects.toThrow(
'ownership changed'
)
expect(mocks.git).toHaveBeenCalledTimes(2)
expect(mocks.invalidation).not.toHaveBeenCalled()
})
it('settles both probes before reporting a failed ref read', async () => {
let release!: () => void
mocks.git
.mockRejectedValueOnce(new Error('base unavailable'))
.mockImplementationOnce(
() => new Promise((resolve) => (release = () => resolve({ stdout: OLD })))
)
let settled = false
const refresh = refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner').finally(() => {
settled = true
})
const assertion = expect(refresh).rejects.toThrow('base unavailable')
await vi.waitFor(() => expect(mocks.git).toHaveBeenCalledTimes(2))
expect(settled).toBe(false)
release()
await assertion
expect(mocks.invalidation).not.toHaveBeenCalled()
})
it('honors cancellation between probes and materialization', async () => {
const controller = new AbortController()
mocks.git.mockImplementation(async (_args: string[], options: { cwd?: string }) => {
controller.abort()
return { stdout: options.cwd === '/repo' ? NEW : OLD }
})
await expect(
refreshPreparedWorktreeTip('/repo', '/prepared', BASE, 'owner', { signal: controller.signal })
).rejects.toThrow()
expect(mocks.git).toHaveBeenCalledTimes(2)
expect(mocks.invalidation).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,60 @@
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { notifyPreparedWorktreeMutation } from './worktree'
import {
gitExecOptions,
resolveWorktreeAddTimeoutMs,
type GitWorktreeExecOptions
} from './worktree-operation-options'
import {
verifyWorktreePreparationLock,
verifyWorktreePreparationLockAtPath
} from './worktree-preparation-lock'
export async function refreshPreparedWorktreeTip(
repoPath: string,
preparedPath: string,
canonicalBase: string,
lockReason: string,
options: GitWorktreeExecOptions = {}
): Promise<void> {
const refreshOptions = { ...options, timeout: options.timeout ?? resolveWorktreeAddTimeoutMs() }
await withRepoRefMaintenancePaused('worktree-prepare', async () => {
const lockPath = await verifyWorktreePreparationLock(preparedPath, lockReason, refreshOptions)
const [target, prepared] = await Promise.allSettled([
gitExecFileAsync(
['rev-parse', '--verify', `${canonicalBase}^{commit}`],
gitExecOptions(repoPath, refreshOptions)
),
gitExecFileAsync(
['rev-parse', '--verify', 'HEAD'],
gitExecOptions(preparedPath, refreshOptions)
)
])
if (target.status === 'rejected') {
throw target.reason
}
if (prepared.status === 'rejected') {
throw prepared.reason
}
await verifyWorktreePreparationLockAtPath(lockPath, lockReason, refreshOptions.signal)
const targetHead = target.value.stdout.trim()
if (prepared.value.stdout.trim() === targetHead) {
return
}
try {
// Reset preserves detached HEAD and does not run post-checkout hooks before submit.
await runWithGitReadCacheInvalidation(() =>
gitExecFileAsync(
[...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead],
gitExecOptions(preparedPath, refreshOptions)
)
)
await verifyWorktreePreparationLockAtPath(lockPath, lockReason, refreshOptions.signal)
} finally {
notifyPreparedWorktreeMutation(repoPath)
}
})
}
+75 -70
View File
@@ -275,44 +275,53 @@ describe('registerAppHandlers', () => {
expect(appExitMock).not.toHaveBeenCalled()
})
it('returns the selected macOS input mode before the keyboard layout fallback', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
spawnMock.mockImplementation(() =>
createFakeSpawnChild({
stdout: JSON.stringify([
{ 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' },
{
'Bundle ID': 'com.apple.inputmethod.SCIM',
'Input Mode': 'com.apple.inputmethod.SCIM.ITABC',
InputSourceKind: 'Input Mode'
}
])
})
)
registerAppHandlers({} as never)
it.each([true, false])(
'prioritizes the selected input mode regardless of record order (%s)',
async (modeLast) => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
const inputMode = {
'Bundle ID': 'com.apple.inputmethod.SCIM',
'Input Mode': 'com.apple.inputmethod.SCIM.ITABC',
InputSourceKind: 'Input Mode'
}
const keyboardLayout = {
InputSourceKind: 'Keyboard Layout',
'KeyboardLayout Name': 'ABC',
'KeyboardLayout ID': 252
}
spawnMock.mockImplementation(() =>
createFakeSpawnChild({
stdout: JSON.stringify([
{ 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' },
...(modeLast ? [keyboardLayout, inputMode] : [inputMode, keyboardLayout])
])
})
)
registerAppHandlers({} as never)
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
'com.apple.inputmethod.SCIM.ITABC'
)
expect(spawnMock).toHaveBeenCalledTimes(1)
// Why: macOS 15's `plutil -extract <key> json` aborts on the input-source
// array, so the probe reads live cfprefsd via `defaults export` and dodges
// the bug with an xml1 extract before converting the clean subtree to JSON.
// Pin the exact pipeline (absolute paths, stdin markers) so dropping any
// stage silently regressing CJK detection to the fallback fails the test.
expect(spawnMock).toHaveBeenCalledWith(
'/bin/sh',
[
'-c',
'/usr/bin/defaults export com.apple.HIToolbox - | ' +
'/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' +
'/usr/bin/plutil -convert json -o - -'
],
expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] })
)
})
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
'com.apple.inputmethod.SCIM.ITABC'
)
expect(spawnMock).toHaveBeenCalledTimes(1)
// Why: macOS 15's `plutil -extract <key> json` aborts on the input-source
// array, so the probe reads live cfprefsd via `defaults export` and dodges
// the bug with an xml1 extract before converting the clean subtree to JSON.
// Pin the exact pipeline (absolute paths, stdin markers) so dropping any
// stage silently regressing CJK detection to the fallback fails the test.
expect(spawnMock).toHaveBeenCalledWith(
'/bin/sh',
[
'-c',
'/usr/bin/defaults export com.apple.HIToolbox - | ' +
'/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' +
'/usr/bin/plutil -convert json -o - -'
],
expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] })
)
}
)
it('falls back to the keyboard layout when no keyboard input mode is selected', async () => {
it('reads the layout ID only after a selected keyboard layout without a bundle ID is proved', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
spawnMock
.mockImplementationOnce(() =>
@@ -321,6 +330,11 @@ describe('registerAppHandlers', () => {
{
'Bundle ID': 'com.apple.PressAndHold',
InputSourceKind: 'Non Keyboard Input Method'
},
{
InputSourceKind: 'Keyboard Layout',
'KeyboardLayout Name': 'ABC',
'KeyboardLayout ID': 252
}
])
})
@@ -339,42 +353,34 @@ describe('registerAppHandlers', () => {
)
})
it('falls back to the keyboard layout when the selected input source probe exits non-zero', async () => {
it.each([
{ name: 'nonzero exit', result: { code: 1 } },
{ name: 'spawn failure', result: { error: new Error('spawn ENOENT') } },
{ name: 'invalid JSON', result: { stdout: '{' } },
{ name: 'non-array JSON', result: { stdout: '{}' } },
{ name: 'empty records', result: { stdout: '[]' } },
{ name: 'unknown record', result: { stdout: '[{"InputSourceKind":"Unknown"}]' } },
{
name: 'unidentified input mode',
result: { stdout: '[{"InputSourceKind":"Keyboard Layout"},{"InputSourceKind":"Input Mode"}]' }
},
{
name: 'non-keyboard record',
result: {
stdout:
'[{"InputSourceKind":"Non Keyboard Input Method","Bundle ID":"com.apple.PressAndHold"}]'
}
}
])('does not infer the backing layout after $name', async ({ result }) => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
// Why: reproduces macOS 15's `plutil` abort — the pipeline exits non-zero, so
// the probe rejects on the `close` branch and the handler falls back.
spawnMock
.mockImplementationOnce(() => createFakeSpawnChild({ code: 1 }))
.mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' }))
spawnMock.mockImplementation(() => createFakeSpawnChild(result))
registerAppHandlers({} as never)
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
'com.apple.keylayout.ABC'
)
expect(spawnMock).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenLastCalledWith(
'/usr/bin/defaults',
['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'],
expect.objectContaining({ detached: true })
)
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBeNull()
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('falls back to the keyboard layout when the selected input source probe fails to spawn', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
// Why: a spawn-level failure (ENOENT/EACCES) emits 'error'; the handler must
// still fall back rather than reject out of the IPC call.
spawnMock
.mockImplementationOnce(() => createFakeSpawnChild({ error: new Error('spawn ENOENT') }))
.mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' }))
registerAppHandlers({} as never)
await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe(
'com.apple.keylayout.ABC'
)
expect(spawnMock).toHaveBeenCalledTimes(2)
})
it('falls back when macOS keyboard input source probes never report completion', async () => {
it('returns unknown and cleans up when the selected-source probe times out', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true }))
registerAppHandlers({} as never)
@@ -391,9 +397,8 @@ describe('registerAppHandlers', () => {
expect(settled).toBe(true)
await expect(resultPromise).resolves.toBeNull()
// Why: both wedged probes get a process-group SIGKILL (negative pid) so the
// shell and any orphaned `defaults`/`plutil` stages are reaped on timeout.
expect(processKillSpy).toHaveBeenCalledTimes(2)
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(processKillSpy).toHaveBeenCalledTimes(1)
expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL')
})
+37 -27
View File
@@ -183,7 +183,9 @@ function readCommandStdout(
})
}
function readSelectedInputSourceIdFromJson(stdout: string): string | null {
type SelectedKeyboardInputSource = { kind: 'inputSource'; id: string } | { kind: 'keyboardLayout' }
function readSelectedInputSourceFromJson(stdout: string): SelectedKeyboardInputSource | null {
let records: unknown
try {
records = JSON.parse(stdout)
@@ -194,54 +196,62 @@ function readSelectedInputSourceIdFromJson(stdout: string): string | null {
return null
}
let hasSelectedKeyboardLayout = false
for (const record of records.slice().toReversed()) {
if (!record || typeof record !== 'object') {
continue
}
const fields = record as Record<string, unknown>
const kind = typeof fields.InputSourceKind === 'string' ? fields.InputSourceKind : ''
if (kind.toLowerCase().includes('non keyboard')) {
const kind =
'InputSourceKind' in record && typeof record.InputSourceKind === 'string'
? record.InputSourceKind.trim().toLowerCase()
: ''
if (kind === 'keyboard layout') {
hasSelectedKeyboardLayout = true
continue
}
const inputMode = fields['Input Mode']
if (typeof inputMode === 'string' && inputMode.trim()) {
return inputMode.trim()
if (kind.includes('non keyboard')) {
continue
}
const bundleId = fields['Bundle ID']
if (typeof bundleId === 'string' && bundleId.trim()) {
return bundleId.trim()
if (kind !== 'input mode' && kind !== 'keyboard input method') {
return null
}
const inputMode = 'Input Mode' in record ? record['Input Mode'] : undefined
const bundleId = 'Bundle ID' in record ? record['Bundle ID'] : undefined
const id = typeof inputMode === 'string' && inputMode.trim() ? inputMode : bundleId
if (typeof id === 'string' && id.trim()) {
return { kind: 'inputSource', id: id.trim() }
}
return null
}
return null
return hasSelectedKeyboardLayout ? { kind: 'keyboardLayout' } : null
}
async function readSelectedKeyboardInputSourceId(): Promise<string | null> {
async function readSelectedKeyboardInputSource(): Promise<SelectedKeyboardInputSource | null> {
try {
const stdout = await readCommandStdout(
'/bin/sh',
['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND],
'Selected keyboard input source probe timed out'
)
return readSelectedInputSourceIdFromJson(stdout)
return readSelectedInputSourceFromJson(stdout)
} catch {
return null
}
}
function readKeyboardLayoutInputSourceId(): Promise<string> {
return readCommandStdout(
'/usr/bin/defaults',
['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'],
'Keyboard layout input source probe timed out'
)
}
async function readKeyboardInputSourceId(): Promise<string | null> {
const selectedInputSourceId = await readSelectedKeyboardInputSourceId()
if (selectedInputSourceId) {
return selectedInputSourceId
const selectedInputSource = await readSelectedKeyboardInputSource()
if (selectedInputSource?.kind === 'inputSource') {
return selectedInputSource.id
}
return readKeyboardLayoutInputSourceId()
// An IME can use ABC underneath; the backing layout alone cannot identify the selected source.
return selectedInputSource?.kind === 'keyboardLayout'
? readCommandStdout(
'/usr/bin/defaults',
['read', MAC_HITOOLBOX_DOMAIN, 'AppleCurrentKeyboardLayoutInputSourceID'],
'Keyboard layout input source probe timed out'
)
: null
}
export function registerAppHandlers(store: Store, options: RegisterAppHandlersOptions = {}): void {
@@ -270,7 +280,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
ipcMain.handle('pwsh:isAvailable', (): Promise<boolean> => isPwshAvailableAsync())
ipcMain.handle('gitBash:isAvailable', (): boolean => isGitBashAvailable())
// Why: renderer layout fingerprint tags ABC/CJK-Roman as 'us', breaking Option+letter (#1205); HIToolbox prefs override it.
// The selected IME identity must win over its US-shaped backing keyboard layout.
ipcMain.handle('app:getKeyboardInputSourceId', async (): Promise<string | null> => {
if (process.platform !== 'darwin') {
return null
@@ -281,7 +291,7 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
const trimmed = stdout?.trim() ?? ''
return trimmed.length > 0 ? trimmed : null
} catch {
// Why: probe can fail (missing keys on first boot, sandbox) — treat as "no signal" and fall back to the fingerprint.
// A failed probe must not promote an IME's backing layout into an Alt default.
return null
}
})
@@ -109,25 +109,26 @@ describe('resolveCreatedWorktree', () => {
await expect(
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
).resolves.toEqual({ created: CREATED, worktrees: [MAIN, CREATED], listingComplete: true })
expect(describeCreatedWorktree).not.toHaveBeenCalled()
expect(describeCreatedWorktree).toHaveBeenCalledOnce()
})
it('completes the create from the direct read when the listing fails', async () => {
vi.mocked(listWorktreesSharedStrict).mockRejectedValue(new Error('git timed out.'))
it('verifies only the new checkout without listing or probing every existing worktree', async () => {
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
await expect(
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
).resolves.toEqual({ created: CREATED, worktrees: [], listingComplete: false })
expect(describeCreatedWorktree).toHaveBeenCalledOnce()
expect(listWorktreesSharedStrict).not.toHaveBeenCalled()
})
it('completes the create from the direct read when the listing omits the row', async () => {
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN])
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
it('uses the whole listing when the direct checkout witness cannot be read', async () => {
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([MAIN, CREATED])
vi.mocked(describeCreatedWorktree).mockRejectedValue(new Error('rev-parse exploded'))
await expect(
resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
).resolves.toMatchObject({ created: CREATED, listingComplete: false })
).resolves.toEqual({ created: CREATED, worktrees: [MAIN, CREATED], listingComplete: true })
})
it("surfaces the listing's own failure rather than an opaque message", async () => {
@@ -186,35 +187,50 @@ describe('resolveCreatedWorktree', () => {
})
})
it('charges the recovery what the listing left of the budget, not a fresh one', async () => {
vi.mocked(listWorktreesSharedStrict).mockImplementation(async () => {
it('charges the listing fallback what the direct read left of the budget', async () => {
vi.mocked(describeCreatedWorktree).mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 60))
throw new Error('git worktree list timed out.')
return undefined
})
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED])
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
const options = vi.mocked(describeCreatedWorktree).mock.lastCall?.[3]
const options = vi.mocked(listWorktreesSharedStrict).mock.lastCall?.[1]
expect(options?.timeout).toBeGreaterThanOrEqual(5_000)
expect(options?.timeout).toBeLessThan(30_000)
})
it("keeps the caller's own deadline instead of the shared budget", async () => {
vi.mocked(listWorktreesSharedStrict).mockRejectedValue(new Error('git worktree list failed.'))
vi.mocked(describeCreatedWorktree).mockResolvedValue(CREATED)
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED])
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature', { timeout: 1_234 })
expect(vi.mocked(describeCreatedWorktree).mock.lastCall?.[3]).toMatchObject({ timeout: 1_234 })
expect(vi.mocked(listWorktreesSharedStrict).mock.lastCall?.[1]).toMatchObject({
timeout: 1_234
})
})
it('forwards exec options only when the caller supplied them', async () => {
it('forwards execution-host options to both checkout verification and the fallback', async () => {
vi.mocked(listWorktreesSharedStrict).mockResolvedValue([CREATED])
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature')
expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith('/repo')
expect(describeCreatedWorktree).toHaveBeenLastCalledWith(
'/repo',
'/workspaces/feature',
'feature'
)
await resolveCreatedWorktree('/repo', '/workspaces/feature', 'feature', { wslDistro: 'Ubuntu' })
expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith('/repo', { wslDistro: 'Ubuntu' })
expect(describeCreatedWorktree).toHaveBeenLastCalledWith(
'/repo',
'/workspaces/feature',
'feature',
{ wslDistro: 'Ubuntu' }
)
expect(listWorktreesSharedStrict).toHaveBeenLastCalledWith(
'/repo',
expect.objectContaining({ wslDistro: 'Ubuntu' })
)
})
})
+32 -42
View File
@@ -23,7 +23,7 @@ export function findCreatedWorktree<T extends { path: string; branch?: string }>
export type CreatedWorktreeResolution = {
created: GitWorktreeInfo
/** Rows `git worktree list` returned; empty when only the direct read found the worktree. */
/** Rows `git worktree list` returned; empty when the direct read found the worktree. */
worktrees: readonly GitWorktreeInfo[]
/** Whether `worktrees` is the repo's whole listing, and so usable as its authorized-root set. */
listingComplete: boolean
@@ -36,16 +36,10 @@ export function createdWorktreeNotFoundError(worktreePath: string, branchName: s
)
}
/**
* Find the row for a worktree `git worktree add` just created, preferring the repo listing and
* falling back to asking Git about the worktree itself.
*
* Why the fallback: the listing was the only witness the old code had, so any Git-level listing
* failure failed a create whose worktree and branch were already on disk, orphaning both (#16520).
*/
/** A listing that burned the whole budget still leaves the direct read a chance to answer. */
const MIN_CREATED_WORKTREE_RECOVERY_MS = 5_000
/** A failed direct read still leaves the listing a chance to verify the create. */
const MIN_CREATED_WORKTREE_LIST_FALLBACK_MS = 5_000
/** Verify the new checkout directly; listing every existing worktree is only a fallback. */
export async function resolveCreatedWorktree(
repoPath: string,
worktreePath: string,
@@ -53,11 +47,28 @@ export async function resolveCreatedWorktree(
options?: GitWorktreeExecOptions
): Promise<CreatedWorktreeResolution> {
const startedAt = Date.now()
let directReadError: Error | undefined
try {
const created = options
? await describeCreatedWorktree(repoPath, worktreePath, branchName, options)
: await describeCreatedWorktree(repoPath, worktreePath, branchName)
if (created) {
return { created, worktrees: [], listingComplete: false }
}
} catch (err) {
directReadError = err instanceof Error ? err : new Error(String(err))
}
const remainingMs = Math.max(
(options?.timeout ?? WORKTREE_LIST_TIMEOUT_MS) - (Date.now() - startedAt),
MIN_CREATED_WORKTREE_LIST_FALLBACK_MS
)
let listingError: Error | undefined
try {
const worktrees = options
? await listWorktreesSharedStrict(repoPath, options)
: await listWorktreesSharedStrict(repoPath)
const worktrees = await listWorktreesSharedStrict(repoPath, {
...options,
timeout: options?.timeout ?? remainingMs
})
const created = findCreatedWorktree(worktrees, worktreePath, branchName)
if (created) {
return { created, worktrees, listingComplete: true }
@@ -65,39 +76,18 @@ export async function resolveCreatedWorktree(
} catch (err) {
listingError = err instanceof Error ? err : new Error(String(err))
}
try {
// One budget for verifying the create, not one per attempt: a hung Git already spent the
// listing's deadline, and charging the recovery a fresh one doubles the wait before the error.
const remainingMs = Math.max(
WORKTREE_LIST_TIMEOUT_MS - (Date.now() - startedAt),
MIN_CREATED_WORKTREE_RECOVERY_MS
)
const described = await describeCreatedWorktree(repoPath, worktreePath, branchName, {
...options,
timeout: options?.timeout ?? remainingMs
})
if (described) {
return { created: described, worktrees: [], listingComplete: false }
}
} catch (err) {
if (listingError) {
// The listing's failure stays the thrown one, but the recovery's reason -- often
// `repo common dir unverifiable: ...` -- would otherwise vanish from the record entirely.
if (listingError) {
if (directReadError) {
console.warn('[worktrees:create] created-worktree recovery also failed', {
err,
err: directReadError,
worktreePath
})
throw listingError
}
// The listing simply omitted the row, so the direct read holds the only actionable failure.
const notFound = createdWorktreeNotFoundError(worktreePath, branchName)
throw new Error(`${notFound.message}: ${err instanceof Error ? err.message : String(err)}`, {
cause: err
})
}
if (listingError) {
throw listingError
}
throw createdWorktreeNotFoundError(worktreePath, branchName)
const notFound = createdWorktreeNotFoundError(worktreePath, branchName)
if (directReadError) {
throw new Error(`${notFound.message}: ${directReadError.message}`, { cause: directReadError })
}
throw notFound
}
@@ -1,29 +0,0 @@
import type { IFilesystemProvider } from '../providers/types'
/** Whether a remote path exists; only a definite "missing" answer reads as false. */
export async function remotePathExists(
provider: IFilesystemProvider,
remotePath: string
): Promise<boolean> {
try {
await provider.stat(remotePath)
return true
} catch (error) {
if (isRemoteMissingError(error)) {
return false
}
throw error
}
}
function isRemoteMissingError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
return (
('code' in error && error.code === 'ENOENT') ||
/\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test(
error.message
)
)
}
+28 -8
View File
@@ -8,8 +8,6 @@ import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
import type { ImportItemResult } from '../../shared/filesystem-import-result-types'
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { runSshProviderContinuation } from '../ssh/ssh-provider-continuations'
import { remotePathExists } from './filesystem-import-ssh-remote-existence'
import {
captureLocalUploadRoot,
preScanSshImportDirectory,
@@ -19,12 +17,6 @@ import {
// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so
// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows.
export async function importExternalPathsSsh(
...args: Parameters<typeof importExternalPathsSshTracked>
): Promise<{ results: ImportItemResult[] }> {
return runSshProviderContinuation(args[2], () => importExternalPathsSshTracked(...args))
}
async function importExternalPathsSshTracked(
sourcePaths: string[],
destDir: string,
connectionId: string,
@@ -277,3 +269,31 @@ async function ensureDropStagingDir(
assertCurrent?.()
await provider.createDir(destDir)
}
async function remotePathExists(
provider: IFilesystemProvider,
remotePath: string
): Promise<boolean> {
try {
await provider.stat(remotePath)
return true
} catch (error) {
if (isRemoteMissingError(error)) {
return false
}
throw error
}
}
function isRemoteMissingError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
const code = (error as NodeJS.ErrnoException).code
return (
code === 'ENOENT' ||
/\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test(
error.message
)
)
}
@@ -1,238 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IFilesystemProvider } from '../providers/types'
import type { FilesystemHandlerContext } from './filesystem/filesystem-handler-context'
import { hasSshProviderContinuations } from '../ssh/ssh-provider-continuations'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import {
advanceSshConnectionGeneration,
resetSshConnectionGenerations
} from '../ssh/ssh-connection-generation'
import { importExternalPathsSsh } from './filesystem-import-ssh'
import { captureLocalUploadRoot, uploadSshImportDirectory } from './filesystem-import-ssh-directory'
import { registerFilesystemWriteHandlers } from './filesystem/filesystem-write-handlers'
const mocks = vi.hoisted(() => ({
handle: vi.fn(),
lstat: vi.fn(),
writeFile: vi.fn(),
trashItem: vi.fn(),
resolveAuthorizedPath: vi.fn(),
tryDeleteWslUncPath: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle }, shell: mocks }))
vi.mock('node:fs/promises', () => ({ lstat: mocks.lstat, writeFile: mocks.writeFile }))
vi.mock('./filesystem-auth', () => ({
authorizeExternalPath: vi.fn(),
resolveAuthorizedPath: mocks.resolveAuthorizedPath
}))
vi.mock('./filesystem-mutations', () => ({ registerFilesystemMutationHandlers: vi.fn() }))
vi.mock('../wsl-unc-delete', () => ({ tryDeleteWslUncPath: mocks.tryDeleteWslUncPath }))
vi.mock('./ssh', () => ({
getSshConnectionManager: () => ({
getConnection: () => ({ getState: () => ({ status: 'connected' }) })
})
}))
vi.mock('./filesystem-import-ssh-directory', () => ({
captureLocalUploadRoot: vi.fn(),
preScanSshImportDirectory: vi.fn(),
uploadSshImportDirectory: vi.fn()
}))
const targetId = 'filesystem-continuation-target'
// The IPC payload fields these handlers read; each test passes the subset its channel needs.
type FilesystemHandlerArgs = Record<string, unknown>
const handlers = new Map<
string,
(_event: unknown, args: FilesystemHandlerArgs) => Promise<unknown>
>()
const fileStat = { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
beforeEach(() => {
vi.resetAllMocks()
resetSshConnectionGenerations()
unregisterSshFilesystemProvider(targetId)
expect(hasSshProviderContinuations(targetId)).toBe(false)
handlers.clear()
mocks.handle.mockImplementation((channel, handler) => handlers.set(channel, handler))
mocks.lstat.mockResolvedValue(fileStat)
mocks.resolveAuthorizedPath.mockImplementation(async (path) => path)
mocks.tryDeleteWslUncPath.mockResolvedValue(false)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the write handlers read no store field on the SSH branch under test.
registerFilesystemWriteHandlers({ store: {} } as FilesystemHandlerContext)
})
describe('SSH filesystem mutation continuation settlement', () => {
it.each(['fs:writeFile', 'fs:deletePath'])(
'%s retains removed providers until settlement',
async (channel) => {
const pending = Promise.withResolvers<void>()
const operation = vi.fn(() => {
expect(hasSshProviderContinuations(targetId)).toBe(true)
return pending.promise
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call.
registerSshFilesystemProvider(targetId, {
writeFile: operation,
deletePath: operation
} as unknown as IFilesystemProvider)
const result = handlers.get(channel)!(null, {
connectionId: targetId,
expectedSshTargetId: targetId,
expectedSshConnectionGeneration: 0,
filePath: '/remote/file',
content: 'text',
targetPath: '/remote/file',
recursive: true
})
unregisterSshFilesystemProvider(targetId)
advanceSshConnectionGeneration(targetId)
expect(hasSshProviderContinuations(targetId)).toBe(true)
expect(hasSshProviderContinuations('other-target')).toBe(false)
pending.resolve()
await result
expect(hasSshProviderContinuations(targetId)).toBe(false)
expect(operation).toHaveBeenCalledWith(
...(channel === 'fs:writeFile' ? ['/remote/file', 'text'] : ['/remote/file', true])
)
}
)
it.each(['fs:writeFile', 'fs:deletePath'])(
'%s releases after provider rejection',
async (channel) => {
const pending = Promise.withResolvers<void>()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call.
registerSshFilesystemProvider(targetId, {
writeFile: () => pending.promise,
deletePath: () => pending.promise
} as unknown as IFilesystemProvider)
const result = handlers.get(channel)!(null, {
connectionId: targetId,
expectedSshTargetId: targetId,
expectedSshConnectionGeneration: 0,
filePath: '/remote/file',
targetPath: '/remote/file',
content: ''
})
const failure = expect(result).rejects.toThrow('late failure')
expect(hasSshProviderContinuations(targetId)).toBe(true)
pending.reject(new Error('late failure'))
await failure
expect(hasSshProviderContinuations(targetId)).toBe(false)
}
)
it.each(['fs:writeFile', 'fs:deletePath'])(
'%s leaves local handling untracked',
async (channel) => {
const pending = Promise.withResolvers<void>()
mocks.writeFile.mockReturnValue(pending.promise)
mocks.trashItem.mockReturnValue(pending.promise)
const result = handlers.get(channel)!(null, {
filePath: '/local/file',
targetPath: '/local/file',
content: 'text'
})
expect(hasSshProviderContinuations(targetId)).toBe(false)
pending.resolve()
await result
expect(channel === 'fs:writeFile' ? mocks.writeFile : mocks.trashItem).toHaveBeenCalled()
expect(hasSshProviderContinuations(targetId)).toBe(false)
}
)
})
describe('complete SSH import continuation settlement', () => {
it('retains a failed directory import through pending rollback', async () => {
const rollback = Promise.withResolvers<void>()
const rollingBack = Promise.withResolvers<void>()
mocks.lstat.mockResolvedValue({ ...fileStat, isDirectory: () => true, isFile: () => false })
vi.mocked(captureLocalUploadRoot).mockResolvedValue('/source/directory')
vi.mocked(uploadSshImportDirectory).mockRejectedValue(new Error('upload failed'))
const close = vi.fn(() => expect(hasSshProviderContinuations(targetId)).toBe(true))
const deletePath = vi.fn(() => {
rollingBack.resolve()
return rollback.promise
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call.
registerSshFilesystemProvider(targetId, {
openFileUploadSession: async () => ({ close }),
stat: async () => {
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
},
createDirNoClobber: async () => {},
deletePath
} as unknown as IFilesystemProvider)
const result = importExternalPathsSsh(['/source/directory'], '/remote', targetId)
await rollingBack.promise
unregisterSshFilesystemProvider(targetId)
expect(hasSshProviderContinuations(targetId)).toBe(true)
expect(close).not.toHaveBeenCalled()
rollback.resolve()
expect((await result).results[0]).toMatchObject({ status: 'failed', reason: 'upload failed' })
expect(deletePath).toHaveBeenCalledWith('/remote/directory', true)
expect(close).toHaveBeenCalledOnce()
expect(hasSshProviderContinuations(targetId)).toBe(false)
})
it('retains tracking across local inspection, provider removal and session close', async () => {
const inspection = Promise.withResolvers<typeof fileStat>()
const inspected = Promise.withResolvers<void>()
const uploaded = Promise.withResolvers<void>()
const upload = Promise.withResolvers<void>()
mocks.lstat.mockImplementation(() => {
inspected.resolve()
return inspection.promise
})
const close = vi.fn(() => expect(hasSshProviderContinuations(targetId)).toBe(true))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call.
registerSshFilesystemProvider(targetId, {
openFileUploadSession: async () => ({
close,
uploadFile: () => {
uploaded.resolve()
return upload.promise
}
}),
stat: async () => {
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
}
} as unknown as IFilesystemProvider)
const result = importExternalPathsSsh(['/source/file'], '/remote', targetId)
expect(hasSshProviderContinuations(targetId)).toBe(true)
await inspected.promise
unregisterSshFilesystemProvider(targetId)
advanceSshConnectionGeneration(targetId)
expect(hasSshProviderContinuations(targetId)).toBe(true)
inspection.resolve(fileStat)
await uploaded.promise
expect(hasSshProviderContinuations(targetId)).toBe(true)
upload.resolve()
expect((await result).results[0].status).toBe('imported')
expect(close).toHaveBeenCalledOnce()
expect(hasSshProviderContinuations(targetId)).toBe(false)
})
it('includes staging preparation and releases on its failure', async () => {
const staging = Promise.withResolvers<void>()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the provider methods these handlers call.
registerSshFilesystemProvider(targetId, {
createDir: () => {
expect(hasSshProviderContinuations(targetId)).toBe(true)
return staging.promise
}
} as unknown as IFilesystemProvider)
const result = importExternalPathsSsh(['/source/file'], '/remote/.orca/drops', targetId, {
ensureDir: true
})
const failure = expect(result).rejects.toThrow('staging failed')
unregisterSshFilesystemProvider(targetId)
expect(hasSshProviderContinuations(targetId)).toBe(true)
staging.reject(new Error('staging failed'))
await failure
expect(hasSshProviderContinuations(targetId)).toBe(false)
})
})
@@ -2,7 +2,6 @@ import { ipcMain, shell } from 'electron'
import { lstat, writeFile } from 'node:fs/promises'
import type { SshMutationExpectation } from '../../../shared/ssh-types'
import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation'
import { runSshProviderContinuation } from '../../ssh/ssh-provider-continuations'
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { tryDeleteWslUncPath } from '../../wsl-unc-delete'
import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth'
@@ -27,9 +26,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return runSshProviderContinuation(args.connectionId, () =>
provider.writeFile(args.filePath, args.content)
)
return provider.writeFile(args.filePath, args.content)
}
const filePath = await resolveAuthorizedPath(args.filePath, store)
try {
@@ -64,9 +61,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return runSshProviderContinuation(args.connectionId, () =>
provider.deletePath(args.targetPath, args.recursive)
)
return provider.deletePath(args.targetPath, args.recursive)
}
// Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots).
const targetPath = await resolveAuthorizedPath(args.targetPath, store, {
@@ -1,66 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
handle: vi.fn(),
deploy: vi.fn(),
status: vi.fn(),
registerProvisioning: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
vi.mock('../ssh/orcad-runtime-lifecycle', () => ({
createManagedOrcadEnvironment: mocks.deploy,
getManagedOrcadRuntimeStatus: mocks.status
}))
vi.mock('./orcad-ssh-provisioning-handlers', () => ({
registerOrcadSshProvisioningHandlers: mocks.registerProvisioning
}))
const { registerOrcadRuntimeLifecycleHandlers } = await import('./orcad-runtime-lifecycle-handlers')
function handler(channel: string): (_event: unknown, args: unknown) => unknown {
const registration = mocks.handle.mock.calls.find(([name]) => name === channel)
if (!registration) {
throw new Error(`${channel} handler was not registered`)
}
return registration[1]
}
describe('managed orcad lifecycle IPC', () => {
beforeEach(() => {
vi.clearAllMocks()
registerOrcadRuntimeLifecycleHandlers({ getUserDataPath: () => '/profile' })
})
it('registers only deploy, status and provisioning; maintenance and stop are not exposed', () => {
expect(mocks.handle.mock.calls.map(([channel]) => channel)).toEqual([
'runtimeEnvironments:deployOrcad',
'runtimeEnvironments:getOrcadStatus'
])
expect(mocks.registerProvisioning).toHaveBeenCalledOnce()
})
it('trims deploy input and treats only a literal true as force', async () => {
await handler('runtimeEnvironments:deployOrcad')(null, {
name: ' Managed ',
sshTargetId: ' ssh-1 ',
force: 'yes'
})
expect(mocks.deploy).toHaveBeenCalledWith('/profile', {
name: 'Managed',
sshTargetId: 'ssh-1',
force: false
})
})
it('rejects missing selectors before touching SSH', async () => {
await expect(handler('runtimeEnvironments:deployOrcad')(null, { name: 'x' })).rejects.toThrow(
'SSH target is required'
)
expect(() => handler('runtimeEnvironments:getOrcadStatus')(null, undefined)).toThrow(
'Server is required'
)
expect(mocks.deploy).not.toHaveBeenCalled()
expect(mocks.status).not.toHaveBeenCalled()
})
})
@@ -1,37 +0,0 @@
import { ipcMain } from 'electron'
import type { OrcadManagedRuntimeStatus } from '../../shared/orcad-managed-runtime'
import {
createManagedOrcadEnvironment,
getManagedOrcadRuntimeStatus
} from '../ssh/orcad-runtime-lifecycle'
import { registerOrcadSshProvisioningHandlers } from './orcad-ssh-provisioning-handlers'
export function registerOrcadRuntimeLifecycleHandlers(options: {
getUserDataPath: () => string
}): void {
registerOrcadSshProvisioningHandlers(options.getUserDataPath)
ipcMain.handle(
'runtimeEnvironments:deployOrcad',
async (_event, args: { name: string; sshTargetId: string; force?: boolean }) =>
createManagedOrcadEnvironment(options.getUserDataPath(), {
name: requiredString(args?.name, 'Server name'),
sshTargetId: requiredString(args?.sshTargetId, 'SSH target'),
force: args?.force === true
})
)
ipcMain.handle(
'runtimeEnvironments:getOrcadStatus',
(_event, args: { selector: string }): Promise<OrcadManagedRuntimeStatus> =>
getManagedOrcadRuntimeStatus(
options.getUserDataPath(),
requiredString(args?.selector, 'Server')
)
)
}
export function requiredString(value: unknown, label: string): string {
if (typeof value !== 'string' || !value.trim()) {
throw new Error(`${label} is required.`)
}
return value.trim()
}
@@ -1,87 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
handle: vi.fn(),
update: vi.fn(),
rollback: vi.fn(),
recover: vi.fn(),
stop: vi.fn(),
cancel: vi.fn(),
retire: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
vi.mock('../ssh/orcad-runtime-lifecycle', () => ({
updateManagedOrcadEnvironment: mocks.update,
rollbackManagedOrcadEnvironment: mocks.rollback,
recoverManagedOrcadEnvironment: mocks.recover,
stopManagedOrcadEnvironment: mocks.stop,
cancelManagedOrcadStop: mocks.cancel
}))
vi.mock('./runtime-environment-removal-cleanup', () => ({
retireRemovedRuntimeEnvironment: mocks.retire
}))
const { registerOrcadRuntimeMaintenanceHandlers } =
await import('./orcad-runtime-maintenance-handlers')
const invalidateTransport = vi.fn()
function handler(channel: string): (_event: unknown, args: unknown) => Promise<unknown> {
const registration = mocks.handle.mock.calls.find(([name]) => name === channel)
if (!registration) {
throw new Error(`${channel} handler was not registered`)
}
return registration[1]
}
describe('managed orcad maintenance IPC', () => {
beforeEach(() => {
vi.clearAllMocks()
registerOrcadRuntimeMaintenanceHandlers({
getUserDataPath: () => '/profile',
getActiveEnvironmentId: () => 'active-environment',
invalidateTransport
})
})
it('reconnects after an update restarts orcad, but not after a deferral', async () => {
mocks.update.mockResolvedValueOnce({ outcome: 'deferred', code: 'busy' })
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: 'yes' })
expect(mocks.update).toHaveBeenCalledWith('/profile', { selector: 'Managed', force: false })
expect(invalidateTransport).not.toHaveBeenCalled()
mocks.update.mockResolvedValueOnce({ outcome: 'updated', environment: { id: 'e-1' } })
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: true })
expect(invalidateTransport).toHaveBeenCalledWith('e-1')
})
it('reconnects after rollback and after recovery restores a serving slot', async () => {
mocks.rollback.mockResolvedValueOnce({ outcome: 'rolled-back', environment: { id: 'e-1' } })
await handler('runtimeEnvironments:rollbackOrcad')(null, { selector: 'Managed' })
mocks.recover.mockResolvedValueOnce({
outcome: 'recovered',
activeVersion: null,
environment: { id: 'e-1' }
})
await handler('runtimeEnvironments:recoverOrcad')(null, { selector: 'Managed' })
expect(invalidateTransport).toHaveBeenCalledTimes(1)
})
it('stops with the Active Server guard and the shared removal cleanup', async () => {
mocks.stop.mockResolvedValueOnce({ outcome: 'unlinked' })
await handler('runtimeEnvironments:stopOrcad')(null, { selector: ' Managed ' })
const [, args, policy] = mocks.stop.mock.calls[0] ?? []
expect(args).toEqual({ selector: 'Managed' })
expect(policy.isActiveEnvironment('active-environment')).toBe(true)
expect(policy.isActiveEnvironment('e-1')).toBe(false)
policy.retireLocalState('e-1')
expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport)
})
it('rejects a missing selector before touching SSH', async () => {
await expect(handler('runtimeEnvironments:cancelOrcadStop')(null, {})).rejects.toThrow(
'Server is required'
)
expect(mocks.cancel).not.toHaveBeenCalled()
})
})
@@ -1,86 +0,0 @@
import { ipcMain } from 'electron'
import type {
OrcadManagedCancelStopResult,
OrcadManagedDeployResult,
OrcadManagedRecoveryResult,
OrcadManagedRollbackResult,
OrcadManagedStopResult
} from '../../shared/orcad-managed-runtime'
import {
cancelManagedOrcadStop,
recoverManagedOrcadEnvironment,
rollbackManagedOrcadEnvironment,
stopManagedOrcadEnvironment,
updateManagedOrcadEnvironment
} from '../ssh/orcad-runtime-lifecycle'
import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup'
import { requiredString } from './orcad-runtime-lifecycle-handlers'
export function registerOrcadRuntimeMaintenanceHandlers(options: {
getUserDataPath: () => string
getActiveEnvironmentId: () => string | null | undefined
invalidateTransport: (environmentId: string) => Promise<void> | void
}): void {
ipcMain.handle(
'runtimeEnvironments:updateOrcad',
async (
_event,
args: { selector: string; force?: boolean }
): Promise<OrcadManagedDeployResult> => {
const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), {
selector: requiredString(args?.selector, 'Server'),
force: args?.force === true
})
// Why: a restarted orcad drops the old connection; reconnect on the new one.
if (result.outcome === 'updated') {
await options.invalidateTransport(result.environment.id)
}
return result
}
)
ipcMain.handle(
'runtimeEnvironments:rollbackOrcad',
async (_event, args: { selector: string }): Promise<OrcadManagedRollbackResult> => {
const result = await rollbackManagedOrcadEnvironment(options.getUserDataPath(), {
selector: requiredString(args?.selector, 'Server')
})
if (result.outcome === 'rolled-back') {
await options.invalidateTransport(result.environment.id)
}
return result
}
)
ipcMain.handle(
'runtimeEnvironments:recoverOrcad',
async (_event, args: { selector: string }): Promise<OrcadManagedRecoveryResult> => {
const result = await recoverManagedOrcadEnvironment(options.getUserDataPath(), {
selector: requiredString(args?.selector, 'Server')
})
if (result.outcome === 'recovered' && result.activeVersion) {
await options.invalidateTransport(result.environment.id)
}
return result
}
)
ipcMain.handle(
'runtimeEnvironments:stopOrcad',
async (_event, args: { selector: string }): Promise<OrcadManagedStopResult> =>
stopManagedOrcadEnvironment(
options.getUserDataPath(),
{ selector: requiredString(args?.selector, 'Server') },
{
isActiveEnvironment: (environmentId) =>
options.getActiveEnvironmentId() === environmentId,
retireLocalState: (environmentId) =>
retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport)
}
)
)
ipcMain.handle(
'runtimeEnvironments:cancelOrcadStop',
async (_event, args: { selector: string }): Promise<OrcadManagedCancelStopResult> =>
cancelManagedOrcadStop(options.getUserDataPath(), {
selector: requiredString(args?.selector, 'Server')
})
)
}
@@ -1,40 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
handle: vi.fn(),
create: vi.fn(),
resume: vi.fn(),
list: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
vi.mock('../ssh/orcad-ssh-provisioning', () => ({
createOrcadSshHost: mocks.create,
resumeOrcadSshHost: mocks.resume,
listPendingOrcadSshProvisioning: mocks.list
}))
import { registerOrcadSshProvisioningHandlers } from './orcad-ssh-provisioning-handlers'
describe('managed SSH provisioning IPC', () => {
beforeEach(() => vi.clearAllMocks())
it('registers typed create, resume and pending discovery without changing legacy SSH channels', async () => {
registerOrcadSshProvisioningHandlers(() => '/active-profile')
const handlers = new Map(mocks.handle.mock.calls.map(([name, handler]) => [name, handler]))
expect([...handlers.keys()]).toEqual([
'runtimeEnvironments:createOrcadSshHost',
'runtimeEnvironments:resumeOrcadSshHost',
'runtimeEnvironments:listPendingOrcadSshProvisioning'
])
const request = { requestId: 'request-1', name: 'host', target: { host: 'builder' } }
const pending = { result: { outcome: 'pending', reason: 'unverifiable' } }
mocks.create.mockResolvedValue(pending)
expect(await handlers.get('runtimeEnvironments:createOrcadSshHost')!(null, request)).toBe(
pending
)
expect(mocks.create).toHaveBeenCalledWith('/active-profile', request)
await handlers.get('runtimeEnvironments:resumeOrcadSshHost')!(null, { requestId: 'request-1' })
expect(mocks.resume).toHaveBeenCalledWith('/active-profile', 'request-1')
handlers.get('runtimeEnvironments:listPendingOrcadSshProvisioning')!()
expect(mocks.list).toHaveBeenCalledWith('/active-profile')
})
})
@@ -1,20 +0,0 @@
import { ipcMain } from 'electron'
import type { OrcadSshProvisioningRequest } from '../../shared/orcad-ssh-provisioning'
import {
createOrcadSshHost,
listPendingOrcadSshProvisioning,
resumeOrcadSshHost
} from '../ssh/orcad-ssh-provisioning'
export function registerOrcadSshProvisioningHandlers(getUserDataPath: () => string): void {
ipcMain.handle(
'runtimeEnvironments:createOrcadSshHost',
(_event, args: OrcadSshProvisioningRequest) => createOrcadSshHost(getUserDataPath(), args)
)
ipcMain.handle('runtimeEnvironments:resumeOrcadSshHost', (_event, args: { requestId: string }) =>
resumeOrcadSshHost(getUserDataPath(), args?.requestId)
)
ipcMain.handle('runtimeEnvironments:listPendingOrcadSshProvisioning', () =>
listPendingOrcadSshProvisioning(getUserDataPath())
)
}
@@ -10,13 +10,6 @@ export const WATCHER_PROCESS_HARD_KILL_DELAY_MS = 5_000
export const WATCHER_PROCESS_EXIT_DEADLINE_MS = RUNTIME_FILE_WATCH_EXIT_DEADLINE_MS
const physicalExitPromises = new WeakMap<ChildProcess, Promise<void>>()
const signalledChildren = new WeakSet<ChildProcess>()
/** Sends the graceful signal once; a later awaited termination only escalates and waits. */
export function signalWatcherChild(child: ChildProcess): void {
signalledChildren.add(child)
child.kill()
}
export function registerWatcherChildPhysicalExit(child: ChildProcess): () => void {
let resolveExit: () => void = () => undefined
@@ -93,10 +86,6 @@ export function terminateWatcherChild(child: ChildProcess): Promise<boolean> {
hardKillTimer.unref?.()
const exitDeadlineTimer = setTimeout(() => finish(false), WATCHER_PROCESS_EXIT_DEADLINE_MS)
exitDeadlineTimer.unref?.()
if (signalledChildren.has(child)) {
return
}
signalledChildren.add(child)
try {
child.kill()
} catch {
@@ -105,10 +94,11 @@ export function terminateWatcherChild(child: ChildProcess): Promise<boolean> {
})
}
export function watcherChildPhysicalExit(child: ChildProcess): Promise<void> {
return child.exitCode !== null || child.signalCode !== null
? Promise.resolve()
: (physicalExitPromises.get(child) ??
export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure {
const physicalExit =
child.exitCode !== null || child.signalCode !== null
? Promise.resolve()
: (physicalExitPromises.get(child) ??
new Promise<void>((resolve) => {
const finish = (): void => {
child.removeListener('exit', finish)
@@ -118,14 +108,11 @@ export function watcherChildPhysicalExit(child: ChildProcess): Promise<void> {
child.once('exit', finish)
child.once('close', finish)
}))
}
export function createWatcherChildTerminationFailure(child: ChildProcess): WatcherProcessFailure {
return new WatcherProcessFailure(
'file watcher process did not exit after termination deadline',
'supervisor',
'process_unavailable',
watcherChildPhysicalExit(child)
physicalExit
)
}
@@ -140,12 +127,10 @@ export async function terminateIdleWatcherChild(
pendingUnsubscribes: Map<number, PendingWatcherUnsubscribe>,
onFinished: (exited: boolean) => void
): Promise<void> {
// Windows directory handles require physical exit, not merely an accepted signal.
try {
await requireWatcherChildTermination(child)
onFinished(true)
} catch (error) {
// Idle children retain capacity but cannot double-watch; the owner may remain reusable.
onFinished(false)
resolvePendingWatcherUnsubscribes(
pendingUnsubscribes,
@@ -4,14 +4,6 @@ import { join } from 'node:path'
type ElectronAppPath = { getAppPath(): string; isPackaged(): boolean }
export function watcherProcessEntryExists(entryPath: string): boolean {
if (existsSync(entryPath)) {
return true
}
console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`)
return false
}
// Why the port and not require('electron'): this module is reachable from plain-Node
// fork entries, where the literal text require("electron") fails the build guard even
// inside a try/catch. hasAppEnvironment() gives the same "no app root here" answer.
@@ -1,120 +0,0 @@
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { afterEach, expect, it, vi } from 'vitest'
import { WatcherOwnedChildren } from './parcel-watcher-owned-children'
import {
registerWatcherChildPhysicalExit,
signalWatcherChild,
WATCHER_PROCESS_EXIT_DEADLINE_MS,
WATCHER_PROCESS_HARD_KILL_DELAY_MS
} from './parcel-watcher-child-termination'
afterEach(() => vi.useRealTimers())
function child() {
const exitState: { exitCode: number | null; signalCode: NodeJS.Signals | null } = {
exitCode: null,
signalCode: null
}
const events = Object.assign(new EventEmitter(), exitState, { kill: vi.fn(() => true) })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: termination reads only the exit fields, kill and events stubbed here.
const process = events as unknown as ChildProcess
const physicalExit = registerWatcherChildPhysicalExit(process)
events.on('exit', physicalExit)
events.on('close', physicalExit)
events.on('error', () => {})
return { process, events, close: () => events.emit('close') }
}
it('joins disposal and does not confuse disconnect/error with physical exit', async () => {
const owner = new WatcherOwnedChildren()
const c = child()
owner.track(c.process)
const logical = vi.fn()
const disposed = vi.fn()
const first = owner.disposeAndWait(logical)
expect(owner.disposeAndWait(logical)).toBe(first)
const result = first.then(disposed)
c.events.emit('disconnect')
c.events.emit('error', new Error('spawn or IPC failure'))
await Promise.resolve()
expect(disposed).not.toHaveBeenCalled()
expect(logical).toHaveBeenCalledOnce()
c.close()
await result
expect(disposed).toHaveBeenCalledOnce()
})
it('includes children already signaled by synchronous or retired-owner disposal', async () => {
const owner = new WatcherOwnedChildren()
const old = child()
const replacement = child()
owner.track(old.process)
old.process.kill()
owner.track(replacement.process)
const disposed = vi.fn()
const result = owner.disposeAndWait(() => {}).then(disposed)
replacement.close()
await Promise.resolve()
expect(disposed).not.toHaveBeenCalled()
old.close()
await result
})
it('retains a child after termination deadline failure and supports explicit retry', async () => {
vi.useFakeTimers()
const owner = new WatcherOwnedChildren()
const c = child()
owner.track(c.process)
const result = owner.disposeAndWait(() => {}).catch((error: unknown) => error)
await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS)
expect(await result).toMatchObject({ message: 'watcher_owned_children_shutdown_incomplete' })
const completed = vi.fn()
const retry = owner.disposeAndWait(() => {}).then(completed)
await Promise.resolve()
expect(completed).not.toHaveBeenCalled()
c.close()
await retry
})
it('waits for other children even when logical disposal and one kill fail', async () => {
const owner = new WatcherOwnedChildren()
const failed = child()
const pending = child()
owner.track(failed.process)
owner.track(pending.process)
failed.events.kill.mockImplementationOnce(() => {
throw new Error('kill failed')
})
const logicalFailure = new Error('logical cleanup failed')
const finished = vi.fn()
const result = owner
.disposeAndWait(() => {
throw logicalFailure
})
.catch((error: unknown) => {
finished()
return error
})
await Promise.resolve()
expect(finished).not.toHaveBeenCalled()
pending.close()
expect(await result).toMatchObject({ errors: [logicalFailure, expect.any(Error)] })
const retry = owner.disposeAndWait(() => {})
failed.close()
await retry
})
it('skips a second graceful signal after the supervisor sent one but still escalates', async () => {
vi.useFakeTimers()
const owner = new WatcherOwnedChildren()
const c = child()
owner.track(c.process)
const disposal = owner.disposeAndWait(() => signalWatcherChild(c.process))
expect(c.events.kill).toHaveBeenCalledTimes(1)
expect(c.events.kill).toHaveBeenCalledWith()
await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_HARD_KILL_DELAY_MS)
expect(c.events.kill).toHaveBeenLastCalledWith('SIGKILL')
c.events.emit('exit', null, 'SIGKILL')
await expect(disposal).resolves.toBeUndefined()
})
@@ -1,47 +0,0 @@
import type { ChildProcessHandle } from '../../shared/child-process/process-spec'
import {
watcherChildPhysicalExit,
requireWatcherChildTermination
} from './parcel-watcher-child-termination'
export class WatcherOwnedChildren {
private readonly children = new Set<ChildProcessHandle>()
private disposal: Promise<void> | null = null
track(child: ChildProcessHandle): ChildProcessHandle {
this.children.add(child)
// Reuse launch-owned physical-exit evidence, including close without exit after spawn failure.
void watcherChildPhysicalExit(child).then(() => {
this.children.delete(child)
})
return child
}
disposeAndWait(disposeLogicalOwner: () => void): Promise<void> {
if (this.disposal) {
return this.disposal
}
const failures: unknown[] = []
try {
disposeLogicalOwner()
} catch (error) {
failures.push(error)
}
const cleanup = Promise.allSettled([...this.children].map(requireWatcherChildTermination))
.then((results) => {
for (const result of results) {
if (result.status === 'rejected') {
failures.push(result.reason)
}
}
if (failures.length > 0) {
throw new AggregateError(failures, 'watcher_owned_children_shutdown_incomplete')
}
})
.finally(() => {
this.disposal = null
})
this.disposal = cleanup
return cleanup
}
}
@@ -1,48 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
const { supervisorDispose, poolDispose } = vi.hoisted(() => ({
supervisorDispose: vi.fn<() => Promise<void>>(),
poolDispose: vi.fn<() => Promise<void>>()
}))
vi.mock('./parcel-watcher-process-supervisor', () => ({
WatcherProcessSupervisor: class {
disposeAndWait = supervisorDispose
}
}))
vi.mock('./runtime-watcher-process-pool', () => ({
RuntimeWatcherProcessPool: class {
disposeAndWait = poolDispose
}
}))
import { disposeWatcherProcessAndWait } from './parcel-watcher-process'
describe('disposeWatcherProcessAndWait', () => {
it('waits for both watcher hosts, and reports a failed one only after the other settles', async () => {
let finishPool!: () => void
supervisorDispose.mockRejectedValueOnce(new Error('child still running'))
poolDispose.mockReturnValueOnce(
new Promise<void>((resolve) => {
finishPool = resolve
})
)
const settled = vi.fn()
const disposal = disposeWatcherProcessAndWait().catch((error: unknown) => {
settled()
throw error
})
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
finishPool()
await expect(disposal).rejects.toMatchObject({ message: 'watcher_process_shutdown_incomplete' })
})
it('resolves once every owned child has exited', async () => {
supervisorDispose.mockResolvedValueOnce()
poolDispose.mockResolvedValueOnce()
await expect(disposeWatcherProcessAndWait()).resolves.toBeUndefined()
expect(supervisorDispose).toHaveBeenCalled()
expect(poolDispose).toHaveBeenCalled()
})
})
@@ -86,13 +86,8 @@ describe('parcel watcher process canary', () => {
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith(
expect.objectContaining({ op: 'subscribe-failed', id: 7 }),
expect.any(Function)
expect.objectContaining({ op: 'subscribe-failed', id: 7 })
)
const failedSend = sendMock.mock.calls.find(([message]) => message.op === 'subscribe-failed')
expect(() =>
failedSend![1](Object.assign(new Error('host disconnected'), { code: 'EPIPE' }))
).not.toThrow()
expect(watchMock).not.toHaveBeenCalledWith('/repo/.git', expect.anything(), expect.anything())
})
@@ -318,13 +313,10 @@ describe('parcel watcher process canary', () => {
finishActiveCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) })
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 }, expect.any(Function))
expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 })
expect(subscribeMock).toHaveBeenCalledTimes(2)
expect(sendMock).not.toHaveBeenCalledWith(
{ op: 'subscribe-started', id: 2 },
expect.any(Function)
)
expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 2 }, expect.any(Function))
expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribe-started', id: 2 })
expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 2 })
})
it('unsubscribes a late cancel after the crawl already finished', async () => {
@@ -340,16 +332,13 @@ describe('parcel watcher process canary', () => {
process.emit('message', { op: 'subscribe', id: 1, dir: '/finished', opts: {} })
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 }, expect.any(Function))
expect(sendMock).toHaveBeenCalledWith({ op: 'subscribed', id: 1 })
process.emit('message', { op: 'cancel-subscribe', id: 1 })
await vi.advanceTimersByTimeAsync(0)
expect(unsubscribe).toHaveBeenCalledTimes(1)
expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }, expect.any(Function))
expect(sendMock).not.toHaveBeenCalledWith(
{ op: 'cancel-requires-restart', id: 1 },
expect.any(Function)
)
expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 })
expect(sendMock).not.toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 })
})
it('reports native unsubscribe rejection without acknowledging handle release', async () => {
@@ -367,15 +356,12 @@ describe('parcel watcher process canary', () => {
process.emit('message', { op: 'unsubscribe', id: 1 })
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith(
{
op: 'unsubscribe-failed',
id: 1,
message: 'native handle still active'
},
expect.any(Function)
)
expect(sendMock).not.toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 }, expect.any(Function))
expect(sendMock).toHaveBeenCalledWith({
op: 'unsubscribe-failed',
id: 1,
message: 'native handle still active'
})
expect(sendMock).not.toHaveBeenCalledWith({ op: 'unsubscribed', id: 1 })
})
it('asks the host to restart when an active crawl is cancelled', async () => {
@@ -394,13 +380,10 @@ describe('parcel watcher process canary', () => {
await vi.advanceTimersByTimeAsync(0)
process.emit('message', { op: 'cancel-subscribe', id: 1 })
expect(sendMock).toHaveBeenCalledWith(
{ op: 'cancel-requires-restart', id: 1 },
expect.any(Function)
)
expect(sendMock).toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 })
finishCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) })
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 1 }, expect.any(Function))
expect(sendMock).not.toHaveBeenCalledWith({ op: 'subscribed', id: 1 })
})
it('still restarts after consecutive missed events once every subscription is live', async () => {
@@ -498,14 +481,11 @@ describe('parcel watcher process canary', () => {
callback?.(null, [{ type: 'update', path: '/repo/after-overflow.txt' }])
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith(
{
op: 'watch-error',
id: 1,
message: 'Events were dropped by the FSEvents client. File system must be re-scanned.'
},
expect.any(Function)
)
expect(sendMock).toHaveBeenCalledWith({
op: 'watch-error',
id: 1,
message: 'Events were dropped by the FSEvents client. File system must be re-scanned.'
})
expect(sendMock).toHaveBeenCalledWith(
{
op: 'events',
+1 -1
View File
@@ -118,7 +118,7 @@ async function startCanary(getStableActivityRevision: () => number | null): Prom
function main(): void {
const send = (message: WatcherToHostMessage): void => {
try {
process.send?.(message, () => undefined)
process.send?.(message)
} catch {
// Host is gone; the disconnect handler below exits this process.
}
@@ -1,7 +1,8 @@
import type { ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { restartCancelledWatcherChild } from './parcel-watcher-cancellation-restart'
import { WatcherCancellationTracker } from './parcel-watcher-cancellation-tracker'
import { getWatcherProcessEntryPath, watcherProcessEntryExists } from './parcel-watcher-entry-path'
import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path'
import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory'
import * as termination from './parcel-watcher-child-termination'
import { launchWatcherChild } from './parcel-watcher-child-launch'
@@ -38,7 +39,6 @@ import {
} from './parcel-watcher-supervisor-subscribe'
import { disposeWatcherSupervisor } from './parcel-watcher-supervisor-disposal'
import { handleWatcherSupervisorMessage } from './parcel-watcher-supervisor-message'
import { WatcherOwnedChildren } from './parcel-watcher-owned-children'
export class WatcherProcessSupervisor {
private child: ChildProcess | null = null
@@ -52,7 +52,6 @@ export class WatcherProcessSupervisor {
private readonly pendingUnsubscribes = new Map<number, PendingWatcherUnsubscribe>()
private readonly cancelledSubscribes = new WatcherCancellationTracker()
private readonly capacityWait = new WatcherSupervisorCapacityWait()
private ownedChildren = new WatcherOwnedChildren()
constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {}
@@ -108,7 +107,6 @@ export class WatcherProcessSupervisor {
resetForTest(): void {
this.dispose()
this.ownedChildren = new WatcherOwnedChildren()
this.shutdownRequested = false
this.terminatingChild = null
this.terminationQueue.resetForTest()
@@ -116,8 +114,6 @@ export class WatcherProcessSupervisor {
resetWatcherChildRegistryForTest()
}
disposeAndWait = (): Promise<void> => this.ownedChildren.disposeAndWait(() => this.dispose())
private ensureWatcherProcess(
entryPath = this.options.entryPath ?? getWatcherProcessEntryPath()
): ChildProcess | null {
@@ -130,7 +126,8 @@ export class WatcherProcessSupervisor {
if (this.crashFuse.isOpen()) {
return null
}
if (!watcherProcessEntryExists(entryPath)) {
if (!existsSync(entryPath)) {
console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`)
return null
}
const launched = launchWatcherChild(
@@ -148,7 +145,7 @@ export class WatcherProcessSupervisor {
return null
}
this.canaryDir = launched.canaryDir
this.child = this.ownedChildren.track(launched.child)
this.child = launched.child
return launched.child
}
@@ -254,9 +251,14 @@ export class WatcherProcessSupervisor {
}
this.child = null
this.terminatingChild = proc
// Why: destructive Windows cleanup must await exit to release directory handles.
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
return this.terminationQueue.track(
termination.terminateIdleWatcherChild(proc, this.pendingUnsubscribes, () => {
// Why: an idle child owns zero records, so a missed exit deadline has no
// double-watch hazard; the child keeps its capacity reservation until
// physical exit, and poisoning this supervisor would permanently end
// local watching — the shared singleton has no retire-and-replace path.
this.terminatingChild = null
})
)
-14
View File
@@ -62,20 +62,6 @@ export function disposeWatcherProcess(): void {
}
}
/** Dispose both watcher hosts and wait for every child they own to exit. */
export async function disposeWatcherProcessAndWait(): Promise<void> {
const results = await Promise.allSettled([
sharedWatcherProcessSupervisor.disposeAndWait(),
runtimeWatcherProcessPool.disposeAndWait()
])
const failures = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
)
if (failures.length > 0) {
throw new AggregateError(failures, 'watcher_process_shutdown_incomplete')
}
}
export function resetWatcherProcessForTest(): void {
sharedWatcherProcessSupervisor.resetForTest()
}
@@ -1,67 +0,0 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { acknowledgeWatcherSubscribe, FakeWatcherChild } from './parcel-watcher-process-test-child'
import { resetWatcherChildRegistryForTest } from './parcel-watcher-child-registry'
const { forkMock } = vi.hoisted(() => ({ forkMock: vi.fn() }))
vi.mock('node:child_process', () => ({ fork: forkMock }))
vi.mock('node:fs', () => ({
existsSync: vi.fn(() => true),
mkdtempSync: vi.fn(() => '/tmp/orca-watcher-disposal-test'),
rmSync: vi.fn()
}))
import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor'
const children: FakeWatcherChild[] = []
beforeEach(() => {
resetWatcherChildRegistryForTest()
forkMock.mockImplementation(() => {
const child = new FakeWatcherChild()
children.push(child)
return child
})
})
afterEach(() => {
for (const child of children.splice(0)) {
child.emit('close')
}
vi.clearAllMocks()
resetWatcherChildRegistryForTest()
})
async function subscribe(supervisor: WatcherProcessSupervisor): Promise<FakeWatcherChild> {
const pending = supervisor.subscribe('/repo', vi.fn(), {})
const child = children.at(-1)!
acknowledgeWatcherSubscribe(child)
await pending
return child
}
it('awaited supervisor disposal retains a child after prior synchronous disposal', async () => {
const supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false })
const child = await subscribe(supervisor)
supervisor.dispose()
const finished = vi.fn()
const shutdown = supervisor.disposeAndWait().then(finished)
await new Promise((resolve) => setImmediate(resolve))
expect(finished).not.toHaveBeenCalled()
await expect(supervisor.subscribe('/another', vi.fn(), {})).rejects.toThrow()
expect(forkMock).toHaveBeenCalledOnce()
child.emit('close')
await shutdown
expect(finished).toHaveBeenCalledOnce()
})
it('test reset isolates old physical-exit callbacks from the new lifetime owner', async () => {
const supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false })
const old = await subscribe(supervisor)
const oldShutdown = supervisor.disposeAndWait()
supervisor.resetForTest()
const current = await subscribe(supervisor)
const finished = vi.fn()
const shutdown = supervisor.disposeAndWait().then(finished)
old.emit('close')
await oldShutdown
expect(finished).not.toHaveBeenCalled()
current.emit('close')
await shutdown
})
@@ -9,7 +9,6 @@ import {
resetPendingSubscribeAttempt,
takePendingSubscribe
} from './parcel-watcher-pending-subscribe'
import { signalWatcherChild } from './parcel-watcher-child-termination'
import { watcherHostFailure } from './parcel-watcher-process-failure'
import type { WatcherProcessSubscriptionRecord } from './parcel-watcher-process-subscription'
@@ -28,8 +27,6 @@ export function disposeWatcherSupervisor(
resolvePendingWatcherUnsubscribes(pendingUnsubscribes)
cancelledSubscribes.completeAll()
records.clear()
if (child) {
signalWatcherChild(child)
}
child?.kill()
return removeWatcherCanaryDirectory(canaryDir)
}
@@ -64,10 +64,6 @@ export function advanceRuntimeEnvironmentCapabilityIncarnation(environmentId: st
state.accepted = null
}
export function getRuntimeEnvironmentCapabilityIncarnation(environmentId: string): number {
return stateFor(environmentId).epoch
}
export function applyRuntimeEnvironmentCapabilityVerdict(args: {
evidence: RuntimeEnvironmentCapabilityEvidence
verdict: RuntimeEnvironmentCapabilityVerdict
@@ -14,8 +14,10 @@ import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call-
import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { Store } from '../persistence'
import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup'
import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime'
import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement'
import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification'
import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence'
import {
closeRemoteRuntimeRequestConnection,
getRuntimeEnvironmentStatusOwner,
@@ -104,8 +106,22 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
throw new Error('Choose another Active Server in Advanced before removing this server.')
}
const removed = removeEnvironment(getUserDataPath(), args.selector)
void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport)
clearRuntimeEnvironmentCapabilityEvidence(removed.id)
clearRuntimeEnvironmentManualDisconnect(removed.id)
const retiring = Promise.resolve(invalidateTransport(removed.id))
closeLegacySelectorTransport(args.selector, removed.id)
// Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes
// too -- but only once the client host releases its partitions, or every one refuses as live.
void retireBrowserRoutePartitionStorageForEnvironment({
environmentId: removed.id,
whenClientHostClosed: retiring,
clearStorage: clearBrowserRoutePartitionStorageForEnvironment,
onError: (error) => {
console.warn('[runtime-environments] browser partition storage clear failed:', error)
}
}).catch((error) => {
console.warn('[runtime-environments] browser partition storage clear failed:', error)
})
return { removed: redactRuntimeEnvironment(removed) }
}
)
@@ -12,17 +12,5 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
'runtimeEnvironments:getStatusSnapshots',
'runtimeEnvironments:call',
'runtimeEnvironments:subscribe',
'runtimeEnvironments:unsubscribe',
'runtimeEnvironments:linkSshAccess',
'runtimeEnvironments:unlinkSshAccess',
'runtimeEnvironments:deployOrcad',
'runtimeEnvironments:getOrcadStatus',
'runtimeEnvironments:updateOrcad',
'runtimeEnvironments:rollbackOrcad',
'runtimeEnvironments:recoverOrcad',
'runtimeEnvironments:stopOrcad',
'runtimeEnvironments:cancelOrcadStop',
'runtimeEnvironments:createOrcadSshHost',
'runtimeEnvironments:resumeOrcadSshHost',
'runtimeEnvironments:listPendingOrcadSshProvisioning'
'runtimeEnvironments:unsubscribe'
] as const
@@ -1,11 +0,0 @@
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import { ensureOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel'
export async function resolveManagedRuntimeEnvironment(
userDataPath: string,
selector: string
): Promise<ReturnType<typeof resolveEnvironment>> {
const environment = resolveEnvironment(userDataPath, selector)
await ensureOrcadManagedTunnel(userDataPath, environment.id)
return resolveEnvironment(userDataPath, environment.id)
}
@@ -1,27 +0,0 @@
import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime'
import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement'
import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence'
import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect'
/** Retires a removed server's client-side state; resolves once its transport is invalidated. */
export function retireRemovedRuntimeEnvironment(
environmentId: string,
invalidateTransport: (environmentId: string) => Promise<void> | void
): Promise<void> {
clearRuntimeEnvironmentCapabilityEvidence(environmentId)
clearRuntimeEnvironmentManualDisconnect(environmentId)
const retiring = Promise.resolve(invalidateTransport(environmentId))
// Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes
// too -- but only once the client host releases its partitions, or every one refuses as live.
void retireBrowserRoutePartitionStorageForEnvironment({
environmentId,
whenClientHostClosed: retiring,
clearStorage: clearBrowserRoutePartitionStorageForEnvironment,
onError: (error) => {
console.warn('[runtime-environments] browser partition storage clear failed:', error)
}
}).catch((error) => {
console.warn('[runtime-environments] browser partition storage clear failed:', error)
})
return retiring
}
@@ -15,14 +15,6 @@ export function runtimeEnvironmentRevisionFailure(
if (!pairingChanged && !runtimeChanged) {
return null
}
return runtimeEnvironmentChangedFailure(environment, method, pairingChanged)
}
export function runtimeEnvironmentChangedFailure(
environment: Pick<KnownRuntimeEnvironment, 'runtimeId'>,
method: string,
pairingChanged = false
): RuntimeRpcResponse<never> {
return {
id: method,
ok: false,
@@ -8,7 +8,6 @@ import {
} from '../../shared/runtime-environments'
import { recordRuntimeEnvironmentUsage } from './runtime-environment-usage-record'
import { RuntimeHostStatusOwner } from '../../shared/runtime-host-status-owner'
import type { RuntimeStatus } from '../../shared/runtime-types'
import {
RUNTIME_HOST_STATUS_CHANNEL,
type RuntimeHostStatusResponse
@@ -16,12 +15,9 @@ import {
import {
applyRuntimeEnvironmentCapabilityVerdict,
getAcceptedRuntimeEnvironmentCapabilityOutcome,
getRuntimeEnvironmentCapabilityIncarnation,
captureRuntimeEnvironmentCapabilityEvidence
} from './runtime-environment-capability-evidence'
import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect'
import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard'
import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel'
export function createRuntimeEnvironmentStatusOwner(
userDataPath: string,
@@ -38,23 +34,13 @@ export function createRuntimeEnvironmentStatusOwner(
return new RuntimeHostStatusOwner({
environmentId: environment.id,
pairingRevision: environment.pairingRevision ?? environment.createdAt,
request: async (signal) => {
const incarnation = getRuntimeEnvironmentCapabilityIncarnation(environment.id)
const isCurrent = (): boolean =>
getRuntimeEnvironmentCapabilityIncarnation(environment.id) === incarnation
if (environment.connectionDependency === 'ssh-tunnel') {
await resolveManagedRuntimeEnvironment(userDataPath, environment.id)
if (!isCurrent()) {
return runtimeEnvironmentChangedFailure(environment, 'status.get')
}
signal.throwIfAborted()
}
request: (signal) => {
evidence = captureRuntimeEnvironmentCapabilityEvidence(environment.id, pairing)
const response = await (transport.isReady() &&
getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind ===
'supported'
return transport.isReady() &&
getAcceptedRuntimeEnvironmentCapabilityOutcome(environment.id, pairing, null)?.kind ===
'supported'
? transport.request(signal)
: sendRemoteRuntimeRequest<RuntimeStatus>(
: sendRemoteRuntimeRequest(
pairing,
'status.get',
undefined,
@@ -62,8 +48,7 @@ export function createRuntimeEnvironmentStatusOwner(
undefined,
signal,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
))
return isCurrent() ? response : runtimeEnvironmentChangedFailure(environment, 'status.get')
)
},
verified: (response, active) => {
const capable =
@@ -1,42 +0,0 @@
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../shared/runtime-types'
import { getRuntimeEnvironmentCapabilityIncarnation } from './runtime-environment-capability-evidence'
import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections'
import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect'
import { runtimeEnvironmentChangedFailure } from './runtime-environment-revision-guard'
import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics'
import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response'
export async function getRuntimeEnvironmentStatus(
userDataPath: string,
selector: string,
timeoutMs?: number,
options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true }
): Promise<RuntimeRpcResponse<RuntimeStatus>> {
const environment = resolveEnvironment(userDataPath, selector)
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
return {
id: 'status.get',
ok: false,
error: {
code: 'runtime_manually_disconnected',
message: 'Runtime environment is manually disconnected.'
}
}
}
const incarnation = getRuntimeEnvironmentCapabilityIncarnation(environment.id)
const response = await getRuntimeEnvironmentStatusOwner(userDataPath, environment.id).refresh({
timeoutMs,
...options
})
// A retired request must not publish old status or borrow its replacement's diagnostics.
if (getRuntimeEnvironmentCapabilityIncarnation(environment.id) !== incarnation) {
return runtimeEnvironmentChangedFailure(environment, 'status.get')
}
return attachRemoteControlDiagnostics(
withTailscaleHintForResponse(response, getPreferredPairingOffer(environment).endpoint),
environment.id
)
}
@@ -1,110 +0,0 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
addEnvironmentFromPairingCode,
resolveEnvironment
} from '../../shared/runtime-environment-store'
import { pairingCode } from './runtime-environments-ipc-test-harness'
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version'
import {
advanceRuntimeEnvironmentCapabilityIncarnation,
resetRuntimeEnvironmentCapabilityEvidence
} from './runtime-environment-capability-evidence'
const mocks = vi.hoisted(() => ({
request: vi.fn(),
ensure: vi.fn(),
reconnect: vi.fn(),
diagnostics: vi.fn()
}))
vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: mocks.request }))
vi.mock('./runtime-environment-request-connections', async () => {
const { withRuntimeStatusOwners } = await import('./runtime-environments-ipc-test-harness')
return withRuntimeStatusOwners({
ensureRemoteRuntimeSharedControlConnection: mocks.ensure,
reconnectRemoteRuntimeSharedControlConnection: mocks.reconnect,
getRemoteRuntimeSharedControlDiagnostics: mocks.diagnostics,
pauseRemoteRuntimeSharedControlRetry: vi.fn(),
closeRemoteRuntimeRequestConnection: vi.fn()
})
})
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing'
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
let directory: string
let envId: string
beforeEach(() => {
vi.clearAllMocks()
resetRuntimeEnvironmentCapabilityEvidence()
directory = mkdtempSync(join(tmpdir(), 'orca-status-retirement-'))
envId = addEnvironmentFromPairingCode(directory, {
name: 'Host',
now: 1,
pairingCode: pairingCode(),
connectionDependency: 'ssh-tunnel'
}).id
mocks.diagnostics.mockReturnValue(null)
})
afterEach(() => {
resetRuntimeEnvironmentStatusOwners()
rmSync(directory, { recursive: true, force: true })
})
function response() {
return {
id: 'status',
ok: true as const,
result: { runtimeId: 'old-host', capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] },
_meta: { runtimeId: 'old-host' }
}
}
it.each(['success', 'failure', 'throw'] as const)(
'fences a retired status probe settling with %s without publishing status or diagnostics',
async (settlement) => {
const pending = Promise.withResolvers<unknown>()
mocks.request.mockReturnValue(pending.promise)
const probe = getRuntimeEnvironmentStatus(directory, envId)
await vi.waitFor(() => expect(mocks.request).toHaveBeenCalledOnce())
mocks.diagnostics.mockClear()
advanceRuntimeEnvironmentCapabilityIncarnation(envId)
if (settlement === 'throw') {
pending.reject(new Error('old endpoint offline'))
} else {
pending.resolve(
settlement === 'success'
? response()
: {
id: 'status',
ok: false,
error: { code: 'offline', message: 'old endpoint' },
_meta: { runtimeId: 'old-host' }
}
)
}
await expect(probe).resolves.toMatchObject({
ok: false,
error: { code: 'runtime_environment_changed' }
})
expect(resolveEnvironment(directory, envId).runtimeId).toBeNull()
expect(mocks.ensure).not.toHaveBeenCalled()
expect(mocks.reconnect).not.toHaveBeenCalled()
expect(mocks.diagnostics).not.toHaveBeenCalled()
}
)
it('does not fence another environment or a fresh probe after retirement', async () => {
mocks.request.mockImplementation(async () => {
advanceRuntimeEnvironmentCapabilityIncarnation('other')
return response()
})
await expect(getRuntimeEnvironmentStatus(directory, envId)).resolves.toMatchObject({ ok: true })
advanceRuntimeEnvironmentCapabilityIncarnation(envId)
await expect(getRuntimeEnvironmentStatus(directory, envId)).resolves.toMatchObject({ ok: true })
expect(resolveEnvironment(directory, envId).runtimeId).toBe('old-host')
expect(mocks.ensure).toHaveBeenCalledTimes(2)
})

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