Merge remote-tracking branch 'origin/main' into brennanb2025/skill-auto-update-research

# Conflicts:
#	config/reliability-gates.jsonc
This commit is contained in:
Brennan Benson
2026-07-12 23:51:08 -07:00
353 changed files with 21393 additions and 3412 deletions
+14
View File
@@ -866,6 +866,20 @@ jobs:
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
- name: Gate runtime file-watcher process isolation
if: runner.os == 'Linux'
run: |
# Why: #8212 is a native-process crash contract. Prove both the Node
# host and the exact Electron runtime survive SIGSEGV before packaging.
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
- name: Publish release artifacts (Linux)
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
uses: nick-fields/retry@v4
+13
View File
@@ -120,6 +120,19 @@ jobs:
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
- name: Gate runtime file-watcher process isolation
run: |
# Why: #8212 is a native-process crash contract. Prove both the Node
# host and the exact Electron runtime survive SIGSEGV before packaging.
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
- name: Publish release artifacts (macOS)
uses: nick-fields/retry@v4
with:
File diff suppressed because it is too large Load Diff
+28 -6
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
/**
* Bundle the relay daemon into a single relay.js file per platform.
* Bundle the relay daemon and its crash-isolated watcher child per platform.
*
* The relay runs on remote hosts via `node relay.js`, so it must be a
* self-contained CommonJS bundle with no external dependencies beyond
* The relay runs on remote hosts via `node relay.js`, so both outputs use
* self-contained CommonJS bundles with no external dependencies beyond
* Node.js built-ins. Native addons (node-pty, @parcel/watcher) are
* marked external and expected to be installed on the remote or
* gracefully degraded.
@@ -17,6 +17,7 @@ const __dirname = import.meta.dirname
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const PLATFORMS = [
'linux-x64',
@@ -42,7 +43,22 @@ for (const platform of PLATFORMS) {
outfile: join(outDir, 'relay.js'),
// Native addons cannot be bundled — they must exist on the remote host.
// The relay gracefully degrades when they are absent.
external: ['node-pty', '@parcel/watcher'],
external: ['node-pty', '@parcel/watcher', 'electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay-watcher.js'),
external: ['@parcel/watcher'],
sourcemap: false,
minify: true,
define: {
@@ -51,9 +67,15 @@ for (const platform of PLATFORMS) {
})
// Why: include a content hash so the deploy check detects code changes
// even when RELAY_VERSION hasn't been bumped (common during development).
// even when RELAY_VERSION hasn't been bumped. Hash both process artifacts
// so a watcher-only change always deploys beside the matching relay host.
const relayContent = readFileSync(join(outDir, 'relay.js'))
const hash = createHash('sha256').update(relayContent).digest('hex').slice(0, 12)
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
const hash = createHash('sha256')
.update(relayContent)
.update(watcherContent)
.digest('hex')
.slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
@@ -198,6 +198,13 @@ describe('orchestration skill guidance', () => {
expect(messaging).toContain('`@grok`')
})
it('documents @cursor in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
expect(messaging).toContain('`@cursor`')
})
it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
@@ -91,6 +91,77 @@ describe('Electron runtime package contract', () => {
)
})
it('blocks Linux and macOS release packaging on watcher process fault recovery', () => {
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
)
const macWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8')
)
const assertFaultGate = (steps, publishStepName, expectedCondition) => {
const names = steps.map((step) => step.name)
const gate = steps.find((step) => step.name === 'Gate runtime file-watcher process isolation')
expect(gate.if).toBe(expectedCondition)
expect(gate['continue-on-error']).toBeUndefined()
expect(gate.run).toContain('node config/scripts/runtime-file-watcher-fault-harness.mjs')
expect(gate.run).toContain('ELECTRON_RUN_AS_NODE=1 pnpm exec electron')
expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name))
expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName))
}
assertFaultGate(
releaseWorkflow.jobs.build.steps,
'Publish release artifacts (Linux)',
"runner.os == 'Linux'"
)
assertFaultGate(
macWorkflow.jobs['build-mac'].steps,
'Publish release artifacts (macOS)',
undefined
)
})
it('packages and release-gates the SSH relay watcher child', () => {
const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
const builderConfig = readFileSync(
join(projectDir, 'config/electron-builder.config.cjs'),
'utf8'
)
const remoteCommands = readFileSync(
join(projectDir, 'src/main/ssh/ssh-remote-commands.ts'),
'utf8'
)
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
)
const macWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8')
)
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
expect(builderConfig).toContain("from: 'out/relay'")
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
const assertRelayGate = (steps, publishStepName) => {
const names = steps.map((step) => step.name)
const gate = steps.find((step) => step.name === 'Gate SSH relay watcher process isolation')
expect(gate['continue-on-error']).toBeUndefined()
expect(gate.run).toContain('node config/scripts/relay-watcher-fault-harness.mjs')
expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name))
expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName))
}
assertRelayGate(releaseWorkflow.jobs.build.steps, 'Publish release artifacts (Linux)')
assertRelayGate(macWorkflow.jobs['build-mac'].steps, 'Publish release artifacts (macOS)')
const releaseNames = releaseWorkflow.jobs.build.steps.map((step) => step.name)
expect(releaseNames.indexOf('Gate SSH relay watcher process isolation')).toBeLessThan(
releaseNames.indexOf('Build Windows release artifacts')
)
})
it('pins the Windows release builder to the VS 2022 runner image', () => {
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
@@ -0,0 +1,286 @@
import { build } from 'esbuild'
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
const WAIT_TIMEOUT_MS = 20_000
const require = createRequire(import.meta.url)
function withTimeout(promise, label, stderr) {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => {
rejectPromise(new Error(`Timed out waiting for ${label}\n${stderr()}`))
}, WAIT_TIMEOUT_MS)
promise.then(
(value) => {
clearTimeout(timer)
resolvePromise(value)
},
(error) => {
clearTimeout(timer)
rejectPromise(error)
}
)
})
}
function pollUntil(readValue, label, stderr) {
const deadline = Date.now() + WAIT_TIMEOUT_MS
return new Promise((resolveValue, rejectValue) => {
const poll = async () => {
try {
const value = await readValue()
if (value !== undefined) {
resolveValue(value)
return
}
} catch {}
if (Date.now() >= deadline) {
rejectValue(new Error(`Timed out waiting for ${label}\n${stderr()}`))
return
}
setTimeout(poll, 10)
}
void poll()
})
}
function waitForExit(proc) {
if (proc.exitCode !== null || proc.signalCode !== null) {
return Promise.resolve()
}
return new Promise((resolveExit) => proc.once('exit', resolveExit))
}
async function loadProtocol(bundleDir) {
const outfile = join(bundleDir, 'relay-protocol.cjs')
await build({
entryPoints: [resolve('src/relay/protocol.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
logLevel: 'silent'
})
return require(outfile)
}
function createRelayClient(entryPath, args, env, protocol) {
const proc = spawn(process.execPath, [entryPath, ...args], {
cwd: dirname(entryPath),
env,
stdio: ['pipe', 'pipe', 'pipe']
})
const messages = []
let stderr = ''
let nextSequence = 1
let stdoutBuffer = Buffer.alloc(0)
let ready = false
let resolveReady
const sentinelReceived = new Promise((resolvePromise) => {
resolveReady = resolvePromise
})
const decoder = new protocol.FrameDecoder((frame) => {
if (frame.type === protocol.MessageType.Regular) {
messages.push(protocol.parseJsonRpcMessage(frame.payload))
}
})
proc.stderr.on('data', (chunk) => {
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
})
proc.stdout.on('data', (chunk) => {
if (ready) {
decoder.feed(chunk)
return
}
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
const index = stdoutBuffer.indexOf(sentinel)
if (index < 0) {
return
}
ready = true
resolveReady()
const remainder = stdoutBuffer.subarray(index + sentinel.length)
if (remainder.length > 0) {
decoder.feed(remainder)
}
})
const waitForMessage = (startIndex, predicate, label) =>
pollUntil(
() => messages.slice(startIndex).find(predicate),
label,
() => stderr
)
const request = async (method, params = {}) => {
const id = nextSequence++
const startIndex = messages.length
proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0))
const response = await waitForMessage(
startIndex,
(message) => message.id === id,
`response to ${method}`
)
if (response.error) {
throw new Error(`${method} failed: ${response.error.message}`)
}
return response.result
}
const notify = (method, params = {}) => {
const sequence = nextSequence++
proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, sequence, 0))
}
return {
proc,
request,
notify,
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
messageCount: () => messages.length,
waitForNotification: (startIndex, method, predicate = () => true) =>
waitForMessage(
startIndex,
(message) => message.method === method && predicate(message.params ?? {}),
`${method} notification`
),
stderr: () => stderr
}
}
async function waitForWatcherPid(pidFile, previousPid, stderr) {
return pollUntil(
async () => {
const pid = Number((await readFile(pidFile, 'utf8')).trim())
if (!Number.isInteger(pid) || pid <= 0 || pid === previousPid) {
return undefined
}
// Why: replacement children reuse this exclusive path after fault injection.
await rm(pidFile, { force: true })
return pid
},
previousPid ? 'replacement watcher child pid' : 'initial watcher child pid',
stderr
)
}
function includesWatchPath(params, targetPath) {
return Array.isArray(params.events)
? params.events.some((event) => event.absolutePath === targetPath)
: false
}
async function main() {
const platform = `${process.platform}-${process.arch}`
const relayEntry = resolve('out', 'relay', platform, 'relay.js')
const watcherEntry = resolve('out', 'relay', platform, 'relay-watcher.js')
if (!existsSync(relayEntry) || !existsSync(watcherEntry)) {
throw new Error(`Missing built relay artifacts for ${platform}; run pnpm run build:relay first`)
}
let tempRoot
let relay
try {
tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
const watchRoot = await realpath(tempRoot)
const pidFile = join(tempRoot, 'watcher.pid')
const protocol = await loadProtocol(tempRoot)
const socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
: join(tempRoot, 'relay.sock')
relay = createRelayClient(
relayEntry,
['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
{ ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
protocol
)
await relay.sentinelReceived
const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
let startIndex = relay.messageCount()
relay.notify('pty.data', { id: spawned.id, data: `echo ${beforePtyMarker}\r` })
await relay.waitForNotification(
startIndex,
'pty.data',
(params) => params.id === spawned.id && String(params.data).includes(beforePtyMarker)
)
await relay.request('fs.watch', { rootPath: watchRoot })
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
const beforePath = join(watchRoot, 'before.txt')
startIndex = relay.messageCount()
await writeFile(beforePath, 'before')
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
includesWatchPath(params, beforePath)
)
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
startIndex = relay.messageCount()
process.kill(firstWatcherPid, faultSignal)
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
Array.isArray(params.events)
? params.events.some(
(event) => event.kind === 'overflow' && event.absolutePath === watchRoot
)
: false
)
const status = await relay.request('relay.status')
if (status.pid !== relay.proc.pid) {
throw new Error('relay.status did not come from the original surviving relay process')
}
const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
startIndex = relay.messageCount()
relay.notify('pty.data', { id: spawned.id, data: `echo ${afterPtyMarker}\r` })
await relay.waitForNotification(
startIndex,
'pty.data',
(params) => params.id === spawned.id && String(params.data).includes(afterPtyMarker)
)
const afterPath = join(watchRoot, 'after.txt')
startIndex = relay.messageCount()
await writeFile(afterPath, 'after')
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
includesWatchPath(params, afterPath)
)
relay.notify('fs.unwatch', { rootPath: watchRoot })
await relay.request('pty.shutdown', { id: spawned.id })
console.log(
JSON.stringify({
relayPid: relay.proc.pid,
killedWatcherPid: firstWatcherPid,
replacementWatcherPid,
faultSignal,
relaySurvived: true,
existingPtySurvived: true,
overflowRefreshDelivered: true,
postCrashEventDelivered: true
})
)
} finally {
if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
relay.proc.kill('SIGTERM')
try {
await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
} catch {
relay.proc.kill('SIGKILL')
await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
}
}
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true })
}
}
}
await main()
@@ -0,0 +1,180 @@
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { build } from 'esbuild'
const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js')
const SUPERVISOR_SOURCE = resolve('src/main/ipc/parcel-watcher-process-supervisor.ts')
const WAIT_TIMEOUT_MS = 15_000
const require = createRequire(import.meta.url)
function withTimeout(promise, label) {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(
() => rejectPromise(new Error(`Timed out waiting for ${label}`)),
WAIT_TIMEOUT_MS
)
promise.then(
(value) => {
clearTimeout(timer)
resolvePromise(value)
},
(error) => {
clearTimeout(timer)
rejectPromise(error)
}
)
})
}
function nextMatchingEvent(register, predicate, label) {
return withTimeout(
new Promise((resolveEvent) => {
register((events) => {
if (events.some(predicate)) {
resolveEvent(events)
}
})
}),
label
)
}
async function loadSupervisor(bundleDir) {
const outfile = join(bundleDir, 'watcher-supervisor.cjs')
await build({
entryPoints: [SUPERVISOR_SOURCE],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
external: ['@parcel/watcher', 'electron'],
logLevel: 'silent'
})
return require(outfile).WatcherProcessSupervisor
}
async function main() {
if (process.platform === 'win32') {
console.log('[runtime-file-watcher-fault] SKIP: SIGSEGV oracle is macOS/Linux only')
return
}
if (!existsSync(ENTRY_PATH)) {
throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`)
}
// Why: mkdtemp/realpath/bundle/construction can fail before the body runs.
// Keep cleanup in finally from the first successful mkdtemp onward, and clean
// the original temp path if realpath never succeeds.
let createdRootPath
let bundleDir
let rootPath
let supervisor
let subscription
let watcherCanaryDir
let eventListener = () => undefined
let rejectWatcherError
const watcherError = new Promise((_, reject) => {
rejectWatcherError = reject
})
// Attach early so a callback rejection before the race cannot become unhandled.
watcherError.catch(() => undefined)
try {
createdRootPath = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-fault-'))
bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-harness-'))
// Parcel reports canonical event paths on macOS, where tmpdir() may use the
// /var symlink spelling. Keep the oracle in the same path domain.
rootPath = await realpath(createdRootPath)
const WatcherProcessSupervisor = await loadSupervisor(bundleDir)
supervisor = new WatcherProcessSupervisor()
let resolveInterruption
const interrupted = withTimeout(
new Promise((resolveWait) => {
resolveInterruption = resolveWait
}),
'automatic watcher resubscription'
)
subscription = await supervisor.subscribe(
rootPath,
(error, events) => {
if (error) {
// Why: throws from the async watcher callback escape main()'s try/finally
// and skip teardown. Surface failures through a harness promise instead.
rejectWatcherError(error)
return
}
eventListener(events)
},
{ ignore: ['.git', 'node_modules'] },
{
delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 },
onInterruption: () => resolveInterruption()
}
)
watcherCanaryDir = supervisor.canaryDir
const beforeEvent = nextMatchingEvent(
(listener) => {
eventListener = listener
},
(event) => event.path === join(rootPath, 'before.txt'),
'pre-crash watch event'
)
await writeFile(join(rootPath, 'before.txt'), 'before')
await Promise.race([beforeEvent, watcherError])
const firstChildPid = supervisor.child?.pid
if (!firstChildPid) {
throw new Error('Watcher supervisor did not expose a live child')
}
process.kill(firstChildPid, 'SIGSEGV')
await Promise.race([interrupted, watcherError])
const replacementChildPid = supervisor.child?.pid
if (!replacementChildPid || replacementChildPid === firstChildPid) {
throw new Error('Watcher supervisor did not replace the faulted child')
}
const afterEvent = nextMatchingEvent(
(listener) => {
eventListener = listener
},
(event) => event.path === join(rootPath, 'after.txt'),
'post-crash watch event'
)
await writeFile(join(rootPath, 'after.txt'), 'after')
await Promise.race([afterEvent, watcherError])
console.log(
JSON.stringify({
hostPid: process.pid,
killedWatcherPid: firstChildPid,
replacementWatcherPid: replacementChildPid,
hostSurvived: true,
automaticResubscribe: true,
postCrashEventDelivered: true
})
)
} finally {
try {
await subscription?.unsubscribe()
} finally {
supervisor?.dispose()
await Promise.all([
createdRootPath ? rm(createdRootPath, { recursive: true, force: true }) : Promise.resolve(),
rootPath && rootPath !== createdRootPath
? rm(rootPath, { recursive: true, force: true })
: Promise.resolve(),
bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve()
])
}
}
if (watcherCanaryDir && existsSync(watcherCanaryDir)) {
throw new Error(`Watcher supervisor leaked its canary directory: ${watcherCanaryDir}`)
}
}
await main()
-1
View File
@@ -182,7 +182,6 @@ export default defineConfig({
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'),
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
+13
View File
@@ -1,8 +1,11 @@
import { Image, StyleSheet, Text, View } from 'react-native'
import type { ImageSourcePropType } from 'react-native'
import { Terminal } from 'lucide-react-native'
import type { TuiAgent } from '../../../src/shared/types'
import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg'
import { colors } from '../theme/mobile-theme'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import { MOBILE_AGENT_ICON_ASSETS } from './mobile-agent-icon-assets'
import { ClaudeIcon, OpenAIIcon } from './AgentIcons'
// Why: agent branding should match the desktop/new-worktree picker everywhere
@@ -49,6 +52,10 @@ function AiderIcon({ size = 16 }: { size?: number }) {
)
}
function BundledIcon({ source, size = 16 }: { source: ImageSourcePropType; size?: number }) {
return <Image source={source} style={{ width: size, height: size, borderRadius: 2 }} />
}
function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) {
return (
<Image
@@ -98,6 +105,12 @@ export function MobileAgentIcon({ agentId, size = 16 }: { agentId: string; size?
return <Terminal size={size} color={colors.textMuted} />
}
// Why: prefer the favicon bundled into the app over Google's favicon service,
// which is unreachable in some regions and offline (#8451).
const bundledIcon = MOBILE_AGENT_ICON_ASSETS[agentId as TuiAgent]
if (bundledIcon) {
return <BundledIcon source={bundledIcon} size={size} />
}
const agent = MOBILE_AGENT_CATALOG.find((entry) => entry.id === agentId)
if (agent?.faviconDomain) {
return <FaviconIcon domain={agent.faviconDomain} size={size} />
+17 -2
View File
@@ -14,6 +14,7 @@ import {
type MobilePrTitleAction
} from '../session/use-mobile-pr-title-action'
import { useMobilePrAiTriage, type MobilePrAiTriage } from '../session/use-mobile-pr-ai-triage'
import { usePRBotAuthorOverrides } from '../session/use-pr-bot-author-overrides'
import { buildFixChecksPrompt, buildResolveConflictsPrompt } from '../session/pr-ai-triage-prompt'
import { prSidebarRenderBranch } from './mobile-pr-sidebar-presentation'
import { mobilePrSidebarStyles as styles } from './pr-sidebar/mobile-pr-sidebar-styles'
@@ -89,6 +90,13 @@ export function MobilePRSidebar({
refetch
})
const triage = useMobilePrAiTriage({ client, connState, worktreeId })
// Keyed on the PR payload identity so overrides re-fetch with each PR refetch
// instead of staying a stale one-shot snapshot for the whole session.
const botAuthorOverrides = usePRBotAuthorOverrides(
client,
connState,
state.kind === 'ready' ? state.data.details : null
)
return (
<ScrollView
@@ -114,6 +122,7 @@ export function MobilePRSidebar({
commentActions={commentActions}
titleAction={titleAction}
triage={triage}
botAuthorOverrides={botAuthorOverrides}
showOpenOnWeb={showOpenOnWeb}
/>
</ScrollView>
@@ -134,7 +143,8 @@ function PrSidebarContent({
commentActions,
titleAction,
triage,
showOpenOnWeb
showOpenOnWeb,
botAuthorOverrides
}: {
branch: ReturnType<typeof prSidebarRenderBranch>
state: PrSidebarState
@@ -150,6 +160,7 @@ function PrSidebarContent({
titleAction: MobilePrTitleAction
triage: MobilePrAiTriage
showOpenOnWeb: boolean
botAuthorOverrides: ReadonlySet<string>
}) {
if (branch === 'loading') {
return (
@@ -215,6 +226,7 @@ function PrSidebarContent({
titleAction={titleAction}
triage={triage}
refetch={refetch}
botAuthorOverrides={botAuthorOverrides}
showOpenOnWeb={showOpenOnWeb}
/>
)
@@ -231,7 +243,8 @@ function PrSidebarSections({
titleAction,
triage,
refetch,
showOpenOnWeb
showOpenOnWeb,
botAuthorOverrides
}: {
data: Extract<PrSidebarState, { kind: 'ready' }>['data']
client: RpcClient | null
@@ -242,6 +255,7 @@ function PrSidebarSections({
triage: MobilePrAiTriage
refetch: () => void
showOpenOnWeb: boolean
botAuthorOverrides: ReadonlySet<string>
}) {
const pr = data.pr
// Bind the triage launchers to this PR's data; the prompt builders are pure so
@@ -314,6 +328,7 @@ function PrSidebarSections({
prState={data.pr.state}
prRepo={data.pr.prRepo ?? null}
actions={commentActions}
botAuthorOverrides={botAuthorOverrides}
/>
</>
)
@@ -0,0 +1,45 @@
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import type { TuiAgent } from '../../../src/shared/types'
import { MOBILE_TUI_AGENT_FAVICON_DOMAINS } from '../tasks/mobile-tui-agents'
// Agents that render a hand-authored glyph in MobileAgentIcon and therefore
// never reach the favicon/bundled-asset path.
const GLYPH_AGENT_IDS = new Set<TuiAgent>([
'claude',
'claude-agent-teams',
'codex',
'pi',
'omp',
'aider'
])
const dirname = import.meta.dirname
const assetsDir = path.resolve(dirname, '../../../src/shared/agent-icons')
const assetsModuleSource = readFileSync(path.join(dirname, 'mobile-agent-icon-assets.ts'), 'utf8')
// Why: these agents previously loaded from Google's favicon service, which is
// unreachable in some regions/offline (#8451). Every one that lacks a glyph
// must ship a bundled PNG so the icon renders without a network request.
const agentsNeedingBundledIcon = Object.keys(MOBILE_TUI_AGENT_FAVICON_DOMAINS).filter(
(id): id is TuiAgent => !GLYPH_AGENT_IDS.has(id as TuiAgent)
)
describe('mobile bundled agent icons', () => {
it('ships a bundled PNG on disk for every favicon-path agent', () => {
for (const id of agentsNeedingBundledIcon) {
expect(existsSync(path.join(assetsDir, `${id}.png`)), `missing bundled icon for ${id}`).toBe(
true
)
}
})
it('wires every favicon-path agent into the bundled asset map', () => {
for (const id of agentsNeedingBundledIcon) {
expect(assetsModuleSource, `${id} not mapped in mobile-agent-icon-assets.ts`).toContain(
`agent-icons/${id}.png`
)
}
})
})
@@ -0,0 +1,41 @@
import type { ImageSourcePropType } from 'react-native'
import type { TuiAgent } from '../../../src/shared/types'
// Why: mobile previously rendered these agent icons from Google's favicon
// service, which is unreachable in some regions (e.g. mainland China) and
// offline, leaving broken images in the agent picker (#8451). Bundle each
// favicon so Metro packages it into the app and the icon renders without any
// network request. The PNGs are shared with desktop in src/shared/agent-icons
// (Metro watches src/shared via metro.config.js sharedRoot). Metro requires
// static literal require() paths, so keep this as an explicit map rather than
// building the path from the agent id.
export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropType>> = {
openclaude: require('../../../src/shared/agent-icons/openclaude.png'),
grok: require('../../../src/shared/agent-icons/grok.png'),
copilot: require('../../../src/shared/agent-icons/copilot.png'),
opencode: require('../../../src/shared/agent-icons/opencode.png'),
'mimo-code': require('../../../src/shared/agent-icons/mimo-code.png'),
ante: require('../../../src/shared/agent-icons/ante.png'),
gemini: require('../../../src/shared/agent-icons/gemini.png'),
antigravity: require('../../../src/shared/agent-icons/antigravity.png'),
goose: require('../../../src/shared/agent-icons/goose.png'),
amp: require('../../../src/shared/agent-icons/amp.png'),
kilo: require('../../../src/shared/agent-icons/kilo.png'),
kiro: require('../../../src/shared/agent-icons/kiro.png'),
crush: require('../../../src/shared/agent-icons/crush.png'),
aug: require('../../../src/shared/agent-icons/aug.png'),
autohand: require('../../../src/shared/agent-icons/autohand.png'),
cline: require('../../../src/shared/agent-icons/cline.png'),
codebuff: require('../../../src/shared/agent-icons/codebuff.png'),
'command-code': require('../../../src/shared/agent-icons/command-code.png'),
continue: require('../../../src/shared/agent-icons/continue.png'),
cursor: require('../../../src/shared/agent-icons/cursor.png'),
droid: require('../../../src/shared/agent-icons/droid.png'),
kimi: require('../../../src/shared/agent-icons/kimi.png'),
'mistral-vibe': require('../../../src/shared/agent-icons/mistral-vibe.png'),
'qwen-code': require('../../../src/shared/agent-icons/qwen-code.png'),
rovo: require('../../../src/shared/agent-icons/rovo.png'),
hermes: require('../../../src/shared/agent-icons/hermes.png'),
devin: require('../../../src/shared/agent-icons/devin.png'),
openclaw: require('../../../src/shared/agent-icons/openclaw.png')
}
@@ -0,0 +1,131 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GitHubWorkItemDetails, PRComment } from '../../../../src/shared/types'
import { PRCommentsSection } from './PRCommentsSection'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Pressable: 'Pressable',
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight'
}))
vi.mock('../../session/pr-comment-actions', () => ({
canAddRootComment: () => false
}))
vi.mock('../../session/mobile-pr-sidebar-state', () => ({
isPrSidebarDetailsPlaceholder: () => false
}))
vi.mock('./PRSection', () => ({ PRSection: 'PRSection' }))
vi.mock('./CommentMarkdown', () => ({ CommentMarkdown: 'CommentMarkdown' }))
vi.mock('./PRCommentCard', () => ({ PRCommentCard: 'PRCommentCard' }))
vi.mock('./PRCommentComposer', () => ({ PRCommentComposer: 'PRCommentComposer' }))
vi.mock('./pr-comments-styles', () => ({ prCommentsStyles: {} }))
vi.mock('./mobile-pr-sidebar-styles', () => ({ mobilePrSidebarStyles: {} }))
vi.mock('../../theme/mobile-theme', () => ({ colors: { textSecondary: '#999' } }))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
function comment(id: number): PRComment {
return {
id,
author: 'octocat',
authorAvatarUrl: '',
body: '',
createdAt: '',
url: ''
}
}
function detailsWithComments(count: number): GitHubWorkItemDetails {
return {
item: { id: 'pr:1', type: 'pr' },
body: '',
comments: Array.from({ length: count }, (_, index) => comment(index + 1))
} as GitHubWorkItemDetails
}
async function renderComments(details: GitHubWorkItemDetails): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(PRCommentsSection, { details, prState: 'open' }))
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('PRCommentsSection did not render')
}
return renderer
}
function audienceTabs(renderer: ReactTestRenderer) {
return renderer.root
.findAllByType('Pressable')
.filter((node) => node.props.accessibilityState !== undefined)
}
function showMoreButton(renderer: ReactTestRenderer) {
const button = renderer.root
.findAllByType('Pressable')
.find((node) => node.props.accessibilityState === undefined)
if (!button) {
throw new Error('Show more button not found')
}
return button
}
async function press(node: { props: { onPress: () => void } }): Promise<void> {
await act(async () => {
node.props.onPress()
})
}
describe('PRCommentsSection', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
renderer?.unmount()
renderer = null
vi.restoreAllMocks()
})
it('resets pagination only when the user chooses a different audience filter', async () => {
renderer = await renderComments(detailsWithComments(25))
expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(12)
await press(showMoreButton(renderer))
expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24)
// The second tab is Humans; moving from All to Humans resets the page limit.
await press(audienceTabs(renderer)[1])
expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(12)
await press(showMoreButton(renderer))
expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24)
// Retapping the active tab used to leave the page size alone; retain that behavior.
await press(audienceTabs(renderer)[1])
expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24)
})
})
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/types'
@@ -39,6 +39,9 @@ type Props = {
// Interactive comment actions (reply/resolve/add/edit/delete). Absent (e.g.
// non-PR) leaves the timeline read-only.
actions?: MobilePrCommentActions
// Author logins manually marked as bots on desktop; keeps the Humans/Bots
// tabs classifying the same comments as the desktop panel.
botAuthorOverrides?: ReadonlySet<string>
}
// Render comments in bounded pages — the whole sidebar is one ScrollView (can't
@@ -49,7 +52,13 @@ const COMMENT_PAGE = 12
// PR body + full comment timeline, mirroring the desktop PR page: a Description
// card, then a Comments section with an audience filter (PRs only), threaded
// review comments, reactions, and collapsible resolved threads.
export function PRCommentsSection({ details, prState, prRepo, actions }: Props) {
export function PRCommentsSection({
details,
prState,
prRepo,
actions,
botAuthorOverrides
}: Props) {
// details is null while phase 2 (the heavy comments/body payload) is still loading.
// A synthetic placeholder means phase 2 failed — do not paint that as empty success.
const loadingDetails = details === null
@@ -83,15 +92,27 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
const canComment = isPr && actions !== undefined && canAddRootComment(prState)
const [filter, setFilter] = useState<PRCommentAudienceFilter>('all')
const counts = useMemo(() => getPRCommentAudienceCounts(comments), [comments])
const visible = useMemo(() => filterPRCommentsByAudience(comments, filter), [comments, filter])
const counts = useMemo(
() => getPRCommentAudienceCounts(comments, botAuthorOverrides),
[botAuthorOverrides, comments]
)
const visible = useMemo(
() => filterPRCommentsByAudience(comments, filter, botAuthorOverrides),
[botAuthorOverrides, comments, filter]
)
const groups = useMemo(() => groupPRComments(visible), [visible])
// Bounded render window; reset to the first page whenever the filtered set changes.
// Bounded render window; reset to the first page when the user selects another filter.
const [limit, setLimit] = useState(COMMENT_PAGE)
useEffect(() => {
const selectFilter = (nextFilter: PRCommentAudienceFilter): void => {
if (nextFilter === filter) {
return
}
// Why: paging belongs to the filter-tab event, so reset it in the same batch
// instead of briefly rendering the new filter with the previous page size.
setLimit(COMMENT_PAGE)
}, [filter])
setFilter(nextFilter)
}
const shownGroups = groups.slice(0, limit)
const remaining = groups.length - shownGroups.length
@@ -139,7 +160,7 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
<Pressable
key={tab.value}
style={[styles.audienceTab, active && styles.audienceTabActive]}
onPress={() => setFilter(tab.value)}
onPress={() => selectFilter(tab.value)}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
@@ -1,4 +1,10 @@
import type { PRComment } from '../../../../src/shared/types'
import {
createBotAuthorOverrideSet,
normalizePRCommentAuthorLogin
} from '../../../../src/shared/pr-bot-author-overrides'
export { createBotAuthorOverrideSet }
// Audience filtering for the PR comment timeline, ported from the desktop helper
// (src/renderer/src/lib/pr-comment-audience.ts) minus its i18n wrapper so it stays
@@ -43,12 +49,18 @@ const KNOWN_AUTOMATION_LOGIN_SUBSTRINGS = [
'-reviewer'
]
export function isBotPRComment(comment: PRComment): boolean {
export function isBotPRComment(
comment: PRComment,
botAuthorOverrides?: ReadonlySet<string>
): boolean {
const author = comment.author.trim()
const normalized = normalizePRCommentAuthorLogin(author)
if (botAuthorOverrides?.has(normalized)) {
return true
}
if (comment.isBot === true) {
return true
}
const author = comment.author.trim()
const normalized = author.toLowerCase()
if (normalized.endsWith(BOT_LOGIN_SUFFIX)) {
return true
}
@@ -59,9 +71,10 @@ export function isBotPRComment(comment: PRComment): boolean {
}
export function getPRCommentAudienceCounts(
comments: PRComment[]
comments: PRComment[],
botAuthorOverrides?: ReadonlySet<string>
): Record<PRCommentAudienceFilter, number> {
const bot = comments.filter(isBotPRComment).length
const bot = comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)).length
return {
all: comments.length,
human: comments.length - bot,
@@ -71,13 +84,14 @@ export function getPRCommentAudienceCounts(
export function filterPRCommentsByAudience(
comments: PRComment[],
filter: PRCommentAudienceFilter
filter: PRCommentAudienceFilter,
botAuthorOverrides?: ReadonlySet<string>
): PRComment[] {
if (filter === 'bot') {
return comments.filter(isBotPRComment)
return comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides))
}
if (filter === 'human') {
return comments.filter((comment) => !isBotPRComment(comment))
return comments.filter((comment) => !isBotPRComment(comment, botAuthorOverrides))
}
return comments
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { PRComment } from '../../../../src/shared/types'
import {
createBotAuthorOverrideSet,
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
isBotPRComment
@@ -38,6 +39,15 @@ describe('pr comment audience', () => {
expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3])
expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3)
})
it('classifies desktop-marked bot authors via overrides (desktop parity)', () => {
const overrides = createBotAuthorOverrideSet([' GretelFlux '])
const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })]
expect(isBotPRComment(comments[1])).toBe(false)
expect(isBotPRComment(comments[1], overrides)).toBe(true)
expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 })
expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2])
})
})
describe('pr comment groups', () => {
@@ -0,0 +1,122 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
ScrollView: 'ScrollView',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Code: 'Code',
Pencil: 'Pencil'
}))
vi.mock('../components/MobileMarkdown', () => ({
MobileMarkdown: 'MobileMarkdown'
}))
vi.mock('./MobileFilePreviewSourceText', () => ({
MobileFilePreviewSourceText: 'MobileFilePreviewSourceText',
MobileFilePreviewTruncatedNote: 'MobileFilePreviewTruncatedNote'
}))
vi.mock('../theme/mobile-theme', () => ({
colors: { textPrimary: '#fff', textSecondary: '#999' }
}))
vi.mock('./mobile-file-preview-styles', () => ({
filePreviewStyles: {}
}))
type PreviewProps = Parameters<typeof MobileFileMarkdownPreview>[0]
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
async function renderPreview(props: PreviewProps): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(MobileFileMarkdownPreview, props))
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('MobileFileMarkdownPreview did not render')
}
return renderer
}
async function updatePreview(renderer: ReactTestRenderer, props: PreviewProps): Promise<void> {
await act(async () => {
renderer.update(createElement(MobileFileMarkdownPreview, props))
})
}
function modeToggle(renderer: ReactTestRenderer, label: string) {
const toggle = renderer.root
.findAllByType('Pressable')
.find((node) => node.props.accessibilityLabel === label)
if (!toggle) {
throw new Error(`Missing ${label} toggle`)
}
return toggle
}
async function selectMode(renderer: ReactTestRenderer, label: string): Promise<void> {
await act(async () => {
modeToggle(renderer, label).props.onPress()
})
}
function isSelected(renderer: ReactTestRenderer, label: string): boolean {
return modeToggle(renderer, label).props.accessibilityState.selected === true
}
describe('MobileFileMarkdownPreview', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
renderer?.unmount()
renderer = null
vi.restoreAllMocks()
})
it('resets the selected mode for a new file or line target without remounting the preview', async () => {
const baseProps: PreviewProps = {
relativePath: 'notes/first.md',
content: '# First',
truncated: false,
byteLength: 7
}
renderer = await renderPreview(baseProps)
expect(isSelected(renderer, 'View rendered Markdown preview')).toBe(true)
await selectMode(renderer, 'View Markdown source')
expect(isSelected(renderer, 'View Markdown source')).toBe(true)
// Content updates alone preserve the user's explicitly selected mode.
await updatePreview(renderer, { ...baseProps, content: '# First updated' })
expect(isSelected(renderer, 'View Markdown source')).toBe(true)
await updatePreview(renderer, { ...baseProps, relativePath: 'notes/second.md' })
expect(isSelected(renderer, 'View rendered Markdown preview')).toBe(true)
await updatePreview(renderer, { ...baseProps, relativePath: 'notes/second.md', initialLine: 8 })
expect(isSelected(renderer, 'View Markdown source')).toBe(true)
})
})
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Pressable, ScrollView, View } from 'react-native'
import { Code, Pencil } from 'lucide-react-native'
import { MobileMarkdown } from '../components/MobileMarkdown'
@@ -25,9 +25,15 @@ export function MobileFileMarkdownPreview({
initialLine
}: Props) {
const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview'))
useEffect(() => {
const [previousRelativePath, setPreviousRelativePath] = useState(relativePath)
const [previousInitialLine, setPreviousInitialLine] = useState(initialLine)
// Why: opening a different file or line target must switch modes before paint,
// never briefly retain the prior file's manually selected mode.
if (relativePath !== previousRelativePath || initialLine !== previousInitialLine) {
setPreviousRelativePath(relativePath)
setPreviousInitialLine(initialLine)
setMode(initialLine ? 'source' : 'preview')
}, [initialLine, relativePath])
}
const previewSelected = mode === 'preview'
const sourceSelected = mode === 'source'
@@ -0,0 +1,61 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ConnectionState } from '../transport/types'
import type { RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { createBotAuthorOverrideSet } from '../../../src/shared/pr-bot-author-overrides'
// Fetches the desktop's manual bot-author overrides (GlobalSettings.prBotAuthorOverrides,
// marked from the desktop Comments panel) so the mobile Humans/Bots comment filter
// classifies the same authors as bots. There is no settings-change stream over the
// mobile RPC, so callers pass a refreshKey (e.g. the PR payload identity) to re-fetch
// alongside PR data instead of holding a one-shot snapshot for the whole session.
export function usePRBotAuthorOverrides(
client: RpcClient | null,
connState: ConnectionState,
refreshKey?: unknown
): ReadonlySet<string> {
const [logins, setLogins] = useState<string[]>([])
const sourceClientRef = useRef<RpcClient | null>(null)
useEffect(() => {
if (!client || connState !== 'connected') {
sourceClientRef.current = null
setLogins([])
return
}
if (sourceClientRef.current !== client) {
// Why: overrides belong to the connected runtime; never render a prior
// host's classification while the replacement host is still loading.
sourceClientRef.current = client
setLogins([])
}
if (refreshKey == null) {
return
}
let stale = false
void client
.sendRequest('settings.get')
.then((response) => {
if (stale || !response.ok) {
return
}
const result = (response as RpcSuccess).result as {
settings?: { prBotAuthorOverrides?: unknown }
} | null
const overrides = result?.settings?.prBotAuthorOverrides
setLogins(
Array.isArray(overrides)
? overrides.filter((login): login is string => typeof login === 'string')
: []
)
})
.catch(() => {
// Best-effort: without the setting the heuristics still classify most bots.
})
return () => {
stale = true
}
}, [client, connState, refreshKey])
return useMemo(() => createBotAuthorOverrideSet(logins), [logins])
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.138-rc.2",
"version": "1.4.138-rc.5",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+16 -16
View File
@@ -1,11 +1,11 @@
{
"schemaVersion": 1,
"appVersion": "1.4.138-rc.2",
"appVersion": "1.4.138-rc.5",
"skills": [
{
"name": "computer-use",
"sourcePath": "skills/computer-use",
"appVersion": "1.4.138-rc.2",
"appVersion": "1.4.138-rc.5",
"releaseRevision": 4,
"packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc",
"gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b",
@@ -24,7 +24,7 @@
{
"name": "linear-tickets",
"sourcePath": "skills/linear-tickets",
"appVersion": "1.4.138-rc.2",
"appVersion": "1.4.138-rc.5",
"releaseRevision": 4,
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
@@ -43,7 +43,7 @@
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"appVersion": "1.4.138-rc.2",
"appVersion": "1.4.138-rc.5",
"releaseRevision": 31,
"packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901",
"gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c",
@@ -62,7 +62,7 @@
{
"name": "orca-emulator",
"sourcePath": "skills/orca-emulator",
"appVersion": "1.4.138-rc.2",
"appVersion": "1.4.138-rc.5",
"releaseRevision": 3,
"packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412",
"gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f",
@@ -81,7 +81,7 @@
{
"name": "orca-emulator-android",
"sourcePath": "skills/orca-emulator-android",
"appVersion": "1.4.138-rc.2",
"appVersion": "1.4.138-rc.5",
"releaseRevision": 1,
"packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4",
"gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6",
@@ -100,7 +100,7 @@
{
"name": "orca-linear",
"sourcePath": "skills/orca-linear",
"appVersion": "1.4.138-rc.2",
"appVersion": "1.4.138-rc.5",
"releaseRevision": 2,
"packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
"gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
@@ -119,7 +119,7 @@
{
"name": "orca-per-workspace-env",
"sourcePath": "skills/orca-per-workspace-env",
"appVersion": "1.4.138-rc.2",
"appVersion": "1.4.138-rc.5",
"releaseRevision": 2,
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
@@ -138,19 +138,19 @@
{
"name": "orchestration",
"sourcePath": "skills/orchestration",
"appVersion": "1.4.138-rc.2",
"releaseRevision": 25,
"packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b",
"gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2",
"appVersion": "1.4.138-rc.5",
"releaseRevision": 24,
"packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9",
"gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982",
"files": [
{
"path": "SKILL.md",
"size": 22850,
"size": 22861,
"executable": false,
"classification": "text",
"exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
"textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
"identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9"
"exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5",
"textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5",
"identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5"
}
]
}
-16
View File
@@ -883,22 +883,6 @@
"identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5"
}
]
},
{
"releaseRevision": 25,
"packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b",
"gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2",
"files": [
{
"path": "SKILL.md",
"size": 22850,
"executable": false,
"classification": "text",
"exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
"textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
"identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9"
}
]
}
],
"mobile-fit-debug": [
+1 -1
View File
@@ -96,7 +96,7 @@ Rules:
- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.
- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly.
- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion.
- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, and `@worktree:<id>`.
- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:<id>`.
- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`.
- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.
- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals.
@@ -0,0 +1,45 @@
import { afterEach, expect, it, vi } from 'vitest'
const callMock = vi.fn()
const originalExitCode = process.exitCode
vi.mock('../format', () => ({ printResult: vi.fn() }))
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
import { printResult } from '../format'
import { ORCHESTRATION_HANDLERS } from './orchestration'
afterEach(() => {
process.exitCode = originalExitCode
})
it('prints a lifecycle rejection and exits unsuccessfully', async () => {
const response = {
result: {
message: { id: 'msg_rejected' },
lifecycle: {
action: 'rejected' as const,
code: 'sender_not_assignee',
reason: 'dispatch ctx_1 expected the assigned pane'
}
}
}
callMock.mockResolvedValueOnce(response)
await ORCHESTRATION_HANDLERS['orchestration send']({
flags: new Map([
['from', 'term_foreign'],
['to', 'term_coord'],
['subject', 'done'],
['type', 'worker_done']
]),
client: { call: callMock },
cwd: '/tmp/repo',
json: false
} as never)
expect(process.exitCode).toBe(1)
const formatter = vi.mocked(printResult).mock.calls[0]?.[2]
expect(formatter?.(response.result)).toBe(
'Rejected msg_rejected: dispatch ctx_1 expected the assigned pane'
)
})
+20
View File
@@ -317,6 +317,26 @@ describe('orchestration send structured payload flags', () => {
})
expect(callMock).not.toHaveBeenCalled()
})
it.each(['worker_done', 'heartbeat'] as const)(
'does not resolve an identity-less %s sender from the active terminal',
async (type) => {
getTerminalHandleMock.mockResolvedValue('term_active_coordinator')
await expect(
invokeSend(
new Map<string, string | boolean>([
['to', 'term_coord'],
['subject', 'update'],
['type', type]
])
)
).rejects.toMatchObject({ code: 'no_active_sender_terminal' })
expect(getTerminalHandleMock).not.toHaveBeenCalled()
expect(callMock).not.toHaveBeenCalled()
}
)
})
describe('orchestration dispatch coordinator handle', () => {
+29 -3
View File
@@ -80,6 +80,16 @@ type MessageSummary = {
read?: number
}
type LifecycleSendRejection = {
action: 'rejected'
code: string
reason: string
}
type OrchestrationSendResult =
| { message: { id: string }; lifecycle?: LifecycleSendRejection }
| { messages: { id: string }[]; recipients: number }
function getOptionalStructuredMessagePayload(
flags: Map<string, string | boolean>
): string | undefined {
@@ -347,6 +357,16 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
const type = getOptionalStringFlag(flags, 'type')
rejectLifecycleGroupRecipient(type, to)
if (
(type === 'worker_done' || type === 'heartbeat') &&
!getOptionalStringFlag(flags, 'from') &&
!process.env.ORCA_TERMINAL_HANDLE
) {
// Why: focus is not lifecycle authority; injected dispatches carry an
// explicit worker handle so an identity-less subprocess must fail closed.
throwNoActiveSenderTerminal()
}
// Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness
// probe (terminal.show throws runtime_unavailable in the exact mid-restart
// window worker_done must survive) and no pane remint (pre-payload-
@@ -354,9 +374,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
// and coordinator replies route to the sender row while the worker's own
// `check` reads its env-handle inbox).
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const result = await client.call<
{ message: { id: string } } | { messages: { id: string }[]; recipients: number }
>('orchestration.send', {
const result = await client.call<OrchestrationSendResult>('orchestration.send', {
from,
to,
subject: getRequiredStringFlag(flags, 'subject'),
@@ -370,8 +388,16 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
senderPaneKey: process.env.ORCA_PANE_KEY || undefined,
devMode: isDevCliInvocation()
})
if ('message' in result.result && result.result.lifecycle?.action === 'rejected') {
// Why: a persisted diagnostic is not a successful lifecycle signal;
// the non-zero status prevents workers from treating it as completion.
process.exitCode = 1
}
printResult(result, json, (r) => {
if ('message' in r) {
if (r.lifecycle?.action === 'rejected') {
return `Rejected ${r.message.id}: ${r.lifecycle.reason}`
}
return `Sent ${r.message.id}`
}
return `Sent ${r.messages.length} messages to ${r.recipients} recipients`
+136 -7
View File
@@ -1,6 +1,7 @@
import { EventEmitter } from 'node:events'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
@@ -18,6 +19,57 @@ class FakeChildProcess extends EventEmitter {
unref = vi.fn()
}
const RECIPE_JSON = JSON.stringify({
schemaVersion: 1,
pairingCode: encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint: 'wss://sandbox.example.com',
deviceToken: 'token',
publicKeyB64: 'public-key'
}),
projectRoot: '/workspace/repo'
})
const SERVE_INSTALL_STATUS = '[serve] orca CLI install: installed'
const SSH_PRIVATE_KEY = 'TOP-SECRET-PRIVATE-KEY'
const SSH_AUTHORIZATION = 'Bearer TOP-SECRET-AUTHORIZATION'
const SSH_PASSPHRASE = 'TOP-SECRET-PASSPHRASE'
const SSH_COOKIE = 'session=TOP-SECRET-COOKIE'
const SSH_RECIPE_JSON = JSON.stringify({
schemaVersion: 1,
connection: {
type: 'ssh',
target: {
label: 'Sandbox',
host: 'sandbox.example.com',
port: 22,
username: 'root'
},
projectRoot: '/workspace/repo'
},
userData: {
credentials: {
privateKey: SSH_PRIVATE_KEY,
authorization: SSH_AUTHORIZATION,
passphrase: SSH_PASSPHRASE,
cookie: SSH_COOKIE
}
}
})
const INVALID_SSH_RECIPE_JSON = SSH_RECIPE_JSON.replace('/workspace/repo', 'relative/repo')
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
function startRecipeJsonServer() {
const child = new FakeChildProcess()
spawnMock.mockReturnValue(child)
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const result = serveOrcaApp({
recipeJson: true,
projectRoot: '/workspace/repo'
})
return { child, result, stdoutSpy, stderrSpy }
}
describe('serveOrcaApp', () => {
beforeEach(() => {
spawnMock.mockReset()
@@ -133,10 +185,7 @@ describe('serveOrcaApp', () => {
projectRoot: '/workspace/repo'
})
queueMicrotask(() => {
child.stdout.emit(
'data',
'{"schemaVersion":1,"pairingCode":"orca://pair?code=abc","projectRoot":"/workspace/repo"}\n'
)
child.stdout.emit('data', `${RECIPE_JSON}\n`)
})
await expect(result).resolves.toBe(0)
@@ -157,12 +206,92 @@ describe('serveOrcaApp', () => {
stdio: ['ignore', 'pipe', 'inherit']
})
)
expect(writeSpy).toHaveBeenCalledWith(
'{"schemaVersion":1,"pairingCode":"orca://pair?code=abc","projectRoot":"/workspace/repo"}\n'
)
expect(writeSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`)
expect(child.unref).toHaveBeenCalled()
})
it('waits past startup status lines for valid recipe JSON', async () => {
const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer()
queueMicrotask(() => {
child.stdout.emit(
'data',
`${SERVE_INSTALL_STATUS}\n${INVALID_SSH_RECIPE_JSON}\n${SSH_RECIPE_JSON}\n${RECIPE_JSON.slice(0, 40)}`
)
child.stdout.emit('data', `${RECIPE_JSON.slice(40)}\n`)
})
await expect(result).resolves.toBe(0)
expect(stderrSpy).toHaveBeenNthCalledWith(1, `${IGNORED_NON_RECIPE_STDOUT}\n`)
expect(stderrSpy).toHaveBeenNthCalledWith(2, `${IGNORED_NON_RECIPE_STDOUT}\n`)
expect(stderrSpy).toHaveBeenNthCalledWith(3, `${IGNORED_NON_RECIPE_STDOUT}\n`)
for (const secret of [SSH_PRIVATE_KEY, SSH_AUTHORIZATION, SSH_PASSPHRASE, SSH_COOKIE]) {
expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining(secret))
}
expect(stdoutSpy).toHaveBeenCalledTimes(1)
expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`)
expect(child.unref).toHaveBeenCalledOnce()
})
it('preserves UTF-8 recipe JSON split across Buffer chunks', async () => {
const { child, result, stdoutSpy } = startRecipeJsonServer()
const unicodeRecipeJson = RECIPE_JSON.replace('/workspace/repo', '/workspace/café')
const recipeBuffer = Buffer.from(`${unicodeRecipeJson}\n`)
const splitIndex = recipeBuffer.indexOf(Buffer.from('é')) + 1
queueMicrotask(() => {
child.stdout.emit('data', recipeBuffer.subarray(0, splitIndex))
child.stdout.emit('data', recipeBuffer.subarray(splitIndex))
})
await expect(result).resolves.toBe(0)
expect(stdoutSpy).toHaveBeenCalledWith(`${unicodeRecipeJson}\n`)
expect(child.unref).toHaveBeenCalledOnce()
})
it('rejects when the server exits without valid recipe JSON', async () => {
const { child, result, stdoutSpy, stderrSpy } = startRecipeJsonServer()
const secrets = ['UPPER-SECRET', 'SLASH-SECRET', 'LEGACY-SECRET', 'PRIVATE-SECRET']
const untrustedLines = [
'ORCA://pair?code=UPPER-SECRET',
'orca://pair/?code=SLASH-SECRET',
'orca://pair#LEGACY-SECRET',
'"embedded privateKey PRIVATE-SECRET"',
'{privateKey:"PRIVATE-SECRET"}'
].join('\n')
queueMicrotask(() => {
child.stdout.emit('data', `${untrustedLines}\n`)
child.emit('exit', 0, null)
child.emit('close', 0, null)
})
await expect(result).rejects.toMatchObject({
code: 'runtime_serve_failed',
message: 'Orca serve exited before printing valid recipe JSON with code 0.'
})
expect(stdoutSpy).not.toHaveBeenCalled()
expect(stderrSpy).toHaveBeenCalledTimes(5)
expect(stderrSpy).toHaveBeenCalledWith(`${IGNORED_NON_RECIPE_STDOUT}\n`)
for (const secret of secrets) {
expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining(secret))
}
expect(child.unref).not.toHaveBeenCalled()
})
it('accepts valid recipe JSON at exit without a trailing newline', async () => {
const { child, result, stdoutSpy } = startRecipeJsonServer()
queueMicrotask(() => {
child.emit('exit', 0, null)
child.stdout.emit('data', RECIPE_JSON)
child.emit('close', 0, null)
})
await expect(result).resolves.toBe(0)
expect(stdoutSpy).toHaveBeenCalledWith(`${RECIPE_JSON}\n`)
expect(child.unref).toHaveBeenCalledOnce()
})
it('uses a shell when a Windows npm command shim is the Electron executable', async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
+50 -16
View File
@@ -1,7 +1,14 @@
import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import {
getEphemeralVmRecipeResultConnection,
parseEphemeralVmRecipeResult
} from '../../shared/ephemeral-vm-recipes'
import { RuntimeClientError } from './types'
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
export function launchOrcaApp(): void {
const overrideCommand = process.env.ORCA_OPEN_COMMAND
if (typeof overrideCommand === 'string' && overrideCommand.trim().length > 0) {
@@ -155,7 +162,7 @@ function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<numb
clearTimeout(timeout)
child.stdout?.off('data', onData)
child.off('error', onError)
child.off('exit', onExit)
child.off('close', onClose)
if (error) {
reject(error)
return
@@ -164,42 +171,69 @@ function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<numb
child.unref()
resolve(0)
}
const emitLine = (line: string): void => {
process.stdout.write(`${line}\n`)
finish()
const writeIgnoredRecipeStdout = (): void => {
// Why: non-readiness child stdout is untrusted and cannot be safely
// redacted, including schema-valid results with arbitrary user data.
process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`)
}
const onData = (chunk: Buffer | string): void => {
output += chunk.toString()
const newlineIndex = output.indexOf('\n')
if (newlineIndex === -1) {
const processRecipeOutputLine = (line: string): void => {
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line
if (!normalizedLine.trim()) {
return
}
emitLine(output.slice(0, newlineIndex))
const parsed = parseEphemeralVmRecipeResult(normalizedLine)
if (!parsed.ok) {
writeIgnoredRecipeStdout()
return
}
if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') {
writeIgnoredRecipeStdout()
return
}
process.stdout.write(`${normalizedLine.trim()}\n`)
finish()
}
const stdoutDecoder = new StringDecoder('utf8')
const onData = (chunk: Buffer | string): void => {
output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk)
while (!settled) {
const newlineIndex = output.indexOf('\n')
if (newlineIndex === -1) {
return
}
const line = output.slice(0, newlineIndex)
output = output.slice(newlineIndex + 1)
processRecipeOutputLine(line)
}
}
const onError = (error: Error): void => {
finish(error)
}
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) {
return
}
const trimmed = output.trim()
if (trimmed) {
emitLine(trimmed)
output += stdoutDecoder.end()
if (output.trim()) {
processRecipeOutputLine(output)
}
if (settled) {
return
}
finish(
new RuntimeClientError(
'runtime_serve_failed',
typeof code === 'number'
? `Orca serve exited before printing recipe JSON with code ${code}.`
: `Orca serve exited before printing recipe JSON via ${signal}.`
? `Orca serve exited before printing valid recipe JSON with code ${code}.`
: `Orca serve exited before printing valid recipe JSON via ${signal}.`
)
)
}
child.stdout?.on('data', onData)
child.once('error', onError)
child.once('exit', onExit)
// Why: `exit` can precede the final piped stdout data. `close` waits until
// stdio closes so a last recipe chunk is not mistaken for missing output.
child.once('close', onClose)
})
}
+1 -1
View File
@@ -26,7 +26,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
notes: [
'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:<id>".',
'worker_done and heartbeat must target a concrete coordinator terminal handle; use status for broadcast updates.',
'A worker_done with the active task/dispatch IDs completes that task when sent from the dispatched pane (or when pane identity is unavailable); on older runtimes the sender must match the dispatch assignee handle, so avoid overriding --from.',
'A worker_done with the active task/dispatch IDs completes that task only from the dispatched pane. When stable pane identity is unavailable, the sender handle must exactly match the dispatch assignee; injected preambles include the correct --from value.',
'Prefer --task-id/--dispatch-id/etc. over raw --payload JSON in worker commands; PowerShell strips JSON quotes easily.'
]
},
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
__resetBranchRenameFailureOutputForTests,
readBranchRenameFailureOutputForDisplay,
rememberBranchRenameFailureOutput
} from './branch-rename-failure-output'
const output = (label: string) => ({
label,
exitCode: 1,
stdout: '',
stderr: `${label} failed`
})
beforeEach(() => {
__resetBranchRenameFailureOutputForTests()
})
describe('branch rename failure output store', () => {
it('returns the formatted capture for a remembered worktree', () => {
rememberBranchRenameFailureOutput('wt-1', output('Pi'))
expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBe(
'Pi exited with code 1.\n\n[stderr]\nPi failed'
)
})
it('returns null for unknown worktrees and after clearing', () => {
expect(readBranchRenameFailureOutputForDisplay('wt-unknown')).toBeNull()
rememberBranchRenameFailureOutput('wt-1', output('Pi'))
rememberBranchRenameFailureOutput('wt-1', null)
expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBeNull()
})
it('evicts the least recently recorded worktree beyond the cap', () => {
for (let index = 0; index < 33; index += 1) {
rememberBranchRenameFailureOutput(`wt-${index}`, output(`Agent${index}`))
}
expect(readBranchRenameFailureOutputForDisplay('wt-0')).toBeNull()
expect(readBranchRenameFailureOutputForDisplay('wt-32')).not.toBeNull()
})
it('re-recording refreshes recency so the entry survives eviction', () => {
for (let index = 0; index < 32; index += 1) {
rememberBranchRenameFailureOutput(`wt-${index}`, output(`Agent${index}`))
}
rememberBranchRenameFailureOutput('wt-0', output('Agent0-again'))
rememberBranchRenameFailureOutput('wt-new', output('AgentNew'))
expect(readBranchRenameFailureOutputForDisplay('wt-0')).not.toBeNull()
expect(readBranchRenameFailureOutputForDisplay('wt-1')).toBeNull()
})
})
@@ -0,0 +1,40 @@
import {
formatAgentGenerationFailureOutputForDisplay,
type AgentGenerationFailureOutput
} from '../text-generation/agent-failure-output'
// Why: the full CLI output is a diagnostic for the local user only. Keeping it
// in memory (never in worktree metadata) means nothing environment-identifying
// is persisted or synced to paired clients; a restart just loses the on-demand
// view while the sanitized excerpt badge survives.
const MAX_ENTRIES = 32
const entriesByWorktreeId = new Map<string, AgentGenerationFailureOutput>()
export function rememberBranchRenameFailureOutput(
worktreeId: string,
output: AgentGenerationFailureOutput | null | undefined
): void {
// Delete-then-set keeps insertion order as recency so eviction drops the
// stalest worktree first.
entriesByWorktreeId.delete(worktreeId)
if (!output) {
return
}
entriesByWorktreeId.set(worktreeId, output)
while (entriesByWorktreeId.size > MAX_ENTRIES) {
const oldest = entriesByWorktreeId.keys().next().value
if (oldest === undefined) {
break
}
entriesByWorktreeId.delete(oldest)
}
}
export function readBranchRenameFailureOutputForDisplay(worktreeId: string): string | null {
const entry = entriesByWorktreeId.get(worktreeId)
return entry ? formatAgentGenerationFailureOutputForDisplay(entry) : null
}
export function __resetBranchRenameFailureOutputForTests(): void {
entriesByWorktreeId.clear()
}
@@ -300,11 +300,16 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
expect(setRenameError).toHaveBeenLastCalledWith(WORKTREE_ID, null)
})
it('records a user-facing error when branch-name generation fails', async () => {
generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' })
it('records a user-facing error and the full CLI output when generation fails', async () => {
const failureOutput = { label: 'Pi', exitCode: 1, stdout: '', stderr: 'No API key found.' }
generateBranchNameMock.mockResolvedValueOnce({
success: false,
error: 'agent not ready',
failureOutput
})
const { deps, setRenameError } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready')
expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready', failureOutput)
})
it('records a user-facing error when no generation agent is configured', async () => {
@@ -341,7 +346,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' })
const { deps, setRenameError } = makeDeps()
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready')
expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready', null)
// Second event: the user has since pushed the branch, so it settles benignly.
// The stale "rename failed" badge must be cleared rather than stick forever.
@@ -28,7 +28,9 @@ import {
resolveTextGenerationParams
} from '../text-generation/commit-message-text-generation'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import type { AgentGenerationFailureOutput } from '../text-generation/agent-failure-output'
import { resolveGenerationTarget } from './first-work-generation-target'
import { runFolderWorkspaceTitleAutoRename } from './first-work-workspace-title-rename'
export type FirstWorkBranchRenameEvent = {
paneKey: string
@@ -58,8 +60,14 @@ export type FirstWorkBranchRenameDeps = {
/** Align the on-disk folder with the new branch leaf (best-effort, local-only). */
renameWorktreeFolder?: (worktreeId: string, newLeaf: string) => Promise<boolean>
/** Record (or clear with null) a user-facing auto-rename generation failure
* so the sidebar can show a "rename failed" badge instead of silent retries. */
setRenameError: (worktreeId: string, error: string | null) => void
* so the sidebar can show a "rename failed" badge instead of silent retries.
* `failureOutput` carries the bounded full CLI output for local on-demand
* display; omitted/null replaces any stale capture. */
setRenameError: (
worktreeId: string,
error: string | null,
failureOutput?: AgentGenerationFailureOutput | null
) => void
/** Authoritative tab→worktree resolution from the session's tab map. */
resolveWorktreeIdForTab: (tabId: string) => string | undefined
/** Invalidate caches + notify the renderer so the new branch name surfaces. */
@@ -249,7 +257,7 @@ async function runAutoRename(
// event retry rather than permanently leaving the creature name.
// A user-canceled generation isn't a failure to surface, so skip the badge.
if (!generated.canceled) {
deps.setRenameError(worktreeId, generated.error)
deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null)
}
return retry(`generation failed: ${generated.error}`)
}
@@ -344,56 +352,3 @@ async function runAutoRename(
)
return true
}
async function runFolderWorkspaceTitleAutoRename(
worktreeId: string,
prompt: string,
assistantMessage: string | undefined,
deps: FirstWorkBranchRenameDeps,
stop: (reason: string, clearError?: boolean) => true,
retry: (reason: string) => false
): Promise<boolean> {
if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) {
return stop('folder workspace is not pending title rename', true)
}
const folderPath = deps.getFolderWorkspacePath?.(worktreeId)
if (!folderPath) {
return stop('folder workspace path unavailable')
}
const settings = deps.getSettings()
const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null)
if (!resolvedParams.ok) {
deps.setRenameError(worktreeId, resolvedParams.error)
return stop(`no generation agent: ${resolvedParams.error}`)
}
const target = await resolveGenerationTarget(
folderPath,
resolvedParams.params.agentId,
null,
deps
)
if (!target) {
deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.')
return retry('could not prepare generation environment')
}
const generated = await generateBranchNameFromContext(
{ firstPrompt: prompt, assistantMessage },
resolvedParams.params,
target
)
if (!generated.success) {
if (!generated.canceled) {
deps.setRenameError(worktreeId, generated.error)
}
return retry(`generation failed: ${generated.error}`)
}
const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug })
deps.setDisplayName(worktreeId, newDisplayName)
deps.setRenameError(worktreeId, null)
deps.onRenamed(worktreeId)
console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`)
return true
}
@@ -0,0 +1,65 @@
import { deriveWorkspaceDisplayName } from '../../shared/display-name-from-work'
import {
generateBranchNameFromContext,
resolveTextGenerationParams
} from '../text-generation/commit-message-text-generation'
import { resolveGenerationTarget } from './first-work-generation-target'
import type { FirstWorkBranchRenameDeps } from './first-work-branch-rename'
/**
* Non-git folder workspaces have no branch to rename; the first-work hook
* instead generates a workspace title. Shares the branch-name generation
* machinery and the caller's stop/retry verdict semantics.
*/
export async function runFolderWorkspaceTitleAutoRename(
worktreeId: string,
prompt: string,
assistantMessage: string | undefined,
deps: FirstWorkBranchRenameDeps,
stop: (reason: string, clearError?: boolean) => true,
retry: (reason: string) => false
): Promise<boolean> {
if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) {
return stop('folder workspace is not pending title rename', true)
}
const folderPath = deps.getFolderWorkspacePath?.(worktreeId)
if (!folderPath) {
return stop('folder workspace path unavailable')
}
const settings = deps.getSettings()
const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null)
if (!resolvedParams.ok) {
deps.setRenameError(worktreeId, resolvedParams.error)
return stop(`no generation agent: ${resolvedParams.error}`)
}
const target = await resolveGenerationTarget(
folderPath,
resolvedParams.params.agentId,
null,
deps
)
if (!target) {
deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.')
return retry('could not prepare generation environment')
}
const generated = await generateBranchNameFromContext(
{ firstPrompt: prompt, assistantMessage },
resolvedParams.params,
target
)
if (!generated.success) {
if (!generated.canceled) {
deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null)
}
return retry(`generation failed: ${generated.error}`)
}
const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug })
deps.setDisplayName(worktreeId, newDisplayName)
deps.setRenameError(worktreeId, null)
deps.onRenamed(worktreeId)
console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`)
return true
}
+116 -5
View File
@@ -3330,6 +3330,30 @@ describe('AgentHookServer prompt-sent telemetry', () => {
expect(trackMock).toHaveBeenCalledTimes(2)
})
it('includes Command Code prompt interaction keys in the IPC snapshot', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
promptInteractionKey: 'command-code-transcript-user-1',
payload: { state: 'working', prompt: 'rerun', agentType: 'command-code' }
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
paneKey: PANE,
promptInteractionKey: 'command-code-transcript-user-1',
state: 'working',
prompt: 'rerun',
agentType: 'command-code'
})
})
it('dedupes Command Code direct prompt hooks followed by transcript-backed stop hooks', () => {
const server = new AgentHookServer()
@@ -3767,8 +3791,17 @@ describe('Claude hook normalization', () => {
expect(result?.payload.toolInput).toBeUndefined()
})
it('PostToolUseFailure surfaces the error text as lastAssistantMessage', () => {
const result = _internals.normalizeHookPayload(
it('PostToolUseFailure clears stale tool fields until the next PreToolUse', () => {
_internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'Edit',
tool_input: { file_path: '/src/config.ts' }
}),
'production'
)
const failed = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PostToolUseFailure',
@@ -3778,9 +3811,27 @@ describe('Claude hook normalization', () => {
}),
'production'
)
expect(result?.payload.state).toBe('working')
expect(result?.payload.toolName).toBe('Edit')
expect(result?.payload.lastAssistantMessage).toBe('file is read-only')
expect(failed?.payload).toMatchObject({
state: 'working',
lastAssistantMessage: 'file is read-only'
})
expect(failed?.payload.toolName).toBeUndefined()
expect(failed?.payload.toolInput).toBeUndefined()
const retry = _internals.normalizeHookPayload(
'claude',
buildBody({
hook_event_name: 'PreToolUse',
tool_name: 'Read',
tool_input: { file_path: '/src/config.ts' }
}),
'production'
)
expect(retry?.payload).toMatchObject({
state: 'working',
toolName: 'Read',
toolInput: '/src/config.ts'
})
})
it('PreToolUse normalizes to working + tool fields', () => {
@@ -4500,6 +4551,36 @@ describe('Cursor hook normalization', () => {
expect(result?.payload.toolInput).toBe('/repo/src/app.ts')
})
it('postToolUseFailure surfaces the error and clears stale tool fields', () => {
_internals.normalizeHookPayload(
'cursor',
buildBody({
hook_event_name: 'preToolUse',
tool_name: 'Read',
tool_input: { file_path: '/repo/src/app.ts' }
}),
'production'
)
const failed = _internals.normalizeHookPayload(
'cursor',
buildBody({
hook_event_name: 'postToolUseFailure',
tool_name: 'Read',
tool_input: { file_path: '/repo/src/app.ts' },
error_message: 'file not found'
}),
'production'
)
// Why: keeping toolName set would let the compact sidebar show the tool
// instead of the failure text, hiding the error from the user.
expect(failed?.payload).toMatchObject({
state: 'working',
lastAssistantMessage: 'file not found'
})
expect(failed?.payload.toolName).toBeUndefined()
expect(failed?.payload.toolInput).toBeUndefined()
})
it('afterAgentResponse carries text into lastAssistantMessage', () => {
const result = _internals.normalizeHookPayload(
'cursor',
@@ -5138,6 +5219,36 @@ describe('Copilot hook normalization', () => {
expect(result?.payload.toolInput).toBe('pnpm test')
})
it('PostToolUseFailure surfaces the error and clears stale tool fields', () => {
_internals.normalizeHookPayload(
'copilot',
buildBody({
hook_event_name: 'PreToolUse',
toolName: 'bash',
toolInput: { command: 'pnpm test' }
}),
'production'
)
const failed = _internals.normalizeHookPayload(
'copilot',
buildBody({
hook_event_name: 'PostToolUseFailure',
toolName: 'bash',
toolInput: { command: 'pnpm test' },
error_message: 'command not found'
}),
'production'
)
// Why: keeping toolName set would let the compact sidebar show the tool
// instead of the failure text, hiding the error from the user.
expect(failed?.payload).toMatchObject({
state: 'working',
lastAssistantMessage: 'command not found'
})
expect(failed?.payload.toolName).toBeUndefined()
expect(failed?.payload.toolInput).toBeUndefined()
})
it('PreToolUse ask_user maps to blocked and surfaces the question', () => {
_internals.normalizeHookPayload(
'copilot',
+17 -1
View File
@@ -59,6 +59,7 @@ import {
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import type { LegacyPaneKeyAliasEntry } from '../../shared/types'
import { normalizeAgentProviderSession } from '../../shared/agent-session-resume'
import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary'
export type { AgentHookSource }
@@ -251,6 +252,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
receivedAt: entry.receivedAt,
stateStartedAt: entry.stateStartedAt,
...(entry.providerSession ? { providerSession: entry.providerSession } : {}),
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
...entry.payload
}
}
@@ -653,8 +655,22 @@ export class AgentHookServer {
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
const commandCodeNewTurn =
previous !== undefined &&
isCommandCodeNewTurnWhileWorking({
agentType: payload.payload.agentType,
previousState: previous.payload.state,
incomingState: payload.payload.state,
previousPrompt: previous.payload.prompt,
incomingPrompt: payload.payload.prompt,
hasExplicitPrompt: payload.hasExplicitPrompt,
previousPromptInteractionKey: previous.promptInteractionKey,
incomingPromptInteractionKey: payload.promptInteractionKey
})
const stateStartedAt =
previous && previous.payload.state === payload.payload.state ? previous.stateStartedAt : now
previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn
? previous.stateStartedAt
: now
return {
...payload,
receivedAt: now,
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue'
describe('runKeyedSerializedOperation', () => {
it('propagates rejections to the caller but never through the stored tail', async () => {
const queues = new Map<string, Promise<void>>()
const failing = runKeyedSerializedOperation(queues, 'key', async () => {
throw new Error('write failed')
})
// Why: awaiting the tail is how reads barrier on writes; an unrelated
// failed write must not abort the reader (startup candidate discovery).
const tail = getKeyedSerializedQueueTail(queues, 'key')
await expect(failing).rejects.toThrow('write failed')
await expect(tail).resolves.toBeUndefined()
await expect(runKeyedSerializedOperation(queues, 'key', async () => 'recovered')).resolves.toBe(
'recovered'
)
})
it('serializes operations per key and clears settled queues', async () => {
const queues = new Map<string, Promise<void>>()
const events: string[] = []
let release!: () => void
const first = runKeyedSerializedOperation(queues, 'a', async () => {
events.push('first-start')
await new Promise<void>((resolve) => {
release = resolve
})
events.push('first-end')
})
const second = runKeyedSerializedOperation(queues, 'a', async () => {
events.push('second')
})
const other = runKeyedSerializedOperation(queues, 'b', async () => {
events.push('other')
})
await other
expect(events).toEqual(['first-start', 'other'])
release()
await Promise.all([first, second])
expect(events).toEqual(['first-start', 'other', 'first-end', 'second'])
await expect(getKeyedSerializedQueueTail(queues, 'a')).resolves.toBeUndefined()
expect(queues.size).toBe(0)
})
})
+33
View File
@@ -0,0 +1,33 @@
/**
* Serializes async operations per key. The stored queue tail never rejects, so
* callers may await it (e.g. read-after-write barriers) without inheriting an
* unrelated operation's failure; rejections still propagate to the enqueuer.
*/
export function runKeyedSerializedOperation<T>(
queues: Map<string, Promise<void>>,
key: string,
operation: () => Promise<T>
): Promise<T> {
const previous = queues.get(key) ?? Promise.resolve()
const current = previous.then(operation)
const queued = current.then(
() => undefined,
() => undefined
)
queues.set(key, queued)
const clear = (): void => {
if (queues.get(key) === queued) {
queues.delete(key)
}
}
queued.then(clear, clear)
return current
}
export function getKeyedSerializedQueueTail(
queues: Map<string, Promise<void>>,
key: string
): Promise<void> {
return queues.get(key) ?? Promise.resolve()
}
+458 -17
View File
@@ -1,22 +1,28 @@
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const execFileMock = vi.hoisted(() => vi.fn())
vi.mock('node:child_process', () => ({
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
execFile: execFileMock
}))
import { WslCliInstaller, _internals } from './wsl-cli-installer'
import { reconcileManagedWslCliRegistrations } from './wsl-cli-registration-reconciliation'
function makeHostStatus(
launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
) {
return {
platform: 'win32',
commandName: 'orca',
commandPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd',
pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin',
commandPath: launcherPath,
pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin',
pathConfigured: true,
launcherPath,
installMethod: 'wrapper',
@@ -28,13 +34,51 @@ function makeHostStatus(
} satisfies CliInstallStatus
}
function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin = true) {
// Frozen from v1.4.138-rc.2: the upgrade regression only reproduces when the
// persisted managed script still names the pre-native Windows batch launcher.
const PRE_RC4_MANAGED_WSL_LAUNCHER = `#!/usr/bin/env bash
set -euo pipefail
# Orca managed WSL CLI launcher
# ORCA_WIN_LAUNCHER_B64=QzpcUHJvZ3JhbSBGaWxlc1xPcmNhXHJlc291cmNlc1xiaW5cb3JjYS5jbWQ=
ORCA_WIN_LAUNCHER='C:\\Program Files\\Orca\\resources\\bin\\orca.cmd'
ORCA_BRIDGE_PS1='/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
if command -v powershell.exe >/dev/null 2>&1; then
ORCA_POWERSHELL=powershell.exe
elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then
ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe
else
echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2
exit 1
fi
ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1")
exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@"
`
function createWslRunner(
initialFile: string | null = null,
pathIncludesLocalBin = true,
options: {
initialBridge?: string | null
initialLegacyFile?: string | null
failInstall?: boolean
interopReady?: boolean
} = {}
) {
const commandPath = '/home/alice/.local/bin/orca-ide'
const legacyCommandPath = '/home/alice/.local/bin/orca'
const bridgePath = '/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
const files = new Map<string, string>()
if (initialFile !== null) {
files.set(commandPath, initialFile)
files.set(bridgePath, _internals.buildWslBridgeScript())
}
if (
options.initialBridge !== null &&
(initialFile !== null || options.initialBridge !== undefined)
) {
files.set(bridgePath, options.initialBridge ?? _internals.buildWslBridgeScript())
}
if (options.initialLegacyFile) {
files.set(legacyCommandPath, options.initialLegacyFile)
}
const calls: string[] = []
const runner = vi.fn(async (_distro: string, command: string) => {
@@ -46,6 +90,15 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
return pathIncludesLocalBin ? 'yes' : 'no'
}
if (command.includes('cat > "$command_tmp"')) {
if (options.failInstall) {
throw new Error('simulated replacement failure')
}
if (
files.has(bridgePath) &&
!files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
) {
throw new Error('__ORCA_CONFLICT__')
}
const launcher =
command.match(/cat > "\$command_tmp" <<'ORCA_WSL_CLI'\n([\s\S]*)\nORCA_WSL_CLI/)?.[1] ?? ''
const bridge =
@@ -54,20 +107,31 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
)?.[1] ?? ''
files.set(commandPath, launcher)
files.set(bridgePath, bridge)
if (files.get(legacyCommandPath)?.includes('# Orca managed WSL CLI launcher')) {
files.delete(legacyCommandPath)
}
return ''
}
if (command.includes('command -v powershell.exe')) {
return 'yes'
return options.interopReady === false ? 'no' : 'yes'
}
if (command.includes('rm -f')) {
if (command.includes(`rm -f '${commandPath}'`)) {
if (
files.has(bridgePath) &&
!files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
) {
throw new Error('__ORCA_CONFLICT__')
}
files.delete(commandPath)
files.delete(bridgePath)
}
if (
files.has(bridgePath) &&
!files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
command.includes(legacyCommandPath) &&
files.get(legacyCommandPath)?.includes('# Orca managed WSL CLI launcher')
) {
throw new Error('__ORCA_CONFLICT__')
files.delete(legacyCommandPath)
}
files.delete(commandPath)
files.delete(bridgePath)
return ''
}
if (command.includes('cat ')) {
@@ -77,6 +141,9 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
if (command.includes(bridgePath)) {
return files.get(bridgePath) ?? '__ORCA_MISSING__'
}
if (command.includes(legacyCommandPath)) {
return files.get(legacyCommandPath) ?? '__ORCA_MISSING__'
}
}
throw new Error(`Unexpected WSL command: ${command}`)
})
@@ -84,7 +151,8 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
runner,
calls,
getBridge: () => files.get(bridgePath) ?? null,
getFile: () => files.get(commandPath) ?? null
getFile: () => files.get(commandPath) ?? null,
getLegacyFile: () => files.get(legacyCommandPath) ?? null
}
}
@@ -116,11 +184,11 @@ describe('WslCliInstaller', () => {
expect(installed).toMatchObject({
state: 'installed',
pathConfigured: true,
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
})
expect(wsl.getFile()).toBe(
_internals.buildWslLauncher(
'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd',
'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe',
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
)
@@ -128,6 +196,7 @@ describe('WslCliInstaller', () => {
const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"'))
expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'")
expect(installCommand).toContain('rm -f "$legacy_command_path"')
expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]')
})
it('derives the shared WSL bridge path for current and legacy command names', () => {
@@ -291,15 +360,387 @@ describe('WslCliInstaller', () => {
await expect(installer.getStatus()).resolves.toMatchObject({
state: 'stale',
currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd',
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
})
await expect(installer.install()).resolves.toMatchObject({
state: 'installed',
currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
})
})
it('repairs the frozen pre-rc4 registration so orchestration send/reply reach native rc4', async () => {
const nativeLauncher = 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe'
const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER)
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: wsl.runner
})
const orchestrationCalls = [
['orchestration', 'send', '--type', 'heartbeat'],
['orchestration', 'send', '--type', 'worker_done'],
['orchestration', 'reply', '--message', 'line one\nline two']
]
const simulateRc4Launch = (args: string[]): number => {
const target = _internals.parseManagedLauncherTarget(wsl.getFile() ?? '')
return target?.toLowerCase().endsWith('orca.cmd') &&
args[0] === 'orchestration' &&
(args[1] === 'send' || args[1] === 'reply')
? 2
: 0
}
expect(orchestrationCalls.map(simulateRc4Launch)).toEqual([2, 2, 2])
await expect(
reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu'],
registry: {
getCandidates: async () => ['Ubuntu'],
recordObservations: async () => undefined
},
createInstaller: () => installer
})
).resolves.toEqual([
{ distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true }
])
await expect(installer.getStatus()).resolves.toMatchObject({
state: 'installed',
currentTarget: nativeLauncher
})
expect(orchestrationCalls.map(simulateRc4Launch)).toEqual([0, 0, 0])
})
it('leaves unmanaged WSL commands and conflicting bridges untouched during automatic repair', async () => {
const unmanaged = '#!/usr/bin/env bash\necho user-owned\n'
const wsl = createWslRunner(unmanaged)
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus() },
wslRunner: wsl.runner
})
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
status: { state: 'conflict' }
})
expect(wsl.getFile()).toBe(unmanaged)
expect(wsl.calls.some((command) => command.includes('cat > "$command_tmp"'))).toBe(false)
})
it('repairs a managed launcher whose bridge is missing, but preserves a conflicting bridge', async () => {
const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
const missingBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
initialBridge: null
})
const missingBridgeInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: missingBridge.runner
})
await expect(missingBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({
changed: true,
status: { state: 'installed' }
})
expect(missingBridge.getBridge()).toBe(_internals.buildWslBridgeScript())
const staleBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
initialBridge: '# Orca managed WSL CLI PowerShell bridge\nWrite-Output "stale"\n'
})
const staleBridgeInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: staleBridge.runner
})
await expect(staleBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({
changed: true,
status: { state: 'installed' }
})
expect(staleBridge.getBridge()).toBe(_internals.buildWslBridgeScript())
const conflictingBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
initialBridge: 'Write-Output "user-owned bridge"\n'
})
const conflictingBridgeInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: conflictingBridge.runner
})
// Why: a stale launcher with a user-owned bridge must surface as a
// non-throwing conflict, not retry a doomed install on every startup.
await expect(conflictingBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
managed: true,
status: { state: 'conflict' }
})
expect(conflictingBridge.getBridge()).toBe('Write-Output "user-owned bridge"\n')
expect(conflictingBridge.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
expect(
conflictingBridge.calls.some((command) => command.includes('cat > "$command_tmp"'))
).toBe(false)
})
it('retains command ownership when only the bridge conflicts', async () => {
const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
const currentLauncher = _internals.buildWslLauncher(
nativeLauncher,
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
const wsl = createWslRunner(currentLauncher, true, {
initialBridge: 'Write-Output "user-owned bridge"\n'
})
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: wsl.runner
})
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
managed: true,
status: { state: 'conflict' }
})
expect(wsl.getBridge()).toBe('Write-Output "user-owned bridge"\n')
expect(wsl.getFile()).toBe(currentLauncher)
})
it('moves a legacy-only managed registration to orca-ide without touching unmanaged names', async () => {
const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
const managedLegacy = createWslRunner(null, true, {
initialBridge: _internals.buildWslBridgeScript(),
initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER
})
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: managedLegacy.runner
})
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
changed: true,
status: { state: 'installed', currentTarget: nativeLauncher }
})
expect(managedLegacy.getLegacyFile()).toBeNull()
const unmanagedLegacy = createWslRunner(null, true, {
initialLegacyFile: '#!/bin/sh\necho user-owned\n'
})
const unmanagedInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: unmanagedLegacy.runner
})
await expect(unmanagedInstaller.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
status: { state: 'not_installed' }
})
expect(unmanagedLegacy.getLegacyFile()).toBe('#!/bin/sh\necho user-owned\n')
})
it('does not adopt a legacy-managed registration when the bridge is user-owned', async () => {
const wsl = createWslRunner(null, true, {
initialBridge: 'Write-Output "user-owned bridge"\n',
initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER
})
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus() },
wslRunner: wsl.runner
})
// Why: adoption would fail install()'s bridge guard on every startup;
// repair must report blocked-but-managed instead of a doomed install.
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
managed: true,
status: { state: 'not_installed' }
})
expect(wsl.getLegacyFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
expect(wsl.calls.some((command) => command.includes('cat > "$command_tmp"'))).toBe(false)
})
it('removes the managed legacy launcher on removal so reconciliation cannot re-adopt it', async () => {
const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
const managedLegacy = createWslRunner(null, true, {
initialBridge: _internals.buildWslBridgeScript(),
initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER
})
const managedLegacyInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: managedLegacy.runner
})
await expect(managedLegacyInstaller.remove()).resolves.toMatchObject({
state: 'not_installed'
})
expect(managedLegacy.getLegacyFile()).toBeNull()
const unmanagedLegacy = createWslRunner(null, true, {
initialLegacyFile: '#!/bin/sh\necho user-owned\n'
})
const unmanagedInstaller2 = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: unmanagedLegacy.runner
})
await expect(unmanagedInstaller2.remove()).resolves.toMatchObject({
state: 'not_installed'
})
expect(unmanagedLegacy.getLegacyFile()).toBe('#!/bin/sh\necho user-owned\n')
const installedWithLegacy = createWslRunner(
_internals.buildWslLauncher(
nativeLauncher,
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
),
true,
{ initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER }
)
const installedInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: installedWithLegacy.runner
})
await expect(installedInstaller.remove()).resolves.toMatchObject({ state: 'not_installed' })
expect(installedWithLegacy.getFile()).toBeNull()
expect(installedWithLegacy.getLegacyFile()).toBeNull()
})
it('keeps the pre-rc4 files on a transactional replacement failure', async () => {
const bridge = _internals.buildWslBridgeScript()
const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
initialBridge: bridge,
failInstall: true
})
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: {
getStatus: async () => makeHostStatus('C:\\Program Files\\Orca\\resources\\bin\\orca.exe')
},
wslRunner: wsl.runner
})
await expect(installer.repairManagedRegistration()).rejects.toThrow(
'simulated replacement failure'
)
expect(wsl.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
expect(wsl.getBridge()).toBe(bridge)
const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"'))
expect(installCommand).toContain('rollback() {')
expect(installCommand).toContain('set +e')
expect(installCommand).toContain('bridge_backup="${bridge_tmp}.backup"')
expect(installCommand).toContain('cp -p')
expect(installCommand).toContain('elif [ "$bridge_touched" -eq 1 ]')
expect(installCommand).toContain('committed=1')
expect(installCommand).toContain('flock -x -w 30 9')
// Why: the command replace must stay one atomic rename; a mv-based backup
// would leave a window where a concurrent shell finds no orca-ide at all.
expect(installCommand).not.toContain('command_backup')
expect(installCommand).not.toContain(`mv -f '/home/alice/.local/bin/orca-ide'`)
})
it.skipIf(process.platform === 'win32')(
'rolls both files back when the command replacement fails after the bridge move',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-rollback-'))
const home = join(root, 'home with spaces')
const commandPath = join(home, '.local', 'bin', 'orca-ide')
const bridgePath = join(home, '.local', 'share', 'orca', 'orca-wsl-bridge.ps1')
const bridge = _internals.buildWslBridgeScript()
await mkdir(join(home, '.local', 'bin'), { recursive: true })
await mkdir(join(home, '.local', 'share', 'orca'), { recursive: true })
await writeFile(commandPath, PRE_RC4_MANAGED_WSL_LAUNCHER, 'utf8')
await writeFile(bridgePath, bridge, 'utf8')
const runner = async (_distro: string, command: string): Promise<string> => {
if (command.includes('printf %s "$HOME"')) {
return home
}
if (command.includes('cat > "$command_tmp"')) {
const executableCommand = command
.split('\n')
.map((line) => (line.startsWith('mv -f "$command_tmp" ') ? 'exit 71' : line))
.join('\n')
return execFileSync('bash', ['-c', executableCommand], { encoding: 'utf8' })
}
if (command.includes('command -v powershell.exe')) {
return 'yes'
}
if (command.includes('case ":$PATH:"')) {
return 'yes'
}
return execFileSync('bash', ['-c', command], { encoding: 'utf8' })
}
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: {
getStatus: async () => makeHostStatus('C:\\Program Files\\Orca\\resources\\bin\\orca.exe')
},
wslRunner: runner
})
try {
await expect(installer.repairManagedRegistration()).rejects.toThrow()
await expect(readFile(commandPath, 'utf8')).resolves.toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
await expect(readFile(bridgePath, 'utf8')).resolves.toBe(bridge)
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
it('skips automatic repair when WSL interop is unavailable', async () => {
const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { interopReady: false })
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus() },
wslRunner: wsl.runner
})
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
status: { state: 'unsupported' }
})
expect(wsl.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
})
it('is idempotent after repairing an old managed registration', async () => {
const nativeLauncher = 'D:\\Custom Orca\\resources\\bin\\orca.exe'
const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER)
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: wsl.runner
})
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ changed: true })
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ changed: false })
expect(wsl.calls.filter((command) => command.includes('cat > "$command_tmp"'))).toHaveLength(1)
expect(wsl.getFile()).toContain("ORCA_WIN_LAUNCHER='D:\\Custom Orca\\resources\\bin\\orca.exe'")
})
it('settles when wsl.exe never reports completion', async () => {
vi.useFakeTimers()
const killMock = vi.fn()
+112 -12
View File
@@ -5,6 +5,8 @@ import type { CliInstallStatus } from '../../shared/cli-install-types'
import { getDefaultWslDistro } from '../wsl'
import { CliInstaller } from './cli-installer'
import {
buildManagedLegacyRemoveCommand,
buildRegistrationLockPrelude,
buildSafeRemoveCommand,
buildSafeReplaceGuard,
buildWslBridgeScript,
@@ -38,6 +40,12 @@ type WslCliInstallerOptions = {
wslRunner?: (distro: string, command: string) => Promise<string>
}
export type ManagedWslCliRepairResult = {
changed: boolean
managed: boolean
status: CliInstallStatus
}
export class WslCliInstaller {
private readonly platform: NodeJS.Platform
private readonly distro: string | null
@@ -119,21 +127,80 @@ export class WslCliInstaller {
})
}
// Why: a stale managed launcher is only repairable when its bridge is
// ours too; reporting conflict here keeps repair from a doomed install
// whose bridge guard would fail on every startup.
const bridgeConflict = managed && (await this.isBridgeConflict(ready.distro, ready.bridgePath))
return this.buildStatus({
distro: ready.distro,
commandPath: ready.commandPath,
launcherPath: ready.launcherPath,
state: managed ? 'stale' : 'conflict',
state: managed && !bridgeConflict ? 'stale' : 'conflict',
currentTarget,
pathConfigured: ready.pathConfigured,
detail: managed
? `${ready.commandPath} points to a different Orca launcher.`
: `${ready.commandPath} exists but is not managed by Orca.`
detail: !managed
? `${ready.commandPath} exists but is not managed by Orca.`
: bridgeConflict
? `${ready.bridgePath} exists but is not managed by Orca.`
: `${ready.commandPath} points to a different Orca launcher.`
})
}
async install(): Promise<CliInstallStatus> {
private async isBridgeConflict(distro: string, bridgePath: string): Promise<boolean> {
const bridgeContent = await this.readCommandFile(distro, bridgePath)
if (bridgeContent === null) {
return false
}
return bridgeContent === 'not_file' || !bridgeContent.includes(BRIDGE_MANAGED_MARKER)
}
async repairManagedRegistration(): Promise<ManagedWslCliRepairResult> {
const status = await this.getStatus()
if (!status.supported) {
return { changed: false, managed: false, status }
}
if (status.state === 'conflict') {
// Why: a user-owned bridge conflicts with repair, but the launcher is
// still Orca-managed and must remain registered for future reconciliation.
return { changed: false, managed: status.currentTarget !== null, status }
}
if (status.state === 'stale') {
return { changed: true, managed: true, status: await this.install(status) }
}
const legacyCommandPath = status.commandPath
? `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
: null
if (!legacyCommandPath || !this.distro) {
return { changed: false, managed: status.state === 'installed', status }
}
const legacyContent = await this.readCommandFile(this.distro, legacyCommandPath)
const legacyManaged =
typeof legacyContent === 'string' && legacyContent.includes(MANAGED_MARKER)
if (!legacyManaged) {
return { changed: false, managed: status.state === 'installed', status }
}
if (
status.commandPath &&
(await this.isBridgeConflict(this.distro, getBridgePathFromCommandPath(status.commandPath)))
) {
// Why: adopting the legacy command would fail install()'s bridge guard
// forever; stay registered so reconciliation retries after an update.
return { changed: false, managed: true, status }
}
// Why: a legacy-only managed command proves the user opted into WSL CLI
// registration; install the current name before removing that owned script.
return { changed: true, managed: true, status: await this.install(status) }
}
async install(precomputedStatus?: CliInstallStatus): Promise<CliInstallStatus> {
// Why: repair passes its fresh probe; re-probing here would double every
// WSL round trip on the startup reconciliation path.
const status = precomputedStatus ?? (await this.getStatus())
if (!status.supported || !status.commandPath || !status.launcherPath) {
throw new Error(status.detail ?? 'WSL CLI registration is unavailable.')
}
@@ -141,20 +208,36 @@ export class WslCliInstaller {
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
}
// Why: the launcher and PowerShell bridge are one registration; the
// command replacement stays a single atomic rename (never missing for a
// concurrent shell) while a bridge copy enables rollback of the pair.
await this.run(
this.distro as string,
[
'set -euo pipefail',
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
`mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`,
buildRegistrationLockPrelude(status.commandPath),
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
`bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
`legacy_command_path=${quoteShell(
`${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
)}`,
'bridge_tmp="${bridge_path}.tmp.$$"',
'cleanup() { rm -f "$command_tmp" "$bridge_tmp"; }',
'trap cleanup EXIT',
'bridge_backup="${bridge_tmp}.backup"',
'bridge_had_original=0',
'bridge_touched=0',
'committed=0',
'rollback() {',
' result=$?',
' set +e',
' if [ "$committed" -ne 1 ]; then',
` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; fi`,
' fi',
' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"',
' exit "$result"',
'}',
'trap rollback EXIT',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(
getBridgePathFromCommandPath(status.commandPath),
@@ -173,11 +256,15 @@ export class WslCliInstaller {
getBridgePathFromCommandPath(status.commandPath),
BRIDGE_MANAGED_MARKER
),
// Why: the command was renamed to avoid GNOME Orca; remove only the
// old Orca-managed WSL wrapper so unmanaged `orca` commands survive.
`if [ -f "$legacy_command_path" ] && grep -Fq ${quoteShell(MANAGED_MARKER)} "$legacy_command_path"; then rm -f "$legacy_command_path"; fi`,
`if [ -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} ]; then cp -p ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} "$bridge_backup"; bridge_had_original=1; fi`,
`mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
'bridge_touched=1',
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
'committed=1',
'rm -f "$bridge_backup"',
// Why: the command was renamed to avoid GNOME Orca; remove only the
// old Orca-managed WSL wrapper after the replacement has committed.
buildManagedLegacyRemoveCommand('"$legacy_command_path"'),
'trap - EXIT'
].join('\n')
)
@@ -189,14 +276,26 @@ export class WslCliInstaller {
if (!status.supported || !status.commandPath) {
return status
}
const legacyCommandPath = `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
if (status.state === 'not_installed') {
// Why: a managed legacy `orca` left behind would later be re-adopted by
// startup reconciliation as opt-in proof, silently undoing this removal.
await this.run(
this.distro as string,
['set -euo pipefail', buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))].join(
'\n'
)
)
return status
}
if (status.state === 'conflict') {
throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`)
}
await this.run(this.distro as string, buildSafeRemoveCommand(status.commandPath))
await this.run(
this.distro as string,
buildSafeRemoveCommand(status.commandPath, legacyCommandPath)
)
return this.getStatus()
}
@@ -411,5 +510,6 @@ export const _internals = {
buildEncodedWslBashCommand,
buildWslBridgeScript,
buildWslLauncher,
getBridgePathFromCommandPath
getBridgePathFromCommandPath,
parseManagedLauncherTarget
}
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation'
describe('runSerializedWslCliRegistrationOperation', () => {
it('serializes operations for distro names with different casing', async () => {
let releaseFirst!: () => void
const events: string[] = []
const first = runSerializedWslCliRegistrationOperation('Ubuntu', async () => {
events.push('first-start')
await new Promise<void>((resolve) => {
releaseFirst = resolve
})
events.push('first-end')
})
await vi.waitFor(() => expect(events).toEqual(['first-start']))
const second = runSerializedWslCliRegistrationOperation(' ubuntu ', async () => {
events.push('second')
})
await Promise.resolve()
expect(events).toEqual(['first-start'])
releaseFirst()
await Promise.all([first, second])
expect(events).toEqual(['first-start', 'first-end', 'second'])
})
it('allows different distros to progress independently', async () => {
let releaseUbuntu!: () => void
const events: string[] = []
const ubuntu = runSerializedWslCliRegistrationOperation('Ubuntu', async () => {
events.push('ubuntu-start')
await new Promise<void>((resolve) => {
releaseUbuntu = resolve
})
})
const debian = runSerializedWslCliRegistrationOperation('Debian', async () => {
events.push('debian')
})
await debian
expect(events).toEqual(['ubuntu-start', 'debian'])
releaseUbuntu()
await ubuntu
})
it('releases the queue after an operation fails', async () => {
await expect(
runSerializedWslCliRegistrationOperation('Ubuntu', async () => {
throw new Error('interop failed')
})
).rejects.toThrow('interop failed')
await expect(
runSerializedWslCliRegistrationOperation('ubuntu', async () => 'recovered')
).resolves.toBe('recovered')
})
})
@@ -0,0 +1,23 @@
import { runKeyedSerializedOperation } from './keyed-promise-queue'
const operationQueues = new Map<string, Promise<void>>()
/**
* Canonical key for a WSL distro name. The operation queue and the
* registration registry must agree on this to serialize against each other.
*/
export function normalizeWslDistroKey(distro: string): string {
return distro.trim().toLowerCase()
}
/**
* Serializes registration reads and mutations for one WSL distro.
*/
export function runSerializedWslCliRegistrationOperation<T>(
distro: string,
operation: () => Promise<T>
): Promise<T> {
// Why: startup repair continues in the background and can otherwise undo a
// concurrent Settings install/remove or overwrite its ownership metadata.
return runKeyedSerializedOperation(operationQueues, normalizeWslDistroKey(distro), operation)
}
@@ -0,0 +1,229 @@
import { describe, expect, it, vi } from 'vitest'
import { reconcileManagedWslCliRegistrations } from './wsl-cli-registration-reconciliation'
import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation'
describe('reconcileManagedWslCliRegistrations', () => {
it('repairs known and newly discovered registrations, then records ownership', async () => {
const registry = {
getCandidates: vi.fn(async () => ['Ubuntu', 'Debian']),
recordObservations: vi.fn(async () => undefined)
}
const repairUbuntu = vi.fn(async () => ({
changed: true,
managed: true,
status: { state: 'installed' as const }
}))
const repairDebian = vi.fn(async () => ({
changed: false,
managed: false,
status: { state: 'not_installed' as const }
}))
const results = await reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu', 'Debian', 'Fedora'],
registry,
createInstaller: (distro) => {
if (distro === 'Ubuntu') {
return { repairManagedRegistration: repairUbuntu }
}
return { repairManagedRegistration: repairDebian }
}
})
expect(registry.getCandidates).toHaveBeenCalledWith(['Ubuntu', 'Debian', 'Fedora'], {
currentTarget: null,
appVersion: ''
})
expect(registry.recordObservations).toHaveBeenCalledTimes(2)
expect(registry.recordObservations).toHaveBeenCalledWith([
{ distro: 'Ubuntu', inspected: true, managed: true }
])
expect(registry.recordObservations).toHaveBeenCalledWith([
{ distro: 'Debian', inspected: true, managed: false, reconciled: null }
])
expect(results).toEqual([
{ distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true },
{ distro: 'Debian', outcome: 'unchanged', state: 'not_installed', managed: false }
])
})
it('passes the host launcher target through and records reconciliations against it', async () => {
const registry = {
getCandidates: vi.fn(async () => ['Ubuntu']),
recordObservations: vi.fn(async () => undefined)
}
await reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
appVersion: '1.4.138',
listDistros: async () => ['Ubuntu'],
getHostLauncherTarget: async () => 'C:\\Orca\\resources\\bin\\orca.exe',
registry,
createInstaller: () => ({
repairManagedRegistration: async () => ({
changed: true,
managed: true,
status: { state: 'installed' as const }
})
})
})
expect(registry.getCandidates).toHaveBeenCalledWith(['Ubuntu'], {
currentTarget: 'C:\\Orca\\resources\\bin\\orca.exe',
appVersion: '1.4.138'
})
expect(registry.recordObservations).toHaveBeenCalledWith([
{
distro: 'Ubuntu',
inspected: true,
managed: true,
reconciled: { target: 'C:\\Orca\\resources\\bin\\orca.exe', appVersion: '1.4.138' }
}
])
})
it('records unsupported distros without changing ownership and skips failed ones', async () => {
const registry = {
getCandidates: vi.fn(async () => ['Broken Distro', 'No Interop']),
recordObservations: vi.fn(async () => undefined)
}
const results = await reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Broken Distro', 'No Interop'],
registry,
createInstaller: (distro) => ({
repairManagedRegistration: async () => {
if (distro === 'Broken Distro') {
throw new Error('WSL interop failed')
}
return {
changed: false,
managed: false,
status: { state: 'unsupported' as const }
}
}
})
})
expect(results).toEqual([
{ distro: 'Broken Distro', outcome: 'failed', error: 'WSL interop failed' },
{ distro: 'No Interop', outcome: 'unchanged', state: 'unsupported', managed: false }
])
expect(registry.recordObservations).toHaveBeenCalledTimes(1)
// Why: unsupported gets a TTL-stamped inspection (managed: null) so an
// interop-off distro is not re-probed and VM-booted on every startup.
expect(registry.recordObservations).toHaveBeenCalledWith([
{ distro: 'No Interop', inspected: true, managed: null, reconciled: null }
])
})
it('keeps a successful repair result when observation recording fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const results = await reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu'],
registry: {
getCandidates: async () => ['Ubuntu'],
recordObservations: async () => {
throw new Error('ENOSPC')
}
},
createInstaller: () => ({
repairManagedRegistration: async () => ({
changed: true,
managed: true,
status: { state: 'installed' as const }
})
})
})
expect(results).toEqual([
{ distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true }
])
expect(warn).toHaveBeenCalledOnce()
} finally {
warn.mockRestore()
}
})
it.each([
{ platform: 'darwin' as const, isPackaged: true },
{ platform: 'linux' as const, isPackaged: true },
{ platform: 'win32' as const, isPackaged: false }
])('does not inspect local, SSH, or development hosts for $platform', async (host) => {
const listDistros = vi.fn(async () => ['Ubuntu'])
await expect(
reconcileManagedWslCliRegistrations({
...host,
userDataPath: '/user-data',
listDistros
})
).resolves.toEqual([])
expect(listDistros).not.toHaveBeenCalled()
})
it('lets a Settings removal win over a late startup repair for the same distro', async () => {
const events: string[] = []
let repairStarted!: () => void
let finishRepair!: () => void
const started = new Promise<void>((resolve) => {
repairStarted = resolve
})
const registry = {
getCandidates: vi.fn(async () => ['Ubuntu']),
recordObservations: vi.fn(async () => {
events.push('repair-observed')
})
}
const reconciliation = reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu'],
registry,
createInstaller: () => ({
repairManagedRegistration: async () => {
events.push('repair-started')
repairStarted()
await new Promise<void>((resolve) => {
finishRepair = resolve
})
events.push('repair-finished')
return {
changed: true,
managed: true,
status: { state: 'installed' as const }
}
}
})
})
await started
const removal = runSerializedWslCliRegistrationOperation('ubuntu', async () => {
events.push('settings-remove')
})
await Promise.resolve()
expect(events).toEqual(['repair-started'])
finishRepair()
await Promise.all([reconciliation, removal])
expect(events).toEqual([
'repair-started',
'repair-finished',
'repair-observed',
'settings-remove'
])
})
})
@@ -0,0 +1,175 @@
import type { CliInstallState, CliInstallStatus } from '../../shared/cli-install-types'
import { listWslDistrosAsync } from '../wsl'
import { CliInstaller } from './cli-installer'
import {
getWslCliRegistrationCandidates,
recordWslCliRegistrationObservations,
type WslCliRegistrationObservation
} from './wsl-cli-registration-registry'
import { WslCliInstaller } from './wsl-cli-installer'
import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation'
// Why: candidate distros can each boot a stopped WSL VM; a small cap staggers
// those boots instead of spiking RAM/CPU for every distro at once at startup.
const MAX_CONCURRENT_DISTRO_REPAIRS = 2
type ManagedWslCliInstaller = {
repairManagedRegistration: () => Promise<{
changed: boolean
managed: boolean
status: { state: CliInstallState }
}>
}
type WslCliRegistrationCandidateContext = {
currentTarget?: string | null
appVersion?: string | null
}
type WslCliRegistrationRegistry = {
getCandidates: (
availableDistros: string[],
context?: WslCliRegistrationCandidateContext
) => Promise<string[]>
recordObservations: (observations: WslCliRegistrationObservation[]) => Promise<void>
}
type WslCliRegistrationReconciliationOptions = {
platform?: NodeJS.Platform
isPackaged: boolean
userDataPath: string
appVersion?: string
listDistros?: () => Promise<string[]>
createInstaller?: (distro: string) => ManagedWslCliInstaller
getHostLauncherTarget?: () => Promise<string | null>
registry?: WslCliRegistrationRegistry
}
export type WslCliRegistrationReconciliationResult =
| {
distro: string
outcome: 'repaired' | 'unchanged'
state: CliInstallState
managed: boolean
}
| {
distro: string
outcome: 'failed'
error: string
}
export async function reconcileManagedWslCliRegistrations(
options: WslCliRegistrationReconciliationOptions
): Promise<WslCliRegistrationReconciliationResult[]> {
const platform = options.platform ?? process.platform
if (platform !== 'win32' || !options.isPackaged) {
return []
}
const registry =
options.registry ??
({
getCandidates: (availableDistros, context) =>
getWslCliRegistrationCandidates(options.userDataPath, availableDistros, context ?? {}),
recordObservations: (observations) =>
recordWslCliRegistrationObservations(options.userDataPath, observations)
} satisfies WslCliRegistrationRegistry)
let createInstaller = options.createInstaller
let getHostLauncherTarget = options.getHostLauncherTarget
if (!createInstaller) {
const hostInstaller = new CliInstaller()
let hostStatus: Promise<CliInstallStatus> | null = null
// Why: every distro must target this app install; share one Windows PATH /
// launcher probe instead of spawning a PowerShell probe per distro. A
// rejected probe is evicted so one transient failure cannot poison the run.
const getHostStatus = (): Promise<CliInstallStatus> =>
(hostStatus ??= hostInstaller.getStatus().catch((error) => {
hostStatus = null
throw error
}))
createInstaller = (distro: string) =>
new WslCliInstaller({
distro,
hostInstaller: { getStatus: getHostStatus }
})
getHostLauncherTarget ??= () => getHostStatus().then((status) => status.launcherPath)
}
const availableDistros = await (options.listDistros ?? listWslDistrosAsync)()
const currentTarget = getHostLauncherTarget
? await getHostLauncherTarget().catch(() => null)
: null
const appVersion = options.appVersion ?? ''
const distros = await registry.getCandidates(availableDistros, { currentTarget, appVersion })
if (distros.length === 0) {
return []
}
const reconcileDistro = async (
distro: string
): Promise<WslCliRegistrationReconciliationResult> => {
let repair: Awaited<ReturnType<ManagedWslCliInstaller['repairManagedRegistration']>>
try {
repair = await createInstaller(distro).repairManagedRegistration()
} catch (error) {
return {
distro,
outcome: 'failed',
error: error instanceof Error ? error.message : String(error)
}
}
const result: WslCliRegistrationReconciliationResult = {
distro,
outcome: repair.changed ? 'repaired' : 'unchanged',
state: repair.status.state,
managed: repair.managed
}
const observation: WslCliRegistrationObservation =
repair.status.state === 'unsupported'
? // Why: managed-ness is unknowable without interop; stamp the
// inspection (negative TTL) without changing registration ownership.
{ distro, inspected: true, managed: null, reconciled: null }
: repair.managed
? {
distro,
inspected: true,
managed: true,
...(currentTarget ? { reconciled: { target: currentTarget, appVersion } } : {})
}
: { distro, inspected: true, managed: false, reconciled: null }
try {
// Why: ownership metadata must commit before a concurrent Settings
// operation can mutate this distro, or stale startup state can win.
await registry.recordObservations([observation])
} catch (error) {
// Why: the repair already succeeded on disk; an advisory bookkeeping
// failure must not reclassify it, mirroring the Settings IPC contract.
console.warn(
`[wsl-cli] Failed to record ${distro} registration observation:`,
error instanceof Error ? error.message : String(error)
)
}
return result
}
const results: WslCliRegistrationReconciliationResult[] = Array.from({
length: distros.length
})
let nextIndex = 0
await Promise.all(
Array.from({ length: Math.min(MAX_CONCURRENT_DISTRO_REPAIRS, distros.length) }, async () => {
for (;;) {
const index = nextIndex++
if (index >= distros.length) {
return
}
const distro = distros[index]
results[index] = await runSerializedWslCliRegistrationOperation(distro, () =>
reconcileDistro(distro)
)
}
})
)
return results
}
@@ -0,0 +1,233 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
getWslCliRegistrationCandidates,
recordWslCliRegistrationObservations,
recordWslCliRegistrationRemoved
} from './wsl-cli-registration-registry'
describe('WSL CLI registration registry', () => {
let userDataPath: string
beforeEach(async () => {
userDataPath = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-registry-'))
})
afterEach(async () => {
await rm(userDataPath, { recursive: true, force: true })
})
it('discovers each distro once while continuing to reconcile managed registrations', async () => {
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'])
).resolves.toEqual(['Ubuntu', 'Debian'])
await recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Ubuntu', inspected: true, managed: false },
{ distro: 'Debian', inspected: true, managed: true }
])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['ubuntu', 'Debian', 'Fedora'])
).resolves.toEqual(['Debian', 'Fedora'])
const state = JSON.parse(
await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
) as Record<string, unknown>
expect(state).toMatchObject({
schemaVersion: 2,
registeredDistros: ['Debian'],
inspectionTimes: {
ubuntu: expect.any(Number),
debian: expect.any(Number)
}
})
})
it('skips a registered distro already reconciled by this build against this launcher', async () => {
const reconciled = { target: 'C:\\Orca\\resources\\bin\\orca.exe', appVersion: '1.4.138' }
await recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Ubuntu', inspected: true, managed: true, reconciled }
])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
currentTarget: reconciled.target,
appVersion: reconciled.appVersion
})
).resolves.toEqual([])
// A launcher move or app update re-probes the registered distro.
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
currentTarget: 'D:\\Elsewhere\\orca.exe',
appVersion: reconciled.appVersion
})
).resolves.toEqual(['Ubuntu'])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
currentTarget: reconciled.target,
appVersion: '1.4.139'
})
).resolves.toEqual(['Ubuntu'])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
currentTarget: reconciled.target,
appVersion: reconciled.appVersion,
now: Date.now() + 365 * 24 * 60 * 60 * 1_000
})
).resolves.toEqual([])
})
it('records unsupported inspections without changing registration ownership', async () => {
await recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Ubuntu', inspected: true, managed: true }
])
await recordWslCliRegistrationObservations(
userDataPath,
[{ distro: 'Ubuntu', inspected: true, managed: null, reconciled: null }],
{ now: 1_000 }
)
const state = JSON.parse(
await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
) as { registeredDistros: string[] }
expect(state.registeredDistros).toEqual(['Ubuntu'])
await recordWslCliRegistrationObservations(
userDataPath,
[{ distro: 'Fedora', inspected: true, managed: null }],
{ now: 1_000 }
)
// Unregistered unsupported distros gain the negative-inspection TTL.
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Fedora'], {
now: 2_000,
negativeInspectionTtlMs: 10_000
})
).resolves.toEqual([])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Fedora'], {
now: 12_000,
negativeInspectionTtlMs: 10_000
})
).resolves.toEqual(['Fedora'])
})
it('serializes concurrent registry updates without losing a distro', async () => {
const updates = Promise.all([
recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Ubuntu', inspected: true, managed: true }
]),
recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Debian', inspected: true, managed: true }
])
])
// Reads join the write queue, so startup cannot observe a half-updated registry.
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'])
).resolves.toEqual(['Ubuntu', 'Debian'])
await updates
const state = JSON.parse(
await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
) as { registeredDistros: string[] }
expect(state.registeredDistros).toEqual(['Ubuntu', 'Debian'])
})
it('rediscovers available distros when the registry is corrupt', async () => {
await mkdir(userDataPath, { recursive: true })
await writeFile(join(userDataPath, 'wsl-cli-registrations.json'), '{broken', 'utf8')
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'])
).resolves.toEqual(['Ubuntu', 'Debian'])
})
it('stops reconciling a registration removed through Settings', async () => {
await recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Ubuntu', inspected: true, managed: true }
])
await recordWslCliRegistrationRemoved(userDataPath, 'ubuntu')
await expect(getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'])).resolves.toEqual([])
})
it('periodically re-inspects a negative entry so restored distros are discovered', async () => {
await recordWslCliRegistrationObservations(
userDataPath,
[{ distro: 'Ubuntu', inspected: true, managed: false }],
{ now: 1_000 }
)
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
now: 1_001,
negativeInspectionTtlMs: 10_000
})
).resolves.toEqual([])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
now: 11_001,
negativeInspectionTtlMs: 10_000
})
).resolves.toEqual(['Ubuntu'])
})
it('rediscovers negative entries after the system clock moves backward', async () => {
await recordWslCliRegistrationObservations(
userDataPath,
[{ distro: 'Ubuntu', inspected: true, managed: false }],
{ now: 100_000 }
)
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
now: 1_000,
negativeInspectionTtlMs: 10_000
})
).resolves.toEqual(['Ubuntu'])
})
it('safely rediscovers schema-v1 negative entries with no inspection time', async () => {
await writeFile(
join(userDataPath, 'wsl-cli-registrations.json'),
JSON.stringify({
schemaVersion: 1,
registeredDistros: ['Debian'],
inspectedDistros: ['Ubuntu', 'Debian']
}),
'utf8'
)
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'], {
now: 1,
negativeInspectionTtlMs: 1_000_000
})
).resolves.toEqual(['Ubuntu', 'Debian'])
})
it('caps inspection bookkeeping while always keeping registered distros', async () => {
const observations = Array.from({ length: 70 }, (_, index) => ({
distro: `Distro${index}`,
inspected: true,
managed: false as const
}))
for (const [index, observation] of observations.entries()) {
await recordWslCliRegistrationObservations(userDataPath, [observation], { now: index })
}
await recordWslCliRegistrationObservations(
userDataPath,
[{ distro: 'Managed Oldest', inspected: true, managed: true }],
{ now: 0 }
)
const state = JSON.parse(
await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
) as { registeredDistros: string[]; inspectionTimes: Record<string, number> }
expect(state.registeredDistros).toEqual(['Managed Oldest'])
expect(Object.keys(state.inspectionTimes).length).toBeLessThanOrEqual(65)
expect(state.inspectionTimes['managed oldest']).toBe(0)
})
})
@@ -0,0 +1,279 @@
import { mkdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue'
import { normalizeWslDistroKey } from './wsl-cli-registration-operation'
const REGISTRY_FILE_NAME = 'wsl-cli-registrations.json'
const REGISTRY_SCHEMA_VERSION = 2
const DEFAULT_NEGATIVE_INSPECTION_TTL_MS = 7 * 24 * 60 * 60 * 1_000
// Why: the registry is advisory; cap per-distro bookkeeping so hosts that
// cycle many uniquely named distros cannot grow the file without bound.
const MAX_INSPECTION_ENTRIES = 64
type WslCliRegistrationReconciliation = {
target: string
appVersion: string
}
type WslCliRegistrationRegistryState = {
schemaVersion: 2
registeredDistros: string[]
inspectionTimes: Record<string, number>
reconciliations: Record<string, WslCliRegistrationReconciliation>
}
type WslCliRegistrationRegistryTiming = {
now?: number
negativeInspectionTtlMs?: number
// Why: a registered distro already reconciled against this exact launcher
// by this exact app build has nothing to repair; skipping it avoids booting
// its VM on every startup while still re-probing after each app update.
currentTarget?: string | null
appVersion?: string | null
}
export type WslCliRegistrationObservation = {
distro: string
inspected: boolean
// Why: null records an inspection without changing registration ownership —
// used for 'unsupported' probes where managed-ness could not be determined.
managed: boolean | null
reconciled?: WslCliRegistrationReconciliation | null
}
const writeQueues = new Map<string, Promise<void>>()
function emptyState(): WslCliRegistrationRegistryState {
return {
schemaVersion: REGISTRY_SCHEMA_VERSION,
registeredDistros: [],
inspectionTimes: {},
reconciliations: {}
}
}
function uniqueDistros(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
const seen = new Set<string>()
const distros: string[] = []
for (const entry of value) {
if (typeof entry !== 'string' || !entry.trim()) {
continue
}
const distro = entry.trim()
const key = normalizeWslDistroKey(distro)
if (!seen.has(key)) {
seen.add(key)
distros.push(distro)
}
}
return distros
}
function parseReconciliations(value: unknown): Record<string, WslCliRegistrationReconciliation> {
if (!value || typeof value !== 'object') {
return {}
}
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, WslCliRegistrationReconciliation] =>
!!entry[1] &&
typeof entry[1] === 'object' &&
typeof (entry[1] as WslCliRegistrationReconciliation).target === 'string' &&
typeof (entry[1] as WslCliRegistrationReconciliation).appVersion === 'string'
)
)
}
function parseState(content: string): WslCliRegistrationRegistryState {
try {
const parsed = JSON.parse(content) as Record<string, unknown>
if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== REGISTRY_SCHEMA_VERSION) {
return emptyState()
}
const inspectionTimes =
parsed.inspectionTimes && typeof parsed.inspectionTimes === 'object'
? Object.fromEntries(
Object.entries(parsed.inspectionTimes).filter(
(entry): entry is [string, number] =>
typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] >= 0
)
)
: {}
return {
schemaVersion: REGISTRY_SCHEMA_VERSION,
registeredDistros: uniqueDistros(parsed.registeredDistros),
inspectionTimes,
reconciliations: parseReconciliations(parsed.reconciliations)
}
} catch {
// Why: a corrupt advisory registry must trigger safe rediscovery rather
// than preventing managed registrations from receiving future updates.
return emptyState()
}
}
function isMissingError(error: unknown): boolean {
return (error as NodeJS.ErrnoException)?.code === 'ENOENT'
}
function getRegistryPath(userDataPath: string): string {
return join(userDataPath, REGISTRY_FILE_NAME)
}
async function readState(userDataPath: string): Promise<WslCliRegistrationRegistryState> {
try {
return parseState(await readFile(getRegistryPath(userDataPath), 'utf8'))
} catch (error) {
if (isMissingError(error)) {
return emptyState()
}
throw error
}
}
function upsertDistro(distros: string[], distro: string): string[] {
const key = normalizeWslDistroKey(distro)
const existingIndex = distros.findIndex((entry) => normalizeWslDistroKey(entry) === key)
if (existingIndex < 0) {
return [...distros, distro.trim()]
}
return distros.map((entry, index) => (index === existingIndex ? distro.trim() : entry))
}
function removeDistro(distros: string[], distro: string): string[] {
const key = normalizeWslDistroKey(distro)
return distros.filter((entry) => normalizeWslDistroKey(entry) !== key)
}
async function writeState(
userDataPath: string,
state: WslCliRegistrationRegistryState
): Promise<void> {
await mkdir(userDataPath, { recursive: true })
// Why: userData writes on Windows can hit Chromium's Protected-DACL EPERM;
// writeFileAtomically carries the ACL-repair retry a plain rename lacks.
writeFileAtomically(getRegistryPath(userDataPath), `${JSON.stringify(state, null, 2)}\n`)
}
function capInspectionEntries(
state: WslCliRegistrationRegistryState
): WslCliRegistrationRegistryState {
const registered = new Set(state.registeredDistros.map(normalizeWslDistroKey))
const entries = Object.entries(state.inspectionTimes)
const inspectionTimes =
entries.length <= MAX_INSPECTION_ENTRIES
? state.inspectionTimes
: Object.fromEntries(
entries
.sort((a, b) => b[1] - a[1])
.filter((entry, index) => index < MAX_INSPECTION_ENTRIES || registered.has(entry[0]))
)
const reconciliations = Object.fromEntries(
Object.entries(state.reconciliations).filter(([key]) => registered.has(key))
)
return { ...state, inspectionTimes, reconciliations }
}
function updateState(
userDataPath: string,
update: (state: WslCliRegistrationRegistryState) => WslCliRegistrationRegistryState
): Promise<void> {
return runKeyedSerializedOperation(writeQueues, getRegistryPath(userDataPath), async () => {
await writeState(userDataPath, capInspectionEntries(update(await readState(userDataPath))))
})
}
export async function getWslCliRegistrationCandidates(
userDataPath: string,
availableDistros: string[],
timing: WslCliRegistrationRegistryTiming = {}
): Promise<string[]> {
// Why: the stored queue tail never rejects, so a failed concurrent write
// cannot abort candidate discovery; reads still see fully applied updates.
await getKeyedSerializedQueueTail(writeQueues, getRegistryPath(userDataPath))
const state = await readState(userDataPath)
const registered = new Set(state.registeredDistros.map(normalizeWslDistroKey))
const now = timing.now ?? Date.now()
const negativeInspectionTtlMs =
timing.negativeInspectionTtlMs ?? DEFAULT_NEGATIVE_INSPECTION_TTL_MS
return uniqueDistros(availableDistros).filter((distro) => {
const key = normalizeWslDistroKey(distro)
if (registered.has(key)) {
const reconciliation = state.reconciliations[key]
return !(
reconciliation &&
timing.currentTarget &&
reconciliation.target === timing.currentTarget &&
reconciliation.appVersion === (timing.appVersion ?? '')
)
}
const inspectedAt = state.inspectionTimes[key]
return (
inspectedAt === undefined || inspectedAt > now || now - inspectedAt >= negativeInspectionTtlMs
)
})
}
export function recordWslCliRegistrationObservations(
userDataPath: string,
observations: WslCliRegistrationObservation[],
timing: Pick<WslCliRegistrationRegistryTiming, 'now'> = {}
): Promise<void> {
const effective = observations.filter(
(observation) => observation.inspected && observation.distro.trim()
)
if (effective.length === 0) {
return Promise.resolve()
}
return updateState(userDataPath, (state) => {
let registeredDistros = state.registeredDistros
let inspectionTimes = state.inspectionTimes
let reconciliations = state.reconciliations
const now = timing.now ?? Date.now()
for (const observation of effective) {
const key = normalizeWslDistroKey(observation.distro)
inspectionTimes = { ...inspectionTimes, [key]: now }
if (observation.managed === true) {
registeredDistros = upsertDistro(registeredDistros, observation.distro)
} else if (observation.managed === false) {
registeredDistros = removeDistro(registeredDistros, observation.distro)
}
if (observation.reconciled !== undefined) {
if (observation.reconciled === null) {
const { [key]: _removed, ...rest } = reconciliations
reconciliations = rest
} else {
reconciliations = { ...reconciliations, [key]: observation.reconciled }
}
}
}
return {
schemaVersion: REGISTRY_SCHEMA_VERSION,
registeredDistros,
inspectionTimes,
reconciliations
}
})
}
export function recordWslCliRegistrationInstalled(
userDataPath: string,
distro: string
): Promise<void> {
return recordWslCliRegistrationObservations(userDataPath, [
{ distro, inspected: true, managed: true }
])
}
export function recordWslCliRegistrationRemoved(
userDataPath: string,
distro: string
): Promise<void> {
return recordWslCliRegistrationObservations(userDataPath, [
{ distro, inspected: true, managed: false, reconciled: null }
])
}
+24 -2
View File
@@ -71,13 +71,35 @@ export function buildSafeReplaceGuard(path: string, managedMarker: string): stri
].join('\n')
}
export function buildSafeRemoveCommand(commandPath: string): string {
export function buildRegistrationLockPrelude(commandPath: string): string {
const lockDir = getPosixDirname(getBridgePathFromCommandPath(commandPath))
// Why: the per-distro queue only serializes one Orca process; flock covers
// a second install (e.g. stable + nightly) mutating the same distro files.
return [
`if command -v flock >/dev/null 2>&1 && mkdir -p ${quoteShell(lockDir)} 2>/dev/null; then`,
` exec 9>${quoteShell(`${lockDir}/.orca-wsl-cli.lock`)}`,
' flock -x -w 30 9',
'fi'
].join('\n')
}
export function buildManagedLegacyRemoveCommand(quotedLegacyCommandPath: string): string {
// Why: remove only the Orca-managed pre-rename wrapper; user-owned `orca`
// commands and symlinks must survive.
return `if [ ! -L ${quotedLegacyCommandPath} ] && [ -f ${quotedLegacyCommandPath} ] && grep -Fq ${quoteShell(MANAGED_MARKER)} ${quotedLegacyCommandPath}; then rm -f ${quotedLegacyCommandPath}; fi`
}
export function buildSafeRemoveCommand(commandPath: string, legacyCommandPath?: string): string {
const bridgePath = getBridgePathFromCommandPath(commandPath)
return [
'set -euo pipefail',
buildRegistrationLockPrelude(commandPath),
buildSafeReplaceGuard(commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
`rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`
`rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`,
// Why: leaving a managed legacy `orca` behind lets startup reconciliation
// re-adopt it as opt-in proof and silently undo this removal.
...(legacyCommandPath ? [buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))] : [])
].join('\n')
}
@@ -97,6 +97,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
floatingTerminalTriggerLocation: 'floating-button',
diffDefaultView: 'inline',
combinedDiffFileTreeVisibleByDefault: false,
prBotAuthorOverrides: [],
notifications: {
enabled: true,
agentTaskComplete: true,
+1
View File
@@ -102,6 +102,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
floatingTerminalTriggerLocation: 'floating-button',
diffDefaultView: 'inline',
combinedDiffFileTreeVisibleByDefault: false,
prBotAuthorOverrides: [],
notifications: {
enabled: true,
agentTaskComplete: true,
+46 -2
View File
@@ -85,6 +85,7 @@ import {
import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect'
import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect'
import { startFirstWindowStartupServices } from './startup/first-window-startup-services'
import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier'
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
import {
@@ -133,6 +134,7 @@ import { StarNagService } from './star-nag/service'
import { agentHookServer } from './agent-hooks/server'
import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager'
import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename'
import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output'
import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename'
import { moveWorktree } from './git/worktree'
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
@@ -186,6 +188,7 @@ import { applyElectronProxySettings } from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation'
import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal'
let mainWindow: BrowserWindow | null = null
@@ -224,6 +227,8 @@ let keybindings: KeybindingService | null = null
const expectedRendererReload = createWebContentsTimedFlag()
const recoveryReloadInFlight = createWebContentsTimedFlag()
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
let managedWslCliReconciliationReady: Promise<void> = Promise.resolve()
let managedWslCliStartupBarrierReady: Promise<void> = Promise.resolve()
// Why: GPU child crashes clustered right after launch indicate a broken driver;
// track them so Orca can move this build onto software rendering.
const gpuLaunchTimeMs = Date.now()
@@ -320,6 +325,7 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true
},
setDisplayName: (worktreeId, displayName) => {
rememberBranchRenameFailureOutput(worktreeId, null)
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
currentStore.updateFolderWorkspace(scope.folderWorkspaceId, {
@@ -351,7 +357,10 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
moveWorktree
})
: undefined,
setRenameError: (worktreeId, error) => {
setRenameError: (worktreeId, error, failureOutput) => {
// Refresh the local-only full-output capture before the dedupe below:
// a repeat of the same error string still comes from a fresh run.
rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput)
// Skip the write + renderer push when nothing changes — benign skips
// clear the error on every settled worktree, most of which never had one.
const scope = parseWorkspaceKey(worktreeId)
@@ -605,7 +614,9 @@ if (hasSingleInstanceLock) {
}
ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
await firstWindowStartupServicesReady
// Why: window rendering and local RPC startup stay independent, but restored
// WSL terminals get a bounded chance to receive launcher repairs first.
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
})
ipcMain.handle(
@@ -1017,6 +1028,7 @@ function openMainWindow(): BrowserWindow {
stateStartedAt,
launchToken,
providerSession,
promptInteractionKey,
isReplay
}) => {
if (mainWindow?.isDestroyed()) {
@@ -1036,6 +1048,7 @@ function openMainWindow(): BrowserWindow {
receivedAt,
stateStartedAt,
...(providerSession ? { providerSession } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(orchestration ? { orchestration } : {})
})
recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state)
@@ -1595,6 +1608,34 @@ app.whenReady().then(async () => {
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
app.setName(devInstanceIdentity.name)
// Why: managed WSL launchers live outside the Windows app bundle, so keep
// their launcher and bridge contract synchronized across app updates.
managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({
isPackaged: app.isPackaged,
userDataPath: getCanonicalUserDataPath(),
appVersion: app.getVersion()
})
.then((results) => {
for (const result of results) {
if (result.outcome === 'failed') {
console.warn(
`[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}`
)
} else if (result.outcome === 'repaired') {
console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`)
}
}
})
.catch((error) => {
console.warn(
'[wsl-cli] Managed registration reconciliation discovery failed:',
error instanceof Error ? error.message : String(error)
)
})
managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier(
managedWslCliReconciliationReady
)
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
logStartupMilestone('store-loaded')
@@ -2049,6 +2090,9 @@ app.whenReady().then(async () => {
}
if (serveOptions) {
// Why: headless serve has no renderer startup barrier, so settle managed
// WSL command reconciliation before exposing its runtime transport.
await managedWslCliReconciliationReady
await startServeAgentHookServer()
registerHeadlessPtyRuntime(
runtime,
+148
View File
@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
ipcHandleMock,
wslInstallerMock,
recordInstalledMock,
recordRemovedMock,
getDefaultWslDistroMock
} = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>(),
ipcHandleMock: vi.fn(),
wslInstallerMock: vi.fn(),
recordInstalledMock: vi.fn(),
recordRemovedMock: vi.fn(),
getDefaultWslDistroMock: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: ipcHandleMock } }))
vi.mock('../cli/cli-installer', () => ({ CliInstaller: vi.fn() }))
vi.mock('../cli/wsl-cli-installer', () => ({ WslCliInstaller: wslInstallerMock }))
vi.mock('../cli/wsl-cli-registration-registry', () => ({
recordWslCliRegistrationInstalled: recordInstalledMock,
recordWslCliRegistrationRemoved: recordRemovedMock
}))
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/canonical-user-data' }))
vi.mock('../startup/hydrate-shell-path', () => ({
hydrateShellPath: vi.fn(async () => ({ ok: false })),
mergePathSegments: vi.fn()
}))
vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock }))
import { registerCliHandlers } from './cli'
type WslHandler = (event: unknown, args?: { distro?: string | null }) => Promise<{ state: string }>
function getWslHandler(channel: string): WslHandler {
const handler = handlers.get(channel)
if (!handler) {
throw new Error(`Missing IPC handler: ${channel}`)
}
return handler as WslHandler
}
describe('WSL CLI registration IPC', () => {
beforeEach(() => {
handlers.clear()
ipcHandleMock.mockReset()
ipcHandleMock.mockImplementation(
(channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler)
}
)
wslInstallerMock.mockReset()
recordInstalledMock.mockReset().mockResolvedValue(undefined)
recordRemovedMock.mockReset().mockResolvedValue(undefined)
getDefaultWslDistroMock.mockReset().mockReturnValue('Ubuntu')
registerCliHandlers()
})
it('records a successful explicit-distro installation', async () => {
const install = vi.fn(async () => ({ state: 'installed' }))
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { install }
})
await expect(getWslHandler('cli:installWsl')({}, { distro: ' Debian ' })).resolves.toEqual({
state: 'installed'
})
expect(wslInstallerMock).toHaveBeenCalledWith({ distro: 'Debian' })
expect(recordInstalledMock).toHaveBeenCalledWith('/canonical-user-data', 'Debian')
})
it('records removal from the resolved default distro', async () => {
const remove = vi.fn(async () => ({ state: 'not_installed' }))
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { remove }
})
await expect(getWslHandler('cli:removeWsl')({})).resolves.toEqual({ state: 'not_installed' })
expect(wslInstallerMock).toHaveBeenCalledWith({ distro: 'Ubuntu' })
expect(recordRemovedMock).toHaveBeenCalledWith('/canonical-user-data', 'Ubuntu')
})
it('does not claim ownership when installation is not confirmed', async () => {
const install = vi.fn(async () => ({ state: 'unsupported' }))
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { install }
})
await expect(getWslHandler('cli:installWsl')({})).resolves.toEqual({ state: 'unsupported' })
expect(recordInstalledMock).not.toHaveBeenCalled()
})
it('keeps successful installation successful when advisory registry persistence fails', async () => {
const install = vi.fn(async () => ({ state: 'installed' }))
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { install }
})
recordInstalledMock.mockRejectedValueOnce(new Error('ENOSPC'))
await expect(getWslHandler('cli:installWsl')({})).resolves.toEqual({ state: 'installed' })
})
it('keeps successful removal successful when advisory registry persistence fails', async () => {
const remove = vi.fn(async () => ({ state: 'not_installed' }))
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { remove }
})
recordRemovedMock.mockRejectedValueOnce(new Error('EACCES'))
await expect(getWslHandler('cli:removeWsl')({})).resolves.toEqual({ state: 'not_installed' })
})
it('serializes a concurrent removal after installation and ownership persistence', async () => {
let finishInstall!: () => void
const install = vi.fn(
() =>
new Promise<{ state: 'installed' }>((resolve) => {
finishInstall = () => resolve({ state: 'installed' })
})
)
const remove = vi.fn(async () => ({ state: 'not_installed' as const }))
wslInstallerMock
.mockImplementationOnce(function MockInstallWslCliInstaller() {
return { install }
})
.mockImplementationOnce(function MockRemoveWslCliInstaller() {
return { remove }
})
const installation = getWslHandler('cli:installWsl')({}, { distro: 'Ubuntu' })
await vi.waitFor(() => expect(install).toHaveBeenCalledOnce())
const removal = getWslHandler('cli:removeWsl')({}, { distro: 'ubuntu' })
await Promise.resolve()
expect(remove).not.toHaveBeenCalled()
finishInstall()
await expect(installation).resolves.toEqual({ state: 'installed' })
await expect(removal).resolves.toEqual({ state: 'not_installed' })
expect(recordInstalledMock.mock.invocationCallOrder[0]).toBeLessThan(
remove.mock.invocationCallOrder[0]
)
expect(remove.mock.invocationCallOrder[0]).toBeLessThan(
recordRemovedMock.mock.invocationCallOrder[0]
)
})
})
+62 -3
View File
@@ -1,13 +1,49 @@
import { ipcMain } from 'electron'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { CliInstaller } from '../cli/cli-installer'
import {
recordWslCliRegistrationInstalled,
recordWslCliRegistrationRemoved
} from '../cli/wsl-cli-registration-registry'
import { WslCliInstaller } from '../cli/wsl-cli-installer'
import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registration-operation'
import { getCanonicalUserDataPath } from '../persistence'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import { getDefaultWslDistro } from '../wsl'
function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined {
return args?.distro?.trim() || undefined
}
function resolveWslCliDistro(args?: { distro?: string | null }): string | null {
return normalizeWslCliDistro(args) ?? getDefaultWslDistro()
}
function runWslCliRegistrationOperation<T>(
distro: string | null,
operation: () => Promise<T>
): Promise<T> {
return distro ? runSerializedWslCliRegistrationOperation(distro, operation) : operation()
}
async function persistWslCliRegistration(
operation: () => Promise<void>,
action: 'install' | 'remove'
): Promise<void> {
try {
await operation()
} catch (error) {
// Why: the WSL file operation already succeeded; advisory metadata must
// not turn that success into a false Settings failure. The atomic write
// left the prior registry intact, and repair is disk-authoritative, so a
// stale entry self-corrects on the next startup probe.
console.warn(
`[wsl-cli] Failed to persist ${action} registration metadata:`,
error instanceof Error ? error.message : String(error)
)
}
}
async function hydrateLocalShellPathForCli(force = false): Promise<void> {
if (process.platform === 'win32') {
return
@@ -39,21 +75,44 @@ export function registerCliHandlers(): void {
ipcMain.handle(
'cli:getWslInstallStatus',
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).getStatus()
// Why: status is a read-only probe; queuing it behind a long-running
// repair/install would hang the Settings spinner for its duration, and
// Settings re-polls, so a rare transient read self-corrects.
return new WslCliInstaller({ distro: resolveWslCliDistro(args) }).getStatus()
}
)
ipcMain.handle(
'cli:installWsl',
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).install()
const distro = resolveWslCliDistro(args)
return runWslCliRegistrationOperation(distro, async () => {
const status = await new WslCliInstaller({ distro }).install()
if (distro && status.state === 'installed') {
await persistWslCliRegistration(
() => recordWslCliRegistrationInstalled(getCanonicalUserDataPath(), distro),
'install'
)
}
return status
})
}
)
ipcMain.handle(
'cli:removeWsl',
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).remove()
const distro = resolveWslCliDistro(args)
return runWslCliRegistrationOperation(distro, async () => {
const status = await new WslCliInstaller({ distro }).remove()
if (distro && status.state === 'not_installed') {
await persistWslCliRegistration(
() => recordWslCliRegistrationRemoved(getCanonicalUserDataPath(), distro),
'remove'
)
}
return status
})
}
)
}
@@ -0,0 +1,102 @@
import { lstat, readdir, realpath } from 'node:fs/promises'
import { isAbsolute, join, relative, sep } from 'node:path'
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
export async function captureLocalUploadRoot(
sourcePath: string,
sourceStat: Awaited<ReturnType<typeof lstat>>
): Promise<string> {
const rootRealPath = await realpath(sourcePath)
const rootRealStat = await lstat(rootRealPath)
if (
statIdentityPartChanged(sourceStat.ino, rootRealStat.ino) ||
statIdentityPartChanged(sourceStat.dev, rootRealStat.dev) ||
!rootRealStat.isDirectory()
) {
throw new Error(`Upload source changed while being inspected: ${sourcePath}`)
}
return rootRealPath
}
export async function preScanSshImportDirectory(
dirPath: string,
remotePathFlavor: RemotePathFlavor
): Promise<boolean> {
const entries = await readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
assertSafeRemotePathSegment(entry.name, remotePathFlavor)
if (entry.isSymbolicLink()) {
return true
}
if (entry.isDirectory()) {
const childPath = join(dirPath, entry.name)
if (await preScanSshImportDirectory(childPath, remotePathFlavor)) {
return true
}
}
}
return false
}
export async function uploadSshImportDirectory(
provider: IFilesystemProvider,
uploadSession: FileUploadSession,
localDir: string,
remoteDir: string,
rootRealPath: string,
remotePathFlavor: RemotePathFlavor
): Promise<void> {
await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
const entries = await readdir(localDir, { withFileTypes: true })
for (const entry of entries) {
assertSafeRemotePathSegment(entry.name, remotePathFlavor)
const localPath = join(localDir, entry.name)
const remotePath = `${remoteDir}/${entry.name}`
await assertLocalUploadPathInsideRoot(rootRealPath, localPath)
const statResult = await lstat(localPath)
// Why: skip symlinks and special files even after the up-front pre-scan;
// this closes the TOCTOU gap if one is created during upload.
if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) {
continue
}
if (statResult.isDirectory()) {
await provider.createDirNoClobber(remotePath)
await uploadSshImportDirectory(
provider,
uploadSession,
localPath,
remotePath,
rootRealPath,
remotePathFlavor
)
continue
}
await uploadSession.uploadFile(localPath, remotePath, { exclusive: true })
}
}
function statIdentityPartChanged(
left: number | bigint | undefined,
right: number | bigint | undefined
): boolean {
const leftKnown = left !== undefined && left !== 0 && left !== 0n
const rightKnown = right !== undefined && right !== 0 && right !== 0n
return leftKnown && rightKnown && left !== right
}
async function assertLocalUploadPathInsideRoot(
rootRealPath: string,
candidatePath: string
): Promise<void> {
const candidateRealPath = await realpath(candidatePath)
const relativeToRoot = relative(rootRealPath, candidateRealPath)
if (
relativeToRoot !== '' &&
(relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot))
) {
throw new Error(`Upload source escapes selected directory: ${candidatePath}`)
}
}
@@ -0,0 +1,274 @@
import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
const { getConnMgrMock, lstatMock, providerMock, readdirMock, realpathMock } = vi.hoisted(() => ({
getConnMgrMock: vi.fn(),
lstatMock: vi.fn(),
providerMock: vi.fn(),
readdirMock: vi.fn(),
realpathMock: vi.fn()
}))
vi.mock('node:fs/promises', () => ({
lstat: lstatMock,
readdir: readdirMock,
realpath: realpathMock
}))
vi.mock('./filesystem-auth', () => ({
authorizeExternalPath: vi.fn(),
isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT'
}))
vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
requireSshFilesystemProvider: providerMock
}))
import { importExternalPathsSsh } from './filesystem-import-ssh'
function createProvider(uploadSession: FileUploadSession): IFilesystemProvider {
const missing = Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
return {
stat: vi.fn().mockRejectedValue(missing),
createDirNoClobber: vi.fn().mockResolvedValue(undefined),
deletePath: vi.fn().mockResolvedValue(undefined),
openFileUploadSession: vi.fn().mockResolvedValue(uploadSession)
} as unknown as IFilesystemProvider
}
describe('SSH import remote path safety', () => {
const connectionId = 'ssh-windows'
const destDir = 'C:/Users/me/project/.orca/drops'
let provider: IFilesystemProvider
let uploadSession: FileUploadSession
beforeEach(() => {
vi.clearAllMocks()
getConnMgrMock.mockReturnValue({
getConnection: () => ({ getState: () => ({ status: 'connected' }) })
})
uploadSession = {
uploadFile: vi.fn().mockResolvedValue(undefined),
close: vi.fn()
}
provider = createProvider(uploadSession)
providerMock.mockReturnValue(provider)
readdirMock.mockResolvedValue([])
realpathMock.mockImplementation(async (value: string) => value)
})
it.runIf(process.platform !== 'win32')(
'rejects a POSIX filename containing Windows traversal before remote stat',
async () => {
const sourcePath = '/tmp/..\\..\\.ssh\\orca_drop'
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
expect(results[0]).toMatchObject({
status: 'failed',
reason: expect.stringContaining('Unsafe')
})
expect(lstatMock).not.toHaveBeenCalled()
expect(provider.stat).not.toHaveBeenCalled()
expect(provider.createDirNoClobber).not.toHaveBeenCalled()
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
expect(provider.deletePath).not.toHaveBeenCalled()
}
)
it.each(['report.txt:orca', 'NUL', 'trailing.', 'question?.txt'])(
'rejects Windows-special top-level name %j before remote stat',
async (name) => {
const { results } = await importExternalPathsSsh([`/tmp/${name}`], destDir, connectionId)
expect(results[0]).toMatchObject({
status: 'failed',
reason: expect.stringContaining('Unsafe')
})
expect(lstatMock).not.toHaveBeenCalled()
expect(provider.stat).not.toHaveBeenCalled()
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
}
)
it('rejects a nested Windows traversal name before creating the import root', async () => {
const sourcePath = path.resolve('/tmp/assets')
lstatMock.mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
})
readdirMock.mockResolvedValue([
{
name: '..\\..\\.ssh\\orca_drop',
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}
])
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
expect(results[0]).toMatchObject({
status: 'failed',
reason: expect.stringContaining('Unsafe')
})
expect(provider.stat).not.toHaveBeenCalled()
expect(provider.createDirNoClobber).not.toHaveBeenCalled()
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
expect(provider.deletePath).not.toHaveBeenCalled()
})
it('keeps exclusive creation for safe file names', async () => {
const sourcePath = path.resolve('/tmp/report.txt')
lstatMock.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
})
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
expect(results[0]).toMatchObject({ status: 'imported', destPath: `${destDir}/report.txt` })
expect(uploadSession.uploadFile).toHaveBeenCalledWith(sourcePath, `${destDir}/report.txt`, {
exclusive: true
})
})
it.runIf(process.platform !== 'win32')(
'preserves backslashes in valid POSIX names for POSIX remotes',
async () => {
const sourcePath = path.resolve('/tmp/notes\\2026.txt')
const posixDestDir = '/home/me/project'
lstatMock.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
})
const { results } = await importExternalPathsSsh([sourcePath], posixDestDir, connectionId)
expect(results[0]).toMatchObject({
status: 'imported',
destPath: `${posixDestDir}/notes\\2026.txt`
})
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
sourcePath,
`${posixDestDir}/notes\\2026.txt`,
{ exclusive: true }
)
}
)
it('streams an N-file directory through guarded per-file uploads', async () => {
const sourcePath = path.resolve('/tmp/assets')
const names = ['one.txt', 'two.txt', 'three.txt']
const entries = names.map((name) => ({
name,
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
}))
lstatMock.mockImplementation(async (value: string) =>
value === sourcePath
? { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
: { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
)
readdirMock.mockResolvedValue(entries)
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
expect(uploadSession.uploadFile).toHaveBeenCalledTimes(3)
for (const name of names) {
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
path.join(sourcePath, name),
`${destDir}/assets/${name}`,
{ exclusive: true }
)
}
})
it('skips special entries without failing the directory import', async () => {
const sourcePath = path.resolve('/tmp/assets')
const regularPath = path.join(sourcePath, 'report.txt')
const socketPath = path.join(sourcePath, 'server.sock')
const entries = [
{
name: 'report.txt',
isFile: () => true,
isDirectory: () => false,
isSymbolicLink: () => false
},
{
name: 'server.sock',
isFile: () => false,
isDirectory: () => false,
isSymbolicLink: () => false
}
]
lstatMock.mockImplementation(async (value: string) => {
if (value === sourcePath) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
if (value === regularPath) {
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
}
expect(value).toBe(socketPath)
return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => false }
})
readdirMock.mockResolvedValue(entries)
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
expect(uploadSession.uploadFile).toHaveBeenCalledOnce()
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
regularPath,
`${destDir}/assets/report.txt`,
{ exclusive: true }
)
})
it('rejects a selected root replaced after its realpath is captured', async () => {
const sourcePath = path.resolve('/tmp/assets')
lstatMock.mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
})
realpathMock.mockResolvedValueOnce(sourcePath).mockResolvedValueOnce('/private/replacement')
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
expect(results[0]).toMatchObject({
status: 'failed',
reason: expect.stringContaining('escapes selected directory')
})
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true)
})
it('rejects a selected root replaced while its realpath is captured', async () => {
const sourcePath = path.resolve('/tmp/assets')
const directoryStat = (ino: number) => ({
dev: 1,
ino,
isFile: () => false,
isDirectory: () => true,
isSymbolicLink: () => false
})
lstatMock.mockResolvedValueOnce(directoryStat(1)).mockResolvedValueOnce(directoryStat(2))
realpathMock.mockResolvedValue('/private/replacement')
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
expect(results[0]).toMatchObject({
status: 'failed',
reason: expect.stringContaining('changed while being inspected')
})
expect(provider.createDirNoClobber).not.toHaveBeenCalled()
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
expect(provider.deletePath).not.toHaveBeenCalled()
})
})
+36 -79
View File
@@ -1,10 +1,17 @@
import { lstat, readdir, realpath } from 'node:fs/promises'
import { basename, join, posix, resolve } from 'node:path'
import { lstat } from 'node:fs/promises'
import { basename, posix, resolve } from 'node:path'
import { authorizeExternalPath, isENOENT } from './filesystem-auth'
import { getSshConnectionManager } from './ssh'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
import type { ImportItemResult } from './filesystem-mutations'
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import {
captureLocalUploadRoot,
preScanSshImportDirectory,
uploadSshImportDirectory
} from './filesystem-import-ssh-directory'
// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so
// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows.
@@ -47,6 +54,10 @@ export async function importExternalPathsSsh(
throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.')
}
const uploadSession = await provider.openFileUploadSession()
// Why: filename legality follows the remote filesystem, not the client's OS.
const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir)
? 'windows'
: 'posix'
try {
for (const sourcePath of sourcePaths) {
const result = await importOneSourceSsh(
@@ -54,7 +65,8 @@ export async function importExternalPathsSsh(
uploadSession,
sourcePath,
destDir,
reservedNames
reservedNames,
remotePathFlavor
)
results.push(result)
if (result.status === 'imported') {
@@ -76,12 +88,24 @@ async function importOneSourceSsh(
uploadSession: FileUploadSession,
sourcePath: string,
destDir: string,
reservedNames: Set<string>
reservedNames: Set<string>,
remotePathFlavor: RemotePathFlavor
): Promise<ImportItemResult> {
const resolvedSource = resolve(sourcePath)
authorizeExternalPath(resolvedSource)
const originalName = basename(resolvedSource)
try {
assertSafeRemotePathSegment(originalName, remotePathFlavor)
} catch (error) {
return {
sourcePath,
status: 'failed',
reason: error instanceof Error ? error.message : String(error)
}
}
let sourceStat: Awaited<ReturnType<typeof lstat>>
try {
sourceStat = await lstat(resolvedSource)
@@ -114,17 +138,13 @@ async function importOneSourceSsh(
const isDir = sourceStat.isDirectory()
if (isDir) {
const hasSymlink = await preScanForSymlinks(resolvedSource)
if (hasSymlink) {
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
}
const originalName = basename(resolvedSource)
let createdDestDir: string | null = null
try {
const rootRealPath = isDir ? await captureLocalUploadRoot(resolvedSource, sourceStat) : null
if (isDir && (await preScanSshImportDirectory(resolvedSource, remotePathFlavor))) {
return { sourcePath, status: 'skipped', reason: 'symlink' }
}
const finalName = await deconflictName(provider, destDir, originalName, reservedNames)
const destPath = `${destDir}/${finalName}`
const renamed = finalName !== originalName
@@ -132,12 +152,13 @@ async function importOneSourceSsh(
if (isDir) {
await provider.createDirNoClobber(destPath)
createdDestDir = destPath
await uploadDirectoryViaProvider(
await uploadSshImportDirectory(
provider,
uploadSession,
resolvedSource,
destPath,
await realpath(resolvedSource)
rootRealPath!,
remotePathFlavor
)
} else {
await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true })
@@ -217,36 +238,6 @@ async function ensureDropStagingDir(provider: IFilesystemProvider, destDir: stri
await provider.createDir(destDir)
}
async function uploadDirectoryViaProvider(
provider: IFilesystemProvider,
uploadSession: FileUploadSession,
localDir: string,
remoteDir: string,
rootRealPath: string
): Promise<void> {
await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
const entries = await readdir(localDir, { withFileTypes: true })
for (const entry of entries) {
const localPath = join(localDir, entry.name)
const remotePath = `${remoteDir}/${entry.name}`
await assertLocalUploadPathInsideRoot(rootRealPath, localPath)
const statResult = await lstat(localPath)
// Why: skip symlinks and special files even after the up-front pre-scan;
// this closes the TOCTOU gap if one is created during upload.
if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) {
continue
}
if (statResult.isDirectory()) {
await provider.createDirNoClobber(remotePath)
await uploadDirectoryViaProvider(provider, uploadSession, localPath, remotePath, rootRealPath)
continue
}
await uploadSession.uploadFile(localPath, remotePath, { exclusive: true })
}
}
async function remotePathExists(
provider: IFilesystemProvider,
remotePath: string
@@ -274,37 +265,3 @@ function isRemoteMissingError(error: unknown): boolean {
)
)
}
async function preScanForSymlinks(dirPath: string): Promise<boolean> {
const entries = await readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isSymbolicLink()) {
return true
}
if (entry.isDirectory()) {
const childPath = join(dirPath, entry.name)
if (await preScanForSymlinks(childPath)) {
return true
}
}
}
return false
}
async function assertLocalUploadPathInsideRoot(
rootRealPath: string,
candidatePath: string
): Promise<void> {
const candidateRealPath = await realpath(candidatePath)
const root = rootRealPath.replace(/[\\/]+$/g, '')
const candidate = candidateRealPath.replace(/[\\/]+$/g, '')
const rootComparable = process.platform === 'win32' ? root.toLowerCase() : root
const candidateComparable = process.platform === 'win32' ? candidate.toLowerCase() : candidate
if (candidateComparable === rootComparable) {
return
}
const boundary = process.platform === 'win32' ? '\\' : '/'
if (!candidateComparable.startsWith(`${rootComparable}${boundary}`)) {
throw new Error(`Upload source escapes selected directory: ${candidatePath}`)
}
}
@@ -300,6 +300,48 @@ describe('local filesystem watcher unsubscribe cleanup', () => {
}
})
it('aborts an opening local watcher when the last listener unwatches', async () => {
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {}
const unsubscribeMock = vi.fn()
vi.mocked(subscribeParcelWatcher).mockImplementation(
() =>
new Promise((resolve) => {
resolveSubscribe = resolve as typeof resolveSubscribe
})
)
const sender = {
isDestroyed: () => false,
send: vi.fn(),
once: vi.fn(),
id: 1
}
const watchPromise = handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/tmp/repo' }
) as Promise<unknown>
await vi.waitFor(() => {
expect(subscribeParcelWatcher).toHaveBeenCalled()
})
const unwatchPromise = handlers['fs:unwatchWorktree'](
{ sender },
{ worktreePath: '/tmp/repo' }
) as Promise<unknown>
// Why: unwatch must abort the in-flight install so watchWorktree settles
// without waiting for the native subscribe crawl to finish.
const watchSettledEarly = await Promise.race([
watchPromise.then(() => true),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 20))
])
resolveSubscribe({ unsubscribe: unsubscribeMock })
await Promise.all([watchPromise, unwatchPromise])
expect(watchSettledEarly).toBe(true)
expect(unsubscribeMock).toHaveBeenCalledTimes(1)
})
it('cancels an opening local watcher for worktree deletion', async () => {
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {}
@@ -325,9 +367,50 @@ describe('local filesystem watcher unsubscribe cleanup', () => {
expect(subscribeParcelWatcher).toHaveBeenCalled()
})
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo')
const closedBeforeNativeSubscribe = await Promise.race([
closePromise.then(() => true),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 0))
])
resolveSubscribe({ unsubscribe: unsubscribeMock })
await Promise.all([watchPromise, closePromise])
expect(closedBeforeNativeSubscribe).toBe(true)
expect(unsubscribeMock).toHaveBeenCalledTimes(1)
})
it('cancels an opening local watcher during app shutdown', async () => {
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
let resolveSubscribe: (subscription: { unsubscribe: () => void }) => void = () => {}
const unsubscribeMock = vi.fn()
vi.mocked(subscribeParcelWatcher).mockImplementation(
() =>
new Promise((resolve) => {
resolveSubscribe = resolve as typeof resolveSubscribe
})
)
const sender = {
isDestroyed: () => false,
send: vi.fn(),
once: vi.fn(),
id: 1
}
const watchPromise = handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/tmp/repo' }
) as Promise<unknown>
await vi.waitFor(() => {
expect(subscribeParcelWatcher).toHaveBeenCalled()
})
const shutdownPromise = closeAllWatchers()
const closedBeforeNativeSubscribe = await Promise.race([
shutdownPromise.then(() => true),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 0))
])
resolveSubscribe({ unsubscribe: unsubscribeMock })
await Promise.all([watchPromise, shutdownPromise])
expect(closedBeforeNativeSubscribe).toBe(true)
expect(unsubscribeMock).toHaveBeenCalledTimes(1)
})
})
@@ -172,4 +172,28 @@ describe('createWslWatcher', () => {
await expect(promise).rejects.toThrow('write EPIPE')
})
it('kills the snapshot process when the install abort signal fires during startup', async () => {
const child = new FakeChildProcess()
spawnMock.mockReturnValueOnce(child)
const controller = new AbortController()
const promise = createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps(), controller.signal)
controller.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
expect(child.kill).toHaveBeenCalledOnce()
})
it('rejects immediately when the install abort signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
createWslWatcher(ROOT_KEY, ROOT_KEY, makeDeps(), controller.signal)
).rejects.toMatchObject({
name: 'AbortError'
})
expect(spawnMock).not.toHaveBeenCalled()
})
})
+20 -2
View File
@@ -150,8 +150,15 @@ function markOverflowWithoutUncStat(root: WatchedRoot): void {
export async function createWslWatcher(
rootKey: string,
worktreePath: string,
deps: WslWatcherDeps
deps: WslWatcherDeps,
signal?: AbortSignal
): Promise<WatchedRoot> {
// Why: cancelled local installs pass the same abort controller used for
// native Parcel subscribe; honor it before spawning a WSL snapshot process.
if (signal?.aborted) {
throw new DOMException('WSL watcher subscription aborted', 'AbortError')
}
const wsl = parseWslUncPath(worktreePath)
if (!wsl) {
throw new Error(`Not a WSL path: ${worktreePath}`)
@@ -257,6 +264,12 @@ export async function createWslWatcher(
throw error instanceof Error ? error : new Error(String(error))
}
const onAbort = (): void => {
settleInitial(new DOMException('WSL watcher subscription aborted', 'AbortError'))
child.kill()
}
signal?.addEventListener('abort', onAbort, { once: true })
const startupTimer = setTimeout(() => {
settleInitial(new Error(`Timed out starting WSL watcher for ${worktreePath}`))
child.kill()
@@ -321,7 +334,12 @@ export async function createWslWatcher(
child.stdin.end(buildSnapshotScript(deps.ignoreDirs))
await initialSnapshotReady.finally(() => clearTimeout(startupTimer))
try {
await initialSnapshotReady
} finally {
clearTimeout(startupTimer)
signal?.removeEventListener('abort', onAbort)
}
root.subscription = {
unsubscribe: async () => {
+47 -13
View File
@@ -72,6 +72,7 @@ const pendingLocalUnsubscribes = new Set<Promise<void>>()
type LocalWatcherInstallToken = {
cancelled: boolean
listeners: Map<number, WebContents>
abortController: AbortController
}
type LocalWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled'
// Why: native watcher creation is async. Concurrent local watch requests for
@@ -96,6 +97,9 @@ function cleanupInFlightLocalInstallsForSender(senderId: number): void {
token.listeners.delete(senderId)
if (token.listeners.size === 0) {
token.cancelled = true
// Why: match closeLocalWatcherForWorktreePath / closeAllWatchers — abort
// so a pending native/forked subscription stops early, not at completion.
token.abortController.abort()
}
}
}
@@ -278,7 +282,11 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void {
// ── Watcher creation ─────────────────────────────────────────────────
async function createWatcher(rootKey: string, rootPath: string): Promise<WatchedRoot> {
async function createWatcher(
rootKey: string,
rootPath: string,
signal?: AbortSignal
): Promise<WatchedRoot> {
const root: WatchedRoot = {
subscription: null!,
listeners: new Map(),
@@ -300,6 +308,11 @@ async function createWatcher(rootKey: string, rootPath: string): Promise<Watched
...(process.platform === 'win32' ? { backend: 'windows' as const } : {})
}
const markWatcherInterrupted = (): void => {
root.batch.overflowed = true
scheduleBatchFlush(rootKey, root)
}
// Why: subscriptions run in a forked watcher process (issue #7547 —
// watcher.node teardown races fail-fast the hosting process). A watcher
// crash there is recovered by resubscribing; onInterruption marks the
@@ -336,11 +349,13 @@ async function createWatcher(rootKey: string, rootPath: string): Promise<Watched
scheduleBatchFlush(rootKey, root)
},
watcherOptions,
() => {
// The watcher process crashed and this root was resubscribed; events
// during the gap are lost, so hand the renderer an overflow refresh.
root.batch.overflowed = true
scheduleBatchFlush(rootKey, root)
{
delivery: { maxEventsPerBatch: MAX_BATCHED_WATCHER_EVENTS },
// A child restart or bounded-queue overflow loses path precision; both
// require the same conservative renderer refresh.
onInterruption: markWatcherInterrupted,
onOverflow: markWatcherInterrupted,
signal
}
)
@@ -466,7 +481,11 @@ async function subscribe(worktreePath: string, sender: WebContents): Promise<voi
return
}
const cancelToken: LocalWatcherInstallToken = { cancelled: false, listeners: new Map() }
const cancelToken: LocalWatcherInstallToken = {
cancelled: false,
listeners: new Map(),
abortController: new AbortController()
}
inFlightLocalInstalls.set(rootKey, cancelToken)
addInFlightLocalInstallListener(cancelToken, sender)
const installPromise = doInstallLocalWatcher(rootKey, worktreePath, cancelToken)
@@ -503,15 +522,23 @@ async function doInstallLocalWatcher(
// Why: WSL paths use one snapshot subprocess inside the Linux distro so
// `wsl --shutdown` can kill it; native Windows paths use @parcel/watcher.
root = isWslPath(worktreePath)
? await createWslWatcher(rootKey, worktreePath, {
ignoreDirs: WATCHER_IGNORE_DIRS,
scheduleBatchFlush,
watchedRoots
})
: await createWatcher(rootKey, rootKey)
? await createWslWatcher(
rootKey,
worktreePath,
{
ignoreDirs: WATCHER_IGNORE_DIRS,
scheduleBatchFlush,
watchedRoots
},
cancelToken.abortController.signal
)
: await createWatcher(rootKey, rootKey, cancelToken.abortController.signal)
} catch {
// Why: createWatcher / createWslWatcher already logged the error. Swallow
// it here so the renderer's watchWorktree call resolves without crashing.
if (cancelToken.cancelled) {
return 'cancelled'
}
rememberUnwatchableRoot(rootKey)
return 'unavailable'
} finally {
@@ -545,6 +572,11 @@ function unsubscribe(worktreePath: string, senderId: number): void {
if (inFlight) {
inFlight.listeners.delete(senderId)
inFlight.cancelled = inFlight.listeners.size === 0
// Why: same early-cancel as closeLocalWatcherForWorktreePath — last normal
// disconnect must abort the pending native/forked install, not let it finish.
if (inFlight.cancelled) {
inFlight.abortController.abort()
}
}
const root = watchedRoots.get(rootKey)
@@ -596,6 +628,7 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr
// cancel an in-flight subscription before Git tries to remove the tree.
inFlight.listeners.clear()
inFlight.cancelled = true
inFlight.abortController.abort()
}
await pendingLocalInstallPromises.get(rootKey)?.catch(() => undefined)
@@ -926,6 +959,7 @@ export async function closeAllWatchers(): Promise<void> {
for (const token of inFlightLocalInstalls.values()) {
token.listeners.clear()
token.cancelled = true
token.abortController.abort()
}
for (const [rootKey, root] of watchedRoots) {
+54 -1
View File
@@ -6,7 +6,9 @@ const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({
}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
app: { isPackaged: false },
ipcMain: { handle: handleMock },
shell: { openExternal: vi.fn() }
}))
vi.mock('qrcode', () => ({
@@ -226,4 +228,55 @@ describe('registerMobileHandlers', () => {
})
expect(revokeRuntimeAccess).toHaveBeenCalledWith('runtime-1')
})
it('inspects and repairs the current packaged Windows websocket port', async () => {
const runPowerShell = vi
.fn()
.mockResolvedValueOnce(
JSON.stringify({
ruleAllowed: false,
privateFirewallEnabled: true,
networkCategory: 'Private'
})
)
.mockResolvedValueOnce('{"launched":true,"exitCode":0}')
const rpcServer = { getWebSocketEndpoint: () => 'ws://0.0.0.0:6768' }
registerMobileHandlers(rpcServer as never, {
firewallEnvironment: {
platform: 'win32',
isPackaged: true,
executablePath: 'C:\\Program Files\\Orca\\Orca.exe',
runPowerShell
}
})
await expect(
handlers.get('mobile:getWindowsFirewallStatus')?.(null, { address: '192.168.0.108' })
).resolves.toMatchObject({ supported: true, port: 6768, ruleAllowed: false })
await expect(
handlers.get('mobile:repairWindowsFirewall')?.({
sender: { isDestroyed: () => false, getType: () => 'window' }
})
).resolves.toEqual({ ok: true })
})
it('rejects firewall mutation from a non-window renderer', async () => {
const runPowerShell = vi.fn()
const rpcServer = { getWebSocketEndpoint: () => 'ws://0.0.0.0:6768' }
registerMobileHandlers(rpcServer as never, {
firewallEnvironment: {
platform: 'win32',
isPackaged: true,
executablePath: 'C:\\Program Files\\Orca\\Orca.exe',
runPowerShell
}
})
expect(
handlers.get('mobile:repairWindowsFirewall')?.({
sender: { isDestroyed: () => false, getType: () => 'webview' }
})
).toEqual({ ok: false, reason: 'unsupported' })
expect(runPowerShell).not.toHaveBeenCalled()
})
})
+51 -2
View File
@@ -1,10 +1,16 @@
import { ipcMain } from 'electron'
import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import { networkInterfaces } from 'node:os'
import QRCode from 'qrcode'
import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants'
import { isTailnetIPv4Address } from '../../shared/tailnet-address'
import type { DeviceEntry } from '../runtime/device-registry'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import {
getWebSocketPort,
inspectWindowsMobileFirewall,
repairWindowsMobileFirewall,
type WindowsMobileFirewallEnvironment
} from '../runtime/windows-mobile-firewall'
export type NetworkInterface = {
name: string
@@ -51,7 +57,21 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
// device management, and WebSocket readiness status. They depend on the
// OrcaRuntimeRpcServer because it owns the device registry and TLS state.
export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
export type MobileHandlerDependencies = {
firewallEnvironment?: WindowsMobileFirewallEnvironment
openWindowsNetworkSettings?: () => Promise<void>
}
export function registerMobileHandlers(
rpcServer: OrcaRuntimeRpcServer,
dependencies: MobileHandlerDependencies = {}
): void {
const firewallEnvironment = dependencies.firewallEnvironment ?? {
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: process.execPath,
systemRoot: process.env.SystemRoot
}
ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({
interfaces: getNetworkInterfaces()
}))
@@ -189,4 +209,33 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
endpoint: rpcServer.getWebSocketEndpoint()
}
})
ipcMain.handle('mobile:getWindowsFirewallStatus', (_event, args?: { address?: string }) => {
const port = getWebSocketPort(rpcServer.getWebSocketEndpoint())
return inspectWindowsMobileFirewall(port, args?.address, firewallEnvironment)
})
ipcMain.handle('mobile:repairWindowsFirewall', (event: IpcMainInvokeEvent) => {
if (!isWindowRenderer(event)) {
return { ok: false as const, reason: 'unsupported' as const }
}
// Why: elevated inputs come from the running runtime, never the renderer.
const port = getWebSocketPort(rpcServer.getWebSocketEndpoint())
return repairWindowsMobileFirewall(port, firewallEnvironment)
})
ipcMain.handle('mobile:openWindowsNetworkSettings', async (event: IpcMainInvokeEvent) => {
if (!isWindowRenderer(event) || firewallEnvironment.platform !== 'win32') {
return false
}
const openSettings =
dependencies.openWindowsNetworkSettings ??
(() => shell.openExternal('ms-settings:network-status'))
await openSettings()
return true
})
}
function isWindowRenderer(event: IpcMainInvokeEvent): boolean {
return !event.sender.isDestroyed() && event.sender.getType() === 'window'
}
@@ -0,0 +1,26 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export function createWatcherCanaryDirectory(): string | null {
try {
return mkdtempSync(join(tmpdir(), 'orca-watcher-canary-'))
} catch (error) {
// Why: the canary is diagnostic only; read-only temp storage must not
// disable the crash-isolated watcher itself.
console.warn('[parcel-watcher-process] canary directory unavailable:', error)
return null
}
}
export function removeWatcherCanaryDirectory(canaryDir: string | null): null {
if (!canaryDir) {
return null
}
try {
rmSync(canaryDir, { recursive: true, force: true })
} catch {
// Canary cleanup is best-effort; watcher lifecycle must still complete.
}
return null
}
@@ -0,0 +1,57 @@
import { fork, type ChildProcess } from 'node:child_process'
import { writeFileSync } from 'node:fs'
import {
createWatcherCanaryDirectory,
removeWatcherCanaryDirectory
} from './parcel-watcher-canary-directory'
import type { WatcherToHostMessage } from './parcel-watcher-process-protocol'
export type LaunchedWatcherChild = {
child: ChildProcess
canaryDir: string | null
}
export function launchWatcherChild(
entryPath: string,
currentCanaryDir: string | null,
onMessage: (child: ChildProcess, message: WatcherToHostMessage) => void,
onGone: (child: ChildProcess, code?: number | null, signal?: NodeJS.Signals | null) => void
): LaunchedWatcherChild | null {
const canaryDir = currentCanaryDir ?? createWatcherCanaryDirectory()
let child: ChildProcess
try {
child = fork(entryPath, [], {
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
...(canaryDir ? { ORCA_WATCHER_CANARY_DIR: canaryDir } : {})
},
...(process.platform === 'win32' ? { windowsHide: true } : {})
})
} catch (error) {
removeWatcherCanaryDirectory(canaryDir)
console.error('[parcel-watcher-process] failed to fork watcher process:', error)
return null
}
const faultHarnessPidFile = process.env.ORCA_WATCHER_CHILD_PID_FILE
if (faultHarnessPidFile && child.pid) {
try {
// Why: exclusive creation lets the harness identify the child without a
// leaked test-only environment variable clobbering an existing file.
writeFileSync(faultHarnessPidFile, String(child.pid), { flag: 'wx' })
} catch {
// Fault-injection observability must never affect watcher availability.
}
}
child.stderr?.on('data', (chunk: Buffer) => {
console.error('[parcel-watcher-process]', String(chunk).trimEnd())
})
child.on('message', (message) => onMessage(child, message as WatcherToHostMessage))
// Why: Node can close IPC before emitting exit. Recover at disconnect so a
// concurrent subscribe cannot replace the child without restoring old roots.
child.on('disconnect', () => onGone(child))
child.on('error', () => onGone(child))
child.on('exit', (code, signal) => onGone(child, code, signal))
return { child, canaryDir }
}
+24
View File
@@ -0,0 +1,24 @@
const CRASH_WINDOW_MS = 30_000
const MAX_CRASHES_PER_WINDOW = 3
export class WatcherProcessCrashFuse {
private crashTimes: number[] = []
recordCrash(now = Date.now()): void {
this.removeExpired(now)
this.crashTimes.push(now)
}
isOpen(now = Date.now()): boolean {
this.removeExpired(now)
return this.crashTimes.length >= MAX_CRASHES_PER_WINDOW
}
reset(): void {
this.crashTimes = []
}
private removeExpired(now: number): void {
this.crashTimes = this.crashTimes.filter((time) => now - time < CRASH_WINDOW_MS)
}
}
@@ -0,0 +1,44 @@
import { setImmediate } from 'node:timers/promises'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { statMock } = vi.hoisted(() => ({ statMock: vi.fn() }))
vi.mock('node:fs/promises', () => ({ stat: statMock }))
import { prepareWatcherProcessEvents } from './parcel-watcher-event-delivery'
describe('prepareWatcherProcessEvents', () => {
beforeEach(() => {
statMock.mockReset()
})
it('caps directory metadata stat work across concurrent roots in one child', async () => {
let activeStats = 0
let maxActiveStats = 0
statMock.mockImplementation(async () => {
activeStats++
maxActiveStats = Math.max(maxActiveStats, activeStats)
await setImmediate()
activeStats--
return { isDirectory: () => false }
})
const batches = Array.from({ length: 8 }, (_, rootIndex) =>
Array.from({ length: 16 }, (_, eventIndex) => ({
type: 'update' as const,
path: `/repo-${rootIndex}/file-${eventIndex}.ts`
}))
)
await Promise.all(
batches.map((events) =>
prepareWatcherProcessEvents(events, {
includeDirectoryMetadata: true,
maxEventsPerBatch: 200
})
)
)
expect(statMock).toHaveBeenCalledTimes(128)
expect(maxActiveStats).toBe(8)
})
})
@@ -0,0 +1,160 @@
import { stat } from 'node:fs/promises'
import type { Event as ParcelWatcherEvent } from '@parcel/watcher'
import { MAX_BATCHED_WATCHER_EVENTS } from './filesystem-watcher-event-batch'
import type {
WatcherProcessDeliveryOptions,
WatcherProcessEvent
} from './parcel-watcher-process-protocol'
const DIRECTORY_STAT_CONCURRENCY = 8
let activeDirectoryStats = 0
const directoryStatWaiters: (() => void)[] = []
export type WatcherProcessEventDeliveryQueue = {
enqueue(events: readonly ParcelWatcherEvent[]): void
close(): void
}
async function acquireDirectoryStatSlot(): Promise<void> {
if (activeDirectoryStats < DIRECTORY_STAT_CONCURRENCY) {
activeDirectoryStats++
return
}
await new Promise<void>((resolve) => directoryStatWaiters.push(resolve))
}
function releaseDirectoryStatSlot(): void {
const next = directoryStatWaiters.shift()
if (next) {
// Transfer the existing slot directly so a newly arriving task cannot
// overtake this waiter and temporarily exceed the global budget.
next()
return
}
activeDirectoryStats--
}
async function statWatcherEventPath(eventPath: string): Promise<boolean> {
await acquireDirectoryStatSlot()
try {
return (await stat(eventPath)).isDirectory()
} finally {
releaseDirectoryStatSlot()
}
}
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
mapper: (item: T) => Promise<R>
): Promise<R[]> {
const results = Array.from<R>({ length: items.length })
let cursor = 0
const lane = async (): Promise<void> => {
while (cursor < items.length) {
const index = cursor++
results[index] = await mapper(items[index])
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane))
return results
}
async function mapWatcherEvent(
event: ParcelWatcherEvent,
includeDirectoryMetadata: boolean
): Promise<WatcherProcessEvent> {
if (!includeDirectoryMetadata || event.type === 'delete') {
return { type: event.type, path: event.path }
}
let isDirectory = false
try {
isDirectory = await statWatcherEventPath(event.path)
} catch {
// Why: a path can vanish between the native event and metadata lookup.
// Treat unknown metadata as a file-like event so parent invalidation still runs.
}
return { type: event.type, path: event.path, isDirectory }
}
export async function prepareWatcherProcessEvents(
events: readonly ParcelWatcherEvent[],
delivery: WatcherProcessDeliveryOptions | undefined
): Promise<WatcherProcessEvent[] | null> {
if (delivery?.maxEventsPerBatch !== undefined && events.length > delivery.maxEventsPerBatch) {
return null
}
if (delivery?.includeDirectoryMetadata !== true) {
return events.map((event) => ({ type: event.type, path: event.path }))
}
return mapWithConcurrency(events, DIRECTORY_STAT_CONCURRENCY, (event) =>
mapWatcherEvent(event, true)
)
}
/** Keep at most one active and one bounded pending batch per subscription. */
export function createWatcherProcessEventDeliveryQueue(
delivery: WatcherProcessDeliveryOptions | undefined,
deliver: (events: WatcherProcessEvent[] | null) => Promise<void>,
onError: (error: unknown) => void
): WatcherProcessEventDeliveryQueue {
const eventLimit = delivery?.maxEventsPerBatch ?? MAX_BATCHED_WATCHER_EVENTS
let active = true
let draining = false
let pendingOverflow = false
let pendingEvents: ParcelWatcherEvent[] = []
const drain = async (): Promise<void> => {
if (!active || draining) {
return
}
draining = true
try {
while (active && (pendingOverflow || pendingEvents.length > 0)) {
const overflowed = pendingOverflow
const events = pendingEvents
pendingOverflow = false
pendingEvents = []
if (overflowed) {
await deliver(null)
continue
}
const prepared = await prepareWatcherProcessEvents(events, delivery)
if (active) {
await deliver(prepared)
}
}
} catch (error) {
if (active) {
onError(error)
}
} finally {
draining = false
if (active && (pendingOverflow || pendingEvents.length > 0)) {
void drain()
}
}
}
return {
enqueue(events): void {
if (!active || events.length === 0 || pendingOverflow) {
return
}
if (events.length > eventLimit || pendingEvents.length + events.length > eventLimit) {
pendingEvents = []
pendingOverflow = true
} else {
for (const event of events) {
pendingEvents.push(event)
}
}
void drain()
},
close(): void {
active = false
pendingEvents = []
pendingOverflow = false
}
}
}
@@ -0,0 +1,155 @@
import type { ChildProcess } from 'node:child_process'
import {
resetPendingSubscribeAttempt,
takePendingSubscribe
} from './parcel-watcher-pending-subscribe'
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
import type { HostToWatcherMessage, WatcherToHostMessage } from './parcel-watcher-process-protocol'
import type {
WatcherProcessSubscription,
WatcherProcessSubscriptionRecord
} from './parcel-watcher-process-subscription'
export function handleWatcherHostMessage(
message: Exclude<
WatcherToHostMessage,
{ op: 'subscribe-started' } | { op: 'cancel-requires-restart' }
>,
records: Map<number, WatcherProcessSubscriptionRecord>,
pendingUnsubscribes: Map<number, () => void>,
reportTerminalError: (record: WatcherProcessSubscriptionRecord, error: Error) => void,
killWatcherChildIfIdle: () => void
): void {
if (message.op === 'unsubscribed') {
const resolve = pendingUnsubscribes.get(message.id)
pendingUnsubscribes.delete(message.id)
resolve?.()
return
}
const record = records.get(message.id)
if (!record) {
return
}
if (message.op === 'subscribed') {
resetPendingSubscribeAttempt(record)
takePendingSubscribe(record)?.resolve()
if (record.interrupted) {
record.interrupted = false
record.hooks.onInterruption?.()
}
return
}
if (message.op === 'subscribe-failed') {
records.delete(message.id)
resetPendingSubscribeAttempt(record)
const pending = takePendingSubscribe(record)
const error = new WatcherProcessFailure(message.message, 'subscription', 'subscribe_failed')
if (pending) {
pending.reject(error)
} else {
reportTerminalError(record, error)
}
killWatcherChildIfIdle()
return
}
if (message.op === 'events') {
record.callback(null, message.events)
return
}
if (message.op === 'overflow') {
record.hooks.onOverflow?.()
return
}
record.callback(new Error(message.message), [])
}
export function reportWatcherTerminalError(
record: WatcherProcessSubscriptionRecord,
error: Error
): void {
if (record.hooks.onTerminalError) {
record.hooks.onTerminalError(error)
return
}
record.callback(error, [])
}
export function failAllWatcherSubscriptions(
records: Map<number, WatcherProcessSubscriptionRecord>,
error: Error
): void {
// Snapshot first: onTerminalError hooks can dispose the supervisor and clear `records`.
for (const record of Array.from(records.values())) {
resetPendingSubscribeAttempt(record)
const pending = takePendingSubscribe(record)
if (pending) {
pending.reject(error)
} else {
reportWatcherTerminalError(record, error)
}
}
records.clear()
}
export function resolvePendingWatcherUnsubscribes(
pendingUnsubscribes: Map<number, () => void>
): void {
for (const resolve of pendingUnsubscribes.values()) {
resolve()
}
pendingUnsubscribes.clear()
}
export function disposeWatcherSupervisorSubscriptions(
records: Map<number, WatcherProcessSubscriptionRecord>,
pendingUnsubscribes: Map<number, () => void>,
cancelledSubscribesAwaitingChild: Set<number>,
error: Error
): void {
for (const record of records.values()) {
resetPendingSubscribeAttempt(record)
takePendingSubscribe(record)?.reject(error)
}
resolvePendingWatcherUnsubscribes(pendingUnsubscribes)
cancelledSubscribesAwaitingChild.clear()
records.clear()
}
export type CreateHostWatcherSubscriptionOptions = {
record: WatcherProcessSubscriptionRecord
records: Map<number, WatcherProcessSubscriptionRecord>
pendingUnsubscribes: Map<number, () => void>
getChild: () => ChildProcess | null
killWatcherChildIfIdle: () => void
sendToChild: (child: ChildProcess, message: HostToWatcherMessage) => void
}
export function createHostWatcherSubscription({
record,
records,
pendingUnsubscribes,
getChild,
killWatcherChildIfIdle,
sendToChild
}: CreateHostWatcherSubscriptionOptions): WatcherProcessSubscription {
return {
unsubscribe: (): Promise<void> => {
if (!records.delete(record.id)) {
return Promise.resolve()
}
resetPendingSubscribeAttempt(record)
const child = getChild()
if (!child?.connected) {
return Promise.resolve()
}
if (records.size === 0) {
killWatcherChildIfIdle()
return Promise.resolve()
}
return new Promise((resolve) => {
pendingUnsubscribes.set(record.id, resolve)
sendToChild(child, { op: 'unsubscribe', id: record.id })
})
}
}
}
@@ -0,0 +1,124 @@
import type * as ParcelWatcher from '@parcel/watcher'
import { createWatcherProcessEventDeliveryQueue } from './parcel-watcher-event-delivery'
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
import type {
WatcherProcessCallback,
WatcherProcessHooks,
WatcherProcessSubscription
} from './parcel-watcher-process-subscription'
import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol'
/**
* Use Parcel directly when Vitest mocks it. Production builds fail closed if
* the crash-isolated child entry is unavailable.
*/
export async function subscribeWithInProcessWatcher(
dir: string,
callback: WatcherProcessCallback,
opts: WatcherProcessSubscribeOptions,
hooks: WatcherProcessHooks
): Promise<WatcherProcessSubscription> {
if (hooks.signal?.aborted) {
throw new WatcherProcessFailure(
'file watcher subscription aborted',
'subscription',
'subscribe_aborted'
)
}
let abortListener: (() => void) | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const cancellation = new Promise<never>((_resolve, reject) => {
if (hooks.signal) {
abortListener = () =>
reject(
new WatcherProcessFailure(
'file watcher subscription aborted',
'subscription',
'subscribe_aborted'
)
)
hooks.signal.addEventListener('abort', abortListener, { once: true })
}
if (hooks.subscribeTimeoutMs !== undefined) {
timer = setTimeout(() => {
reject(
new WatcherProcessFailure(
`file watcher subscription timed out after ${hooks.subscribeTimeoutMs}ms`,
'subscription',
'subscribe_timeout'
)
)
}, hooks.subscribeTimeoutMs)
timer.unref?.()
}
})
const clearPendingControls = (): void => {
if (abortListener && hooks.signal) {
hooks.signal.removeEventListener('abort', abortListener)
}
if (timer) {
clearTimeout(timer)
}
}
let watcher: typeof ParcelWatcher
try {
// Why: setup ownership starts before module loading; an abort or timeout
// during the import must settle the caller just like one during the crawl.
watcher = await Promise.race([import('@parcel/watcher'), cancellation])
} catch (error) {
clearPendingControls()
throw error
}
let active = true
const eventDelivery = createWatcherProcessEventDeliveryQueue(
hooks.delivery,
async (events) => {
if (!active) {
return
}
if (events === null) {
hooks.onOverflow?.()
} else {
callback(null, events)
}
},
(error) => callback(error instanceof Error ? error : new Error(String(error)), [])
)
const subscriptionPromise = watcher.subscribe(
dir,
(err, events) => {
if (!active) {
return
}
if (err) {
callback(err, [])
return
}
eventDelivery.enqueue(events)
},
opts as ParcelWatcher.Options
)
let subscription: ParcelWatcher.AsyncSubscription
try {
subscription = await Promise.race([subscriptionPromise, cancellation])
} catch (error) {
active = false
eventDelivery.close()
clearPendingControls()
// Why: Parcel cannot cancel a crawl in-process. If it eventually finishes,
// release the late native handle instead of leaking it into the test host.
// Swallow unsubscribe failures so they never surface as unhandled rejections.
void subscriptionPromise
.then((lateSubscription) => lateSubscription.unsubscribe())
.catch(() => undefined)
throw error
}
clearPendingControls()
return {
unsubscribe: async (): Promise<void> => {
active = false
eventDelivery.close()
await subscription.unsubscribe()
}
}
}
@@ -0,0 +1,105 @@
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
import type {
PendingWatcherProcessSubscribe,
WatcherProcessSubscriptionRecord
} from './parcel-watcher-process-subscription'
export function installPendingSubscribeControls(
record: WatcherProcessSubscriptionRecord,
cancel: (error: WatcherProcessFailure) => void
): void {
const pending = record.pendingSubscribe
if (!pending) {
return
}
if (record.hooks.signal) {
pending.abortListener = () =>
cancel(
new WatcherProcessFailure(
'file watcher subscription aborted',
'subscription',
'subscribe_aborted'
)
)
record.hooks.signal.addEventListener('abort', pending.abortListener, { once: true })
}
}
export function startPendingSubscribeTimeout(
record: WatcherProcessSubscriptionRecord,
cancel: (error: WatcherProcessFailure) => void
): void {
const pending = record.pendingSubscribe
if (!pending) {
return
}
record.crawlStarted = true
if (pending.timer || record.hooks.subscribeTimeoutMs === undefined) {
return
}
pending.timer = setTimeout(() => {
cancel(
new WatcherProcessFailure(
`file watcher subscription timed out after ${record.hooks.subscribeTimeoutMs}ms`,
'subscription',
'subscribe_timeout'
)
)
}, record.hooks.subscribeTimeoutMs)
pending.timer.unref?.()
}
export function startInterruptedSubscribeTimeout(
record: WatcherProcessSubscriptionRecord,
cancel: (error: WatcherProcessFailure) => void
): void {
if (
!record.interrupted ||
record.pendingSubscribe ||
record.resubscribeTimer ||
record.hooks.subscribeTimeoutMs === undefined
) {
return
}
record.crawlStarted = true
record.resubscribeTimer = setTimeout(() => {
cancel(
new WatcherProcessFailure(
`file watcher resubscription timed out after ${record.hooks.subscribeTimeoutMs}ms`,
'subscription',
'subscribe_timeout'
)
)
}, record.hooks.subscribeTimeoutMs)
record.resubscribeTimer.unref?.()
}
export function resetPendingSubscribeAttempt(record: WatcherProcessSubscriptionRecord): void {
record.crawlStarted = false
if (record.resubscribeTimer) {
clearTimeout(record.resubscribeTimer)
record.resubscribeTimer = undefined
}
const pending = record.pendingSubscribe
if (pending?.timer) {
clearTimeout(pending.timer)
pending.timer = undefined
}
}
export function takePendingSubscribe(
record: WatcherProcessSubscriptionRecord
): PendingWatcherProcessSubscribe | undefined {
const pending = record.pendingSubscribe
if (!pending) {
return undefined
}
record.pendingSubscribe = undefined
if (pending.abortListener && record.hooks.signal) {
record.hooks.signal.removeEventListener('abort', pending.abortListener)
}
if (pending.timer) {
clearTimeout(pending.timer)
}
return pending
}
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { subscribeMock, writeFileSyncMock } = vi.hoisted(() => ({
const { statMock, subscribeMock, writeFileSyncMock } = vi.hoisted(() => ({
statMock: vi.fn(),
subscribeMock: vi.fn(),
writeFileSyncMock: vi.fn()
}))
@@ -10,21 +11,25 @@ vi.mock('node:fs', () => ({
rmSync: vi.fn(),
writeFileSync: writeFileSyncMock
}))
vi.mock('node:fs/promises', () => ({ stat: statMock }))
vi.mock('@parcel/watcher', () => ({ subscribe: subscribeMock }))
describe('parcel watcher process canary', () => {
let originalMessageListeners: ReturnType<typeof process.listeners>
let originalExitListeners: ReturnType<typeof process.listeners>
let originalDisconnectListeners: ReturnType<typeof process.listeners>
let originalSend: typeof process.send
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
subscribeMock.mockReset()
statMock.mockReset()
writeFileSyncMock.mockReset()
originalMessageListeners = process.listeners('message')
originalExitListeners = process.listeners('exit')
originalDisconnectListeners = process.listeners('disconnect')
originalSend = process.send
})
afterEach(() => {
@@ -43,6 +48,7 @@ describe('parcel watcher process canary', () => {
process.off('disconnect', listener)
}
}
process.send = originalSend
vi.useRealTimers()
vi.restoreAllMocks()
})
@@ -201,6 +207,80 @@ describe('parcel watcher process canary', () => {
expect(exitSpy).toHaveBeenCalledWith(2)
})
it('removes a cancelled queued crawl before it reaches Parcel', async () => {
let finishActiveCrawl:
| ((subscription: { unsubscribe: () => Promise<void> }) => void)
| undefined
subscribeMock.mockResolvedValueOnce({ unsubscribe: vi.fn() }).mockReturnValueOnce(
new Promise((resolve) => {
finishActiveCrawl = resolve
})
)
const sendMock = vi.fn()
process.send = sendMock
await import('./parcel-watcher-process-entry')
await vi.advanceTimersByTimeAsync(0)
process.emit('message', { op: 'subscribe', id: 1, dir: '/active', opts: {} })
await vi.advanceTimersByTimeAsync(0)
process.emit('message', { op: 'subscribe', id: 2, dir: '/queued', opts: {} })
process.emit('message', { op: 'cancel-subscribe', id: 2 })
// Why: non-restart cancel reuses async unsubscribe teardown; drain the
// exclusive lifecycle queue after the active crawl finishes.
finishActiveCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) })
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith({ op: 'unsubscribed', id: 2 })
expect(subscribeMock).toHaveBeenCalledTimes(2)
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 () => {
const unsubscribe = vi.fn().mockResolvedValue(undefined)
subscribeMock.mockResolvedValueOnce({ unsubscribe: vi.fn() }).mockResolvedValueOnce({
unsubscribe
})
const sendMock = vi.fn()
process.send = sendMock
await import('./parcel-watcher-process-entry')
await vi.advanceTimersByTimeAsync(0)
process.emit('message', { op: 'subscribe', id: 1, dir: '/finished', opts: {} })
await vi.advanceTimersByTimeAsync(0)
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(sendMock).not.toHaveBeenCalledWith({ op: 'cancel-requires-restart', id: 1 })
})
it('asks the host to restart when an active crawl is cancelled', async () => {
let finishCrawl: ((subscription: { unsubscribe: () => Promise<void> }) => void) | undefined
subscribeMock.mockResolvedValueOnce({ unsubscribe: vi.fn() }).mockReturnValueOnce(
new Promise((resolve) => {
finishCrawl = resolve
})
)
const sendMock = vi.fn()
process.send = sendMock
await import('./parcel-watcher-process-entry')
await vi.advanceTimersByTimeAsync(0)
process.emit('message', { op: 'subscribe', id: 1, dir: '/active', opts: {} })
await vi.advanceTimersByTimeAsync(0)
process.emit('message', { op: 'cancel-subscribe', id: 1 })
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 })
})
it('still restarts after consecutive missed events once every subscription is live', async () => {
subscribeMock.mockResolvedValue({ unsubscribe: vi.fn() })
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
@@ -215,4 +295,198 @@ describe('parcel watcher process canary', () => {
expect(writeFileSyncMock).toHaveBeenCalledTimes(3)
expect(exitSpy).toHaveBeenCalledWith(2)
})
it('collapses and enriches runtime batches before they cross child IPC', async () => {
let callback:
| ((err: Error | null, events: { type: string; path: string }[]) => void)
| undefined
subscribeMock
.mockResolvedValueOnce({ unsubscribe: vi.fn() })
.mockImplementationOnce(async (_dir, nextCallback) => {
callback = nextCallback
return { unsubscribe: vi.fn() }
})
statMock.mockImplementation(async (eventPath: string) => ({
isDirectory: () => eventPath.endsWith('/dir')
}))
const sendMock = vi.fn()
process.send = sendMock
await import('./parcel-watcher-process-entry')
await vi.advanceTimersByTimeAsync(0)
process.emit('message', {
op: 'subscribe',
id: 1,
dir: '/repo',
opts: {},
delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 }
})
await vi.advanceTimersByTimeAsync(0)
callback?.(null, [
{ type: 'update', path: '/repo/file.txt' },
{ type: 'create', path: '/repo/dir' }
])
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith(
{
op: 'events',
id: 1,
events: [
{ type: 'update', path: '/repo/file.txt', isDirectory: false },
{ type: 'create', path: '/repo/dir', isDirectory: true }
]
},
expect.any(Function)
)
sendMock.mockClear()
callback?.(null, [
{ type: 'update', path: '/repo/a' },
{ type: 'update', path: '/repo/b' },
{ type: 'update', path: '/repo/c' }
])
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith({ op: 'overflow', id: 1 }, expect.any(Function))
expect(statMock).toHaveBeenCalledTimes(2)
})
it('reports an FSEvents overflow and keeps delivering later events', async () => {
let callback:
| ((err: Error | null, events: { type: string; path: string }[]) => void)
| undefined
subscribeMock
.mockResolvedValueOnce({ unsubscribe: vi.fn() })
.mockImplementationOnce(async (_dir, nextCallback) => {
callback = nextCallback
return { unsubscribe: vi.fn() }
})
const sendMock = vi.fn()
process.send = sendMock
await import('./parcel-watcher-process-entry')
await vi.advanceTimersByTimeAsync(0)
process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} })
await vi.advanceTimersByTimeAsync(0)
callback?.(
new Error('Events were dropped by the FSEvents client. File system must be re-scanned.'),
[]
)
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(sendMock).toHaveBeenCalledWith(
{
op: 'events',
id: 1,
events: [{ type: 'update', path: '/repo/after-overflow.txt' }]
},
expect.any(Function)
)
})
it('bounds pending event batches while child IPC reports backpressure', async () => {
let callback:
| ((err: Error | null, events: { type: string; path: string }[]) => void)
| undefined
let releaseFirstEvent: (() => void) | undefined
const sentEventPaths: string[] = []
subscribeMock
.mockResolvedValueOnce({ unsubscribe: vi.fn() })
.mockImplementationOnce(async (_dir, nextCallback) => {
callback = nextCallback
return { unsubscribe: vi.fn() }
})
process.send = vi.fn((message, onSent) => {
if (message.op !== 'events') {
return true
}
sentEventPaths.push(message.events[0]?.path ?? '')
if (!releaseFirstEvent) {
releaseFirstEvent = onSent
return false
}
return true
}) as typeof process.send
await import('./parcel-watcher-process-entry')
await vi.advanceTimersByTimeAsync(0)
process.emit('message', {
op: 'subscribe',
id: 1,
dir: '/repo',
opts: {},
delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 }
})
await vi.advanceTimersByTimeAsync(0)
callback?.(null, [{ type: 'update', path: '/repo/first.txt' }])
callback?.(null, [{ type: 'update', path: '/repo/second.txt' }])
callback?.(null, [{ type: 'update', path: '/repo/third.txt' }])
callback?.(null, [{ type: 'update', path: '/repo/fourth.txt' }])
await vi.advanceTimersByTimeAsync(0)
expect(sentEventPaths).toEqual(['/repo/first.txt'])
releaseFirstEvent?.()
await vi.advanceTimersByTimeAsync(0)
expect(sentEventPaths).toEqual(['/repo/first.txt'])
expect(process.send).toHaveBeenCalledWith({ op: 'overflow', id: 1 }, expect.any(Function))
expect(statMock).toHaveBeenCalledTimes(1)
})
it('drops queued event work when the subscription is removed', async () => {
let callback:
| ((err: Error | null, events: { type: string; path: string }[]) => void)
| undefined
let releaseFirstEvent: (() => void) | undefined
const unsubscribe = vi.fn().mockResolvedValue(undefined)
subscribeMock
.mockResolvedValueOnce({ unsubscribe: vi.fn() })
.mockImplementationOnce(async (_dir, nextCallback) => {
callback = nextCallback
return { unsubscribe }
})
process.send = vi.fn((message, onSent) => {
if (message.op === 'events' && !releaseFirstEvent) {
releaseFirstEvent = onSent
return false
}
return true
}) as typeof process.send
await import('./parcel-watcher-process-entry')
await vi.advanceTimersByTimeAsync(0)
process.emit('message', {
op: 'subscribe',
id: 1,
dir: '/repo',
opts: {},
delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 2 }
})
await vi.advanceTimersByTimeAsync(0)
callback?.(null, [{ type: 'update', path: '/repo/first.txt' }])
callback?.(null, [{ type: 'update', path: '/repo/queued.txt' }])
await vi.advanceTimersByTimeAsync(0)
process.emit('message', { op: 'unsubscribe', id: 1 })
await vi.advanceTimersByTimeAsync(0)
releaseFirstEvent?.()
await vi.advanceTimersByTimeAsync(0)
expect(unsubscribe).toHaveBeenCalledTimes(1)
expect(statMock).toHaveBeenCalledTimes(1)
expect(process.send).not.toHaveBeenCalledWith(
expect.objectContaining({
op: 'events',
events: [expect.objectContaining({ path: '/repo/queued.txt' })]
}),
expect.any(Function)
)
})
})
+95 -36
View File
@@ -1,4 +1,4 @@
// Forked (ELECTRON_RUN_AS_NODE) child that hosts all local @parcel/watcher
// Forked (ELECTRON_RUN_AS_NODE) child that hosts its assigned @parcel/watcher
// subscriptions. Why: watcher.node has native teardown races that fail-fast
// the hosting process (issue #7547, 0xc0000409 on Windows; same class as
// #5377/#6635). Running the native module here turns a watcher fault into a
@@ -8,28 +8,16 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as ParcelWatcher from '@parcel/watcher'
export type WatcherProcessEvent = {
type: 'create' | 'update' | 'delete'
path: string
}
export type WatcherProcessSubscribeOptions = {
ignore?: string[]
ignoreGlobs?: string[]
backend?: string
}
export type HostToWatcherMessage =
| { op: 'subscribe'; id: number; dir: string; opts: WatcherProcessSubscribeOptions }
| { op: 'unsubscribe'; id: number }
export type WatcherToHostMessage =
| { op: 'subscribed'; id: number }
| { op: 'subscribe-failed'; id: number; message: string }
| { op: 'events'; id: number; events: WatcherProcessEvent[] }
| { op: 'watch-error'; id: number; message: string }
| { op: 'unsubscribed'; id: number }
import {
createWatcherProcessEventDeliveryQueue,
type WatcherProcessEventDeliveryQueue
} from './parcel-watcher-event-delivery'
import type {
HostToWatcherMessage,
WatcherProcessDeliveryOptions,
WatcherProcessSubscribeOptions,
WatcherToHostMessage
} from './parcel-watcher-process-protocol'
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
@@ -49,8 +37,9 @@ const CANARY_MAX_MISSES = 2
async function startCanary(getStableActivityRevision: () => number | null): Promise<void> {
let canaryDir: string
let lastEventAt = 0
const configuredCanaryDir = process.env.ORCA_WATCHER_CANARY_DIR
try {
canaryDir = mkdtempSync(join(tmpdir(), 'orca-watcher-canary-'))
canaryDir = configuredCanaryDir ?? mkdtempSync(join(tmpdir(), 'orca-watcher-canary-'))
const watcher = await import('@parcel/watcher')
// Why: pin the Windows backend like the main subscriptions do, so the
// canary never probes for Watchman.
@@ -71,6 +60,9 @@ async function startCanary(getStableActivityRevision: () => number | null): Prom
return
}
process.on('exit', () => {
if (configuredCanaryDir) {
return
}
try {
rmSync(canaryDir, { recursive: true, force: true })
} catch {
@@ -125,12 +117,34 @@ function main(): void {
}
}
const sendEventWithBackpressure = (message: WatcherToHostMessage): Promise<void> => {
return new Promise((resolve) => {
let settled = false
const finish = (): void => {
if (!settled) {
settled = true
resolve()
}
}
try {
const accepted = process.send?.(message, finish)
if (accepted !== false) {
finish()
}
} catch {
finish()
}
})
}
// Subscribe promises are kept (not just subscriptions) so an unsubscribe
// that races a still-crawling subscribe awaits it instead of leaking the
// native handle — on Windows a leaked handle keeps the worktree dir locked.
const subscriptions = new Map<number, Promise<ParcelWatcher.AsyncSubscription | null>>()
const liveSubscriptionIds = new Set<number>()
const eventDeliveries = new Map<number, WatcherProcessEventDeliveryQueue>()
let pendingSubscriptionCrawls = 0
let activeSubscriptionCrawlId: number | null = null
let subscriptionActivityRevision = 0
let nativeLifecycleTail = Promise.resolve()
@@ -156,11 +170,29 @@ function main(): void {
const handleSubscribe = async (
id: number,
dir: string,
opts: WatcherProcessSubscribeOptions
opts: WatcherProcessSubscribeOptions,
delivery: WatcherProcessDeliveryOptions | undefined
): Promise<ParcelWatcher.AsyncSubscription | null> => {
const eventDelivery = createWatcherProcessEventDeliveryQueue(
delivery,
async (events) => {
await sendEventWithBackpressure(
events === null ? { op: 'overflow', id } : { op: 'events', id, events }
)
},
(deliveryError) => {
send({ op: 'watch-error', id, message: errorMessage(deliveryError) })
}
)
eventDeliveries.set(id, eventDelivery)
try {
const subscription = await runNativeWatcherLifecycleExclusive(async () => {
if (!subscriptions.has(id)) {
return null
}
activeSubscriptionCrawlId = id
beginSubscriptionCrawl()
send({ op: 'subscribe-started', id })
try {
const watcher = await import('@parcel/watcher')
return await watcher.subscribe(
@@ -170,31 +202,32 @@ function main(): void {
send({ op: 'watch-error', id, message: errorMessage(err) })
return
}
if (events.length > 0) {
send({
op: 'events',
id,
events: events.map((event) => ({ type: event.type, path: event.path }))
})
}
eventDelivery.enqueue(events)
},
opts as ParcelWatcher.Options
)
} finally {
activeSubscriptionCrawlId = null
finishSubscriptionCrawl()
}
})
// An unsubscribe can remove the record while subscribe() is crawling.
// Only advertise it as live if it is still owned by this process.
if (subscriptions.has(id)) {
const stillOwned = subscriptions.has(id)
if (stillOwned) {
liveSubscriptionIds.add(id)
send({ op: 'subscribed', id })
}
send({ op: 'subscribed', id })
return subscription
} catch (err) {
const stillOwned = subscriptions.has(id)
eventDelivery.close()
eventDeliveries.delete(id)
subscriptions.delete(id)
liveSubscriptionIds.delete(id)
send({ op: 'subscribe-failed', id, message: errorMessage(err) })
if (stillOwned) {
send({ op: 'subscribe-failed', id, message: errorMessage(err) })
}
return null
}
}
@@ -206,6 +239,8 @@ function main(): void {
const pending = subscriptions.get(id)
subscriptions.delete(id)
liveSubscriptionIds.delete(id)
eventDeliveries.get(id)?.close()
eventDeliveries.delete(id)
try {
// Why: Parcel already serializes crawl and teardown on one backend mutex.
// Mirror that ordering here so neither operation can mask a canary miss.
@@ -223,16 +258,40 @@ function main(): void {
send({ op: 'unsubscribed', id })
}
const handleCancelSubscribe = (id: number): void => {
// Why: an in-flight native crawl cannot be cancelled safely; ask the host to
// kill this child instead of risking the Parcel teardown deadlock.
if (activeSubscriptionCrawlId === id) {
subscriptions.delete(id)
liveSubscriptionIds.delete(id)
eventDeliveries.get(id)?.close()
eventDeliveries.delete(id)
send({ op: 'cancel-requires-restart', id })
return
}
// Why: cancel can race a crawl that already finished. Reuse unsubscribe
// teardown so a live native handle is released instead of leaked (Windows
// keeps the worktree locked while the handle stays open).
void handleUnsubscribe(id)
}
process.on('message', (message: HostToWatcherMessage) => {
if (!message || typeof message !== 'object') {
return
}
if (message.op === 'subscribe') {
subscriptions.set(message.id, handleSubscribe(message.id, message.dir, message.opts))
subscriptions.set(
message.id,
handleSubscribe(message.id, message.dir, message.opts, message.delivery)
)
return
}
if (message.op === 'unsubscribe') {
void handleUnsubscribe(message.id)
return
}
if (message.op === 'cancel-subscribe') {
handleCancelSubscribe(message.id)
}
})
@@ -0,0 +1,32 @@
export type WatcherProcessFailureScope = 'subscription' | 'supervisor'
export type WatcherProcessFailureCode =
| 'entry_missing'
| 'process_unavailable'
| 'subscribe_aborted'
| 'subscribe_failed'
| 'subscribe_timeout'
| 'supervisor_crash_fuse'
| 'supervisor_disposed'
export class WatcherProcessFailure extends Error {
constructor(
message: string,
readonly scope: WatcherProcessFailureScope,
readonly code: WatcherProcessFailureCode
) {
super(message)
this.name = 'WatcherProcessFailure'
}
}
export function watcherHostFailure(
message: string,
code: WatcherProcessFailureCode
): WatcherProcessFailure {
return new WatcherProcessFailure(message, 'supervisor', code)
}
export function isWatcherProcessFailure(error: unknown): error is WatcherProcessFailure {
return error instanceof WatcherProcessFailure
}
@@ -0,0 +1,37 @@
export type WatcherProcessEvent = {
type: 'create' | 'update' | 'delete'
path: string
isDirectory?: boolean
}
export type WatcherProcessSubscribeOptions = {
ignore?: string[]
ignoreGlobs?: string[]
backend?: string
}
export type WatcherProcessDeliveryOptions = {
includeDirectoryMetadata?: boolean
maxEventsPerBatch?: number
}
export type HostToWatcherMessage =
| {
op: 'subscribe'
id: number
dir: string
opts: WatcherProcessSubscribeOptions
delivery?: WatcherProcessDeliveryOptions
}
| { op: 'unsubscribe'; id: number }
| { op: 'cancel-subscribe'; id: number }
export type WatcherToHostMessage =
| { op: 'subscribe-started'; id: number }
| { op: 'subscribed'; id: number }
| { op: 'subscribe-failed'; id: number; message: string }
| { op: 'events'; id: number; events: WatcherProcessEvent[] }
| { op: 'overflow'; id: number }
| { op: 'watch-error'; id: number; message: string }
| { op: 'cancel-requires-restart'; id: number }
| { op: 'unsubscribed'; id: number }
@@ -0,0 +1,35 @@
import type {
WatcherProcessDeliveryOptions,
WatcherProcessEvent,
WatcherProcessSubscribeOptions
} from './parcel-watcher-process-protocol'
export type WatcherProcessCallback = (err: Error | null, events: WatcherProcessEvent[]) => void
export type WatcherProcessSubscription = { unsubscribe(): Promise<void> }
export type WatcherProcessHooks = {
delivery?: WatcherProcessDeliveryOptions
onInterruption?: () => void
onOverflow?: () => void
onTerminalError?: (error: Error) => void
signal?: AbortSignal
subscribeTimeoutMs?: number
}
export type PendingWatcherProcessSubscribe = {
resolve: () => void
reject: (err: Error) => void
abortListener?: () => void
timer?: ReturnType<typeof setTimeout>
}
export type WatcherProcessSubscriptionRecord = {
id: number
dir: string
opts: WatcherProcessSubscribeOptions
callback: WatcherProcessCallback
hooks: WatcherProcessHooks
interrupted: boolean
crawlStarted: boolean
resubscribeTimer?: ReturnType<typeof setTimeout>
pendingSubscribe?: PendingWatcherProcessSubscribe
}
@@ -0,0 +1,4 @@
export type WatcherProcessSupervisorOptions = {
entryPath?: string
useInProcessVitestFallback?: boolean
}
@@ -0,0 +1,316 @@
import type { ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path'
import { removeWatcherCanaryDirectory } from './parcel-watcher-canary-directory'
import { launchWatcherChild } from './parcel-watcher-child-launch'
import { WatcherProcessCrashFuse } from './parcel-watcher-crash-fuse'
import {
disposeWatcherSupervisorSubscriptions,
failAllWatcherSubscriptions,
handleWatcherHostMessage,
reportWatcherTerminalError,
resolvePendingWatcherUnsubscribes
} from './parcel-watcher-host-subscriptions'
import {
resetPendingSubscribeAttempt,
startInterruptedSubscribeTimeout,
startPendingSubscribeTimeout,
takePendingSubscribe
} from './parcel-watcher-pending-subscribe'
import { watcherHostFailure } from './parcel-watcher-process-failure'
import type { WatcherProcessFailure } from './parcel-watcher-process-failure'
import type {
HostToWatcherMessage,
WatcherProcessSubscribeOptions,
WatcherToHostMessage
} from './parcel-watcher-process-protocol'
import type {
WatcherProcessCallback,
WatcherProcessHooks,
WatcherProcessSubscription,
WatcherProcessSubscriptionRecord
} from './parcel-watcher-process-subscription'
import type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options'
import { subscribeThroughWatcherSupervisor } from './parcel-watcher-supervisor-subscribe'
export type {
WatcherProcessCallback,
WatcherProcessHooks,
WatcherProcessSubscription
} from './parcel-watcher-process-subscription'
export type { WatcherProcessSupervisorOptions } from './parcel-watcher-process-supervisor-options'
export class WatcherProcessSupervisor {
private child: ChildProcess | null = null
private nextSubscriptionId = 1
private readonly crashFuse = new WatcherProcessCrashFuse()
private shutdownRequested = false
private canaryDir: string | null = null
private readonly records = new Map<number, WatcherProcessSubscriptionRecord>()
private readonly pendingUnsubscribes = new Map<number, () => void>()
private readonly cancelledSubscribesAwaitingChild = new Set<number>()
constructor(private readonly options: WatcherProcessSupervisorOptions = {}) {}
subscribe(
dir: string,
callback: WatcherProcessCallback,
opts: WatcherProcessSubscribeOptions,
hooks: WatcherProcessHooks = {}
): Promise<WatcherProcessSubscription> {
return subscribeThroughWatcherSupervisor({
dir,
callback,
opts,
hooks,
shutdownRequested: this.shutdownRequested,
entryPath: this.options.entryPath ?? getWatcherProcessEntryPath(),
useInProcessVitestFallback: this.options.useInProcessVitestFallback ?? true,
allocateId: () => this.nextSubscriptionId++,
records: this.records,
pendingUnsubscribes: this.pendingUnsubscribes,
ensureWatcherProcess: (entryPath) => this.ensureWatcherProcess(entryPath),
getChild: () => this.child,
killWatcherChildIfIdle: () => this.killWatcherChildIfIdle(),
sendSubscribe: (child, record) => this.sendSubscribe(child, record),
sendToChild: (child, message) => this.sendToChild(child, message),
cancelPendingSubscribe: (record, error) => this.cancelPendingSubscribe(record, error)
})
}
dispose(): void {
this.shutdownRequested = true
const proc = this.child
this.child = null
const error = watcherHostFailure('file watcher supervisor disposed', 'supervisor_disposed')
disposeWatcherSupervisorSubscriptions(
this.records,
this.pendingUnsubscribes,
this.cancelledSubscribesAwaitingChild,
error
)
proc?.kill()
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
}
resetForTest(): void {
this.dispose()
this.shutdownRequested = false
this.crashFuse.reset()
}
private ensureWatcherProcess(
entryPath = this.options.entryPath ?? getWatcherProcessEntryPath()
): ChildProcess | null {
if (this.shutdownRequested) {
return null
}
if (this.child?.connected) {
return this.child
}
if (this.crashFuse.isOpen()) {
return null
}
if (!existsSync(entryPath)) {
console.error(`[parcel-watcher-process] entry not found at ${entryPath}; refusing fail-open`)
return null
}
const launched = launchWatcherChild(
entryPath,
this.canaryDir,
(child, message) => {
if (this.child === child) {
this.handleChildMessage(message)
}
},
(child, code, signal) => this.handleChildGone(child, code, signal)
)
if (!launched) {
this.canaryDir = null
return null
}
this.canaryDir = launched.canaryDir
this.child = launched.child
return launched.child
}
private handleChildMessage(message: WatcherToHostMessage): void {
if (message.op === 'subscribe-started') {
const record = this.records.get(message.id)
if (record) {
startPendingSubscribeTimeout(record, (error) => this.cancelPendingSubscribe(record, error))
startInterruptedSubscribeTimeout(record, (error) =>
this.cancelInterruptedSubscribe(record, error)
)
}
return
}
if (message.op === 'cancel-requires-restart') {
if (this.cancelledSubscribesAwaitingChild.delete(message.id)) {
this.restartAfterCancelledSubscribe()
}
return
}
if (message.op === 'unsubscribed') {
const completedCancellation = this.cancelledSubscribesAwaitingChild.delete(message.id)
handleWatcherHostMessage(
message,
this.records,
this.pendingUnsubscribes,
reportWatcherTerminalError,
() => this.killWatcherChildIfIdle()
)
if (completedCancellation) {
this.killWatcherChildIfIdle()
}
return
}
handleWatcherHostMessage(
message,
this.records,
this.pendingUnsubscribes,
reportWatcherTerminalError,
() => this.killWatcherChildIfIdle()
)
}
private handleChildGone(
proc: ChildProcess,
code?: number | null,
signal?: NodeJS.Signals | null
): void {
if (this.child !== proc) {
return
}
if (code !== undefined && (code !== 0 || signal)) {
console.error(
`[parcel-watcher-process] watcher process exited (code=${code}, signal=${signal})`
)
}
this.child = null
this.cancelledSubscribesAwaitingChild.clear()
resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes)
if (this.shutdownRequested || this.records.size === 0) {
return
}
this.crashFuse.recordCrash()
for (const record of this.records.values()) {
record.interrupted = true
resetPendingSubscribeAttempt(record)
}
const replacement = this.ensureWatcherProcess()
if (!replacement) {
console.error(
'[parcel-watcher-process] watcher process crashed repeatedly; disabling file watching'
)
failAllWatcherSubscriptions(
this.records,
watcherHostFailure('file watcher process crashed repeatedly', 'supervisor_crash_fuse')
)
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
return
}
console.error(
`[parcel-watcher-process] watcher process crashed; resubscribing ${this.records.size} root(s)`
)
for (const record of this.records.values()) {
this.sendSubscribe(replacement, record)
}
}
private killWatcherChildIfIdle(): void {
const proc = this.child
if (!proc || this.records.size > 0) {
return
}
this.child = null
resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes)
proc.kill()
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
}
private cancelPendingSubscribe(
record: WatcherProcessSubscriptionRecord,
error: WatcherProcessFailure
): void {
if (!record.pendingSubscribe || !this.records.delete(record.id)) {
return
}
const crawlStarted = record.crawlStarted
const pending = takePendingSubscribe(record)
pending?.reject(error)
if (crawlStarted) {
this.restartAfterCancelledSubscribe()
return
}
const proc = this.child
if (!proc?.connected) {
this.killWatcherChildIfIdle()
return
}
this.cancelledSubscribesAwaitingChild.add(record.id)
this.sendToChild(proc, { op: 'cancel-subscribe', id: record.id })
}
private cancelInterruptedSubscribe(
record: WatcherProcessSubscriptionRecord,
error: WatcherProcessFailure
): void {
if (!record.interrupted || record.pendingSubscribe || !this.records.delete(record.id)) {
return
}
resetPendingSubscribeAttempt(record)
// Why: one slow recovery root must not pin every healthy root in its shard.
// End that subscription so the runtime pool can retry it in quarantine.
reportWatcherTerminalError(record, error)
this.restartAfterCancelledSubscribe()
}
private restartAfterCancelledSubscribe(): void {
const proc = this.child
this.child = null
this.cancelledSubscribesAwaitingChild.clear()
resolvePendingWatcherUnsubscribes(this.pendingUnsubscribes)
proc?.kill()
if (this.records.size === 0) {
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
return
}
const replacement = this.ensureWatcherProcess()
if (!replacement) {
failAllWatcherSubscriptions(
this.records,
watcherHostFailure(
'file watcher process unavailable after subscription cancellation',
'process_unavailable'
)
)
this.canaryDir = removeWatcherCanaryDirectory(this.canaryDir)
return
}
for (const liveRecord of this.records.values()) {
liveRecord.interrupted = true
resetPendingSubscribeAttempt(liveRecord)
this.sendSubscribe(replacement, liveRecord)
}
}
private sendSubscribe(proc: ChildProcess, record: WatcherProcessSubscriptionRecord): void {
resetPendingSubscribeAttempt(record)
this.sendToChild(proc, {
op: 'subscribe',
id: record.id,
dir: record.dir,
opts: record.opts,
delivery: record.hooks.delivery
})
}
private sendToChild(proc: ChildProcess, message: HostToWatcherMessage): void {
try {
proc.send(message)
} catch {
// Channel already closed — the child's exit handler recovers.
}
}
}
+420 -6
View File
@@ -4,19 +4,37 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { forkMock, existsSyncMock, parcelSubscribeMock } = vi.hoisted(() => ({
const {
forkMock,
existsSyncMock,
mkdtempSyncMock,
parcelSubscribeMock,
rmSyncMock,
writeFileSyncMock
} = vi.hoisted(() => ({
forkMock: vi.fn(),
existsSyncMock: vi.fn(),
parcelSubscribeMock: vi.fn()
mkdtempSyncMock: vi.fn(() => '/tmp/orca-watcher-canary-supervisor-test'),
parcelSubscribeMock: vi.fn(),
rmSyncMock: vi.fn(),
writeFileSyncMock: vi.fn()
}))
vi.mock('node:child_process', () => ({ fork: forkMock }))
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
vi.mock('node:fs', () => ({
existsSync: existsSyncMock,
mkdtempSync: mkdtempSyncMock,
rmSync: rmSyncMock,
writeFileSync: writeFileSyncMock
}))
vi.mock('@parcel/watcher', () => ({ subscribe: parcelSubscribeMock }))
import {
createWatcherProcessSupervisor,
disposeWatcherProcess,
resetRuntimeWatcherProcessForTest,
resetWatcherProcessForTest,
subscribeViaRuntimeWatcherProcess,
subscribeViaWatcherProcess
} from './parcel-watcher-process'
@@ -24,6 +42,7 @@ type SentMessage = { op: string; id: number; dir?: string }
class FakeChild extends EventEmitter {
connected = true
pid = 1234
sent: SentMessage[] = []
stderr = new EventEmitter()
kill = vi.fn(() => {
@@ -55,6 +74,7 @@ function ackSubscribe(child: FakeChild, index = -1): number {
describe('subscribeViaWatcherProcess', () => {
beforeEach(() => {
resetWatcherProcessForTest()
resetRuntimeWatcherProcessForTest()
// Why: the client deliberately runs in-process under vitest; these tests
// exercise the forked-process mode, so hide the vitest marker.
vi.stubEnv('VITEST', '')
@@ -64,6 +84,7 @@ describe('subscribeViaWatcherProcess', () => {
afterEach(() => {
disposeWatcherProcess()
resetRuntimeWatcherProcessForTest()
vi.unstubAllEnvs()
vi.clearAllMocks()
})
@@ -81,6 +102,19 @@ describe('subscribeViaWatcherProcess', () => {
expect(callback).toHaveBeenCalledWith(null, events)
})
it('creates the fault-harness pid file without clobbering an existing path', async () => {
vi.stubEnv('ORCA_WATCHER_CHILD_PID_FILE', '/tmp/orca-watcher.pid')
const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {})
const child = currentChild()
expect(writeFileSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher.pid', '1234', {
flag: 'wx'
})
ackSubscribe(child)
await promise
})
it('forwards watcher errors to the callback', async () => {
const callback = vi.fn()
const promise = subscribeViaWatcherProcess('/repo', callback, {})
@@ -92,6 +126,42 @@ describe('subscribeViaWatcherProcess', () => {
expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }), [])
})
it('reports a terminal resubscribe failure separately from recoverable watch errors', async () => {
const callback = vi.fn()
const onTerminalError = vi.fn()
const promise = subscribeViaWatcherProcess('/repo', callback, {}, { onTerminalError })
const child = currentChild()
const id = ackSubscribe(child)
await promise
child.emit('message', { op: 'subscribe-failed', id, message: 'root unavailable' })
expect(onTerminalError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'root unavailable' })
)
expect(callback).not.toHaveBeenCalled()
})
it('passes bounded event-delivery options to the watcher child', async () => {
const promise = subscribeViaWatcherProcess(
'/repo',
vi.fn(),
{},
{
delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 }
}
)
const child = currentChild()
expect(child.sent[0]).toMatchObject({
op: 'subscribe',
dir: '/repo',
delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 }
})
ackSubscribe(child)
await promise
})
it('rejects the subscribe when the watcher process reports failure', async () => {
const promise = subscribeViaWatcherProcess('/gone', vi.fn(), {})
const child = currentChild()
@@ -135,7 +205,7 @@ describe('subscribeViaWatcherProcess', () => {
it('respawns after a crash, resubscribes, and reports the interruption', async () => {
const callback = vi.fn()
const onInterruption = vi.fn()
const promise = subscribeViaWatcherProcess('/repo', callback, {}, onInterruption)
const promise = subscribeViaWatcherProcess('/repo', callback, {}, { onInterruption })
const first = currentChild()
const id = ackSubscribe(first)
await promise
@@ -157,8 +227,71 @@ describe('subscribeViaWatcherProcess', () => {
expect(callback).toHaveBeenCalledWith(null, events)
})
it('recovers existing records when IPC disconnects before child exit', async () => {
const firstPromise = subscribeViaWatcherProcess('/existing', vi.fn(), {})
const first = currentChild()
ackSubscribe(first)
await firstPromise
first.connected = false
first.emit('disconnect')
const replacement = currentChild()
const secondPromise = subscribeViaWatcherProcess('/new', vi.fn(), {})
expect(replacement).not.toBe(first)
expect(replacement.sent.filter((message) => message.op === 'subscribe')).toEqual([
expect.objectContaining({ dir: '/existing' }),
expect.objectContaining({ dir: '/new' })
])
ackSubscribe(replacement, 0)
ackSubscribe(replacement, 1)
await secondPromise
})
it('spreads runtime roots across healthy children and contains a shard crash', async () => {
const firstInterruption = vi.fn()
const secondInterruption = vi.fn()
const firstPromise = subscribeViaRuntimeWatcherProcess(
'/repo-a',
vi.fn(),
{},
{
onInterruption: firstInterruption
}
)
const firstChild = currentChild()
ackSubscribe(firstChild)
await firstPromise
const secondPromise = subscribeViaRuntimeWatcherProcess(
'/repo-b',
vi.fn(),
{},
{
onInterruption: secondInterruption
}
)
const secondChild = currentChild()
expect(secondChild).not.toBe(firstChild)
ackSubscribe(secondChild)
await secondPromise
expect(forkMock).toHaveBeenCalledTimes(2)
firstChild.connected = false
firstChild.emit('exit', null, 'SIGSEGV')
const replacement = currentChild()
expect(replacement).not.toBe(firstChild)
expect(replacement.sent.filter((message) => message.op === 'subscribe')).toHaveLength(1)
ackSubscribe(replacement)
expect(firstInterruption).toHaveBeenCalledTimes(1)
expect(secondInterruption).not.toHaveBeenCalled()
expect(secondChild.connected).toBe(true)
})
it('completes an in-flight subscribe across a crash-respawn', async () => {
const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {})
const onInterruption = vi.fn()
const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}, { onInterruption })
const first = currentChild()
expect(first.sent[0].op).toBe('subscribe')
@@ -168,6 +301,190 @@ describe('subscribeViaWatcherProcess', () => {
const second = currentChild()
ackSubscribe(second)
await expect(promise).resolves.toMatchObject({ unsubscribe: expect.any(Function) })
expect(onInterruption).toHaveBeenCalledTimes(1)
})
it('cancels a queued crawl without restarting healthy roots', async () => {
const healthyInterruption = vi.fn()
const healthyPromise = subscribeViaWatcherProcess(
'/healthy',
vi.fn(),
{},
{
onInterruption: healthyInterruption
}
)
const child = currentChild()
ackSubscribe(child)
await healthyPromise
const controller = new AbortController()
const pending = subscribeViaWatcherProcess(
'/queued',
vi.fn(),
{},
{
signal: controller.signal
}
)
const queuedId = child.sent.at(-1)?.id
controller.abort()
await expect(pending).rejects.toThrow('aborted')
expect(child.kill).not.toHaveBeenCalled()
expect(child.sent.at(-1)).toEqual({ op: 'cancel-subscribe', id: queuedId })
expect(healthyInterruption).not.toHaveBeenCalled()
})
it('cancels an active crawl and restores healthy roots when its owner aborts', async () => {
const healthyInterruption = vi.fn()
const healthyPromise = subscribeViaWatcherProcess(
'/healthy',
vi.fn(),
{},
{
onInterruption: healthyInterruption
}
)
const first = currentChild()
ackSubscribe(first)
await healthyPromise
const controller = new AbortController()
const pending = subscribeViaWatcherProcess('/slow', vi.fn(), {}, { signal: controller.signal })
first.emit('message', { op: 'subscribe-started', id: first.sent.at(-1)?.id })
controller.abort()
await expect(pending).rejects.toThrow('aborted')
expect(first.kill).toHaveBeenCalledTimes(1)
const replacement = currentChild()
expect(replacement).not.toBe(first)
expect(replacement.sent.filter((message) => message.op === 'subscribe')).toEqual([
expect.objectContaining({ dir: '/healthy' })
])
ackSubscribe(replacement)
expect(healthyInterruption).toHaveBeenCalledTimes(1)
})
it('bounds a pending crawl and restores healthy roots after setup timeout', async () => {
vi.useFakeTimers()
try {
const healthyInterruption = vi.fn()
const healthyPromise = subscribeViaWatcherProcess(
'/healthy',
vi.fn(),
{},
{
onInterruption: healthyInterruption
}
)
const first = currentChild()
ackSubscribe(first)
await healthyPromise
const pending = subscribeViaWatcherProcess('/slow', vi.fn(), {}, { subscribeTimeoutMs: 100 })
first.emit('message', { op: 'subscribe-started', id: first.sent.at(-1)?.id })
const timedOut = expect(pending).rejects.toThrow('timed out')
await vi.advanceTimersByTimeAsync(100)
await timedOut
const replacement = currentChild()
expect(replacement).not.toBe(first)
ackSubscribe(replacement)
expect(healthyInterruption).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('ends a root whose crash resubscription crawl exceeds its deadline', async () => {
vi.useFakeTimers()
try {
const supervisor = createWatcherProcessSupervisor()
const onTerminalError = vi.fn()
const initial = supervisor.subscribe(
'/slow-recovery',
vi.fn(),
{},
{
onTerminalError,
subscribeTimeoutMs: 100
}
)
const first = currentChild()
ackSubscribe(first)
await initial
first.connected = false
first.emit('exit', null, 'SIGSEGV')
const replacement = currentChild()
replacement.emit('message', {
op: 'subscribe-started',
id: replacement.sent[0].id
})
await vi.advanceTimersByTimeAsync(100)
expect(onTerminalError).toHaveBeenCalledWith(
expect.objectContaining({
code: 'subscribe_timeout',
message: 'file watcher resubscription timed out after 100ms'
})
)
expect(replacement.kill).toHaveBeenCalledTimes(1)
expect(forkMock).toHaveBeenCalledTimes(2)
supervisor.dispose()
} finally {
vi.useRealTimers()
}
})
it('starts the setup timeout only when the child begins that crawl', async () => {
vi.useFakeTimers()
try {
const pending = subscribeViaWatcherProcess(
'/queued',
vi.fn(),
{},
{
subscribeTimeoutMs: 100
}
)
const child = currentChild()
let settled = false
void pending.then(
() => {
settled = true
},
() => {
settled = true
}
)
await vi.advanceTimersByTimeAsync(100)
expect(settled).toBe(false)
expect(child.kill).not.toHaveBeenCalled()
child.emit('message', { op: 'subscribe-started', id: child.sent.at(-1)?.id })
const timedOut = expect(pending).rejects.toThrow('timed out')
await vi.advanceTimersByTimeAsync(100)
await timedOut
} finally {
vi.useRealTimers()
}
})
it('rejects an in-flight and later subscribe when the supervisor is disposed', async () => {
const supervisor = createWatcherProcessSupervisor()
const pending = supervisor.subscribe('/repo', vi.fn(), {})
const child = currentChild()
supervisor.dispose()
await expect(pending).rejects.toThrow('supervisor disposed')
await expect(supervisor.subscribe('/later', vi.fn(), {})).rejects.toThrow('supervisor disposed')
expect(child.kill).toHaveBeenCalledTimes(1)
expect(forkMock).toHaveBeenCalledTimes(1)
})
it('disables watching after repeated crashes instead of fork-bombing', async () => {
@@ -203,6 +520,10 @@ describe('subscribeViaWatcherProcess', () => {
await expect(subscription.unsubscribe()).resolves.toBeUndefined()
expect(first.kill).toHaveBeenCalledTimes(1)
expect(rmSyncMock).toHaveBeenCalledWith('/tmp/orca-watcher-canary-supervisor-test', {
recursive: true,
force: true
})
// Why: the deliberate idle kill must not count as a crash — no respawn
// and no crash-fuse advance when the killed child's exit event lands.
@@ -279,8 +600,101 @@ describe('subscribeViaWatcherProcess', () => {
ignore: ['**/.git']
})
expect(forkMock).not.toHaveBeenCalled()
expect(parcelSubscribeMock).toHaveBeenCalledWith('/repo', callback, { ignore: ['**/.git'] })
expect(parcelSubscribeMock).toHaveBeenCalledWith('/repo', expect.any(Function), {
ignore: ['**/.git']
})
await subscription.unsubscribe()
expect(unsubscribe).toHaveBeenCalled()
})
it('rejects a pre-aborted in-process subscribe before loading Parcel', async () => {
vi.stubEnv('VITEST', 'true')
const controller = new AbortController()
controller.abort()
await expect(
subscribeViaWatcherProcess('/repo', vi.fn(), {}, { signal: controller.signal })
).rejects.toThrow('aborted')
expect(parcelSubscribeMock).not.toHaveBeenCalled()
})
it('releases a late in-process subscription after pending setup is aborted', async () => {
vi.stubEnv('VITEST', 'true')
const unsubscribe = vi.fn().mockResolvedValue(undefined)
let resolveSubscription!: (subscription: { unsubscribe: typeof unsubscribe }) => void
parcelSubscribeMock.mockReturnValue(
new Promise((resolve) => {
resolveSubscription = resolve
})
)
const controller = new AbortController()
const pending = subscribeViaWatcherProcess('/repo', vi.fn(), {}, { signal: controller.signal })
await vi.waitFor(() => expect(parcelSubscribeMock).toHaveBeenCalledTimes(1))
controller.abort()
await expect(pending).rejects.toThrow('aborted')
resolveSubscription({ unsubscribe })
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1))
})
it('fails closed when the isolated watcher entry is absent outside tests', async () => {
existsSyncMock.mockReturnValue(false)
await expect(subscribeViaWatcherProcess('/repo', vi.fn(), {})).rejects.toThrow(
'watcher process entry is missing'
)
expect(forkMock).not.toHaveBeenCalled()
expect(parcelSubscribeMock).not.toHaveBeenCalled()
})
it('starts without a canary when the diagnostic temp directory is unavailable', async () => {
mkdtempSyncMock.mockImplementationOnce(() => {
throw new Error('read-only temp directory')
})
const pending = subscribeViaWatcherProcess('/repo', vi.fn(), {})
const child = currentChild()
const forkOptions = forkMock.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined
expect(forkOptions?.env?.ORCA_WATCHER_CANARY_DIR).toBeUndefined()
ackSubscribe(child)
await expect(pending).resolves.toMatchObject({ unsubscribe: expect.any(Function) })
})
it('keeps independently-created supervisors in separate crash-fuse domains', async () => {
const firstSupervisor = createWatcherProcessSupervisor()
const secondSupervisor = createWatcherProcessSupervisor()
const firstCallback = vi.fn()
const secondCallback = vi.fn()
const firstPromise = firstSupervisor.subscribe('/faulting', firstCallback, {})
const firstChild = currentChild()
ackSubscribe(firstChild)
await firstPromise
const secondPromise = secondSupervisor.subscribe('/healthy', secondCallback, {})
const secondChild = currentChild()
ackSubscribe(secondChild)
await secondPromise
let faultingChild = firstChild
for (let crash = 0; crash < 2; crash++) {
faultingChild.connected = false
faultingChild.emit('exit', null, 'SIGSEGV')
faultingChild = currentChild()
ackSubscribe(faultingChild)
}
faultingChild.connected = false
faultingChild.emit('exit', null, 'SIGSEGV')
expect(firstCallback).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('crashed repeatedly') }),
[]
)
expect(secondCallback).not.toHaveBeenCalled()
expect(secondChild.connected).toBe(true)
firstSupervisor.dispose()
secondSupervisor.dispose()
})
})
+57 -311
View File
@@ -1,329 +1,75 @@
// Host side of the out-of-process @parcel/watcher (see
// parcel-watcher-process-entry.ts). Why: native watcher teardown races
// fail-fast the hosting process (issue #7547), so local subscriptions live in
// a disposable forked child; when it crashes the host respawns it and
// resubscribes every live root, and callers are told via onInterruption so
// they can refresh state that changed during the gap.
import { fork, type ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import type * as ParcelWatcher from '@parcel/watcher'
import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path'
import type {
HostToWatcherMessage,
// Host API for crash-isolated @parcel/watcher children. Desktop uses one
// supervisor; runtime roots use a bounded pool with independent crash fuses.
import {
WatcherProcessSupervisor,
type WatcherProcessCallback,
type WatcherProcessHooks,
type WatcherProcessSubscription
} from './parcel-watcher-process-supervisor'
import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol'
import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool'
export type {
WatcherProcessCallback,
WatcherProcessHooks,
WatcherProcessSubscription
} from './parcel-watcher-process-supervisor'
export type {
WatcherProcessDeliveryOptions,
WatcherProcessEvent,
WatcherProcessSubscribeOptions,
WatcherToHostMessage
} from './parcel-watcher-process-entry'
WatcherProcessSubscribeOptions
} from './parcel-watcher-process-protocol'
export type { WatcherProcessEvent, WatcherProcessSubscribeOptions }
const sharedWatcherProcessSupervisor = new WatcherProcessSupervisor()
// Why: healthy and quarantine pools each cap at four children, containing a
// failed shard without scaling process count with its number of roots.
const runtimeWatcherProcessPool = new RuntimeWatcherProcessPool()
export type WatcherProcessCallback = (err: Error | null, events: WatcherProcessEvent[]) => void
export type WatcherProcessSubscription = { unsubscribe(): Promise<void> }
// Why: a crash loop (e.g. a root that faults the native watcher on every
// resubscribe) must degrade to "file watching disabled" rather than fork-bomb.
const CRASH_WINDOW_MS = 30_000
const MAX_CRASHES_PER_WINDOW = 3
type SubscriptionRecord = {
id: number
dir: string
opts: WatcherProcessSubscribeOptions
callback: WatcherProcessCallback
onInterruption?: () => void
pendingSubscribe?: { resolve: () => void; reject: (err: Error) => void }
export function createWatcherProcessSupervisor(): WatcherProcessSupervisor {
return new WatcherProcessSupervisor()
}
let child: ChildProcess | null = null
let nextSubscriptionId = 1
let crashTimes: number[] = []
let shutdownRequested = false
let loggedInProcessFallback = false
const records = new Map<number, SubscriptionRecord>()
const pendingUnsubscribes = new Map<number, () => void>()
function shouldRunInProcess(entryPath: string): boolean {
// Why: vitest suites mock '@parcel/watcher' at the module level; a forked
// child would bypass those mocks (and could execute a stale build output),
// so tests keep the historical in-process path.
if (process.env.VITEST) {
return true
}
if (existsSync(entryPath)) {
return false
}
if (!loggedInProcessFallback) {
loggedInProcessFallback = true
console.warn(
`[parcel-watcher-process] entry not found at ${entryPath}; using in-process watcher (no crash isolation)`
)
}
return true
}
async function subscribeInProcess(
dir: string,
callback: WatcherProcessCallback,
opts: WatcherProcessSubscribeOptions
): Promise<WatcherProcessSubscription> {
const watcher = await import('@parcel/watcher')
const subscription = await watcher.subscribe(
dir,
callback as ParcelWatcher.SubscribeCallback,
opts as ParcelWatcher.Options
)
return { unsubscribe: () => subscription.unsubscribe() }
}
function inCrashCooldown(): boolean {
const now = Date.now()
crashTimes = crashTimes.filter((time) => now - time < CRASH_WINDOW_MS)
return crashTimes.length >= MAX_CRASHES_PER_WINDOW
}
function sendToChild(proc: ChildProcess, message: HostToWatcherMessage): void {
try {
proc.send(message)
} catch {
// Channel already closed — the child's exit handler recovers.
}
}
function ensureWatcherProcess(): ChildProcess | null {
if (shutdownRequested) {
return null
}
if (child?.connected) {
return child
}
if (inCrashCooldown()) {
return null
}
let proc: ChildProcess
try {
proc = fork(getWatcherProcessEntryPath(), [], {
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
...(process.platform === 'win32' ? { windowsHide: true } : {})
})
} catch (err) {
console.error('[parcel-watcher-process] failed to fork watcher process:', err)
return null
}
proc.stderr?.on('data', (chunk: Buffer) => {
console.error('[parcel-watcher-process]', String(chunk).trimEnd())
})
proc.on('message', (message) => {
if (child === proc) {
handleChildMessage(message as WatcherToHostMessage)
}
})
proc.on('error', () => handleChildGone(proc))
proc.on('exit', (code, signal) => {
if (child === proc && (code !== 0 || signal)) {
console.error(
`[parcel-watcher-process] watcher process exited (code=${code}, signal=${signal})`
)
}
handleChildGone(proc)
})
child = proc
return proc
}
function handleChildMessage(message: WatcherToHostMessage): void {
if (!message || typeof message !== 'object') {
return
}
if (message.op === 'unsubscribed') {
const resolve = pendingUnsubscribes.get(message.id)
pendingUnsubscribes.delete(message.id)
resolve?.()
return
}
const record = records.get(message.id)
if (!record) {
return
}
if (message.op === 'subscribed') {
const pending = record.pendingSubscribe
record.pendingSubscribe = undefined
if (pending) {
pending.resolve()
} else {
// Reached after a crash-respawn resubscribe: events emitted while the
// watcher process was down are lost, so let the caller refresh.
record.onInterruption?.()
}
return
}
if (message.op === 'subscribe-failed') {
records.delete(message.id)
const pending = record.pendingSubscribe
record.pendingSubscribe = undefined
if (pending) {
pending.reject(new Error(message.message))
} else {
record.callback(new Error(message.message), [])
}
// Why: a failed last subscribe empties the records map without any
// unsubscribe ever being called — tear down the idle child here too.
killWatcherChildIfIdle()
return
}
if (message.op === 'events') {
record.callback(null, message.events)
return
}
if (message.op === 'watch-error') {
record.callback(new Error(message.message), [])
}
}
function failAllSubscriptions(err: Error): void {
for (const record of records.values()) {
const pending = record.pendingSubscribe
record.pendingSubscribe = undefined
if (pending) {
pending.reject(err)
} else {
record.callback(err, [])
}
}
records.clear()
}
function handleChildGone(proc: ChildProcess): void {
if (child !== proc) {
return
}
child = null
// Why: process death released every native handle, so a pending unsubscribe
// is complete by definition — resolve rather than hang worktree deletion.
for (const resolve of pendingUnsubscribes.values()) {
resolve()
}
pendingUnsubscribes.clear()
if (shutdownRequested || records.size === 0) {
return
}
crashTimes.push(Date.now())
const replacement = ensureWatcherProcess()
if (!replacement) {
console.error(
'[parcel-watcher-process] watcher process crashed repeatedly; disabling file watching'
)
failAllSubscriptions(new Error('file watcher process crashed repeatedly'))
return
}
console.error(
`[parcel-watcher-process] watcher process crashed; resubscribing ${records.size} root(s)`
)
for (const record of records.values()) {
sendToChild(replacement, {
op: 'subscribe',
id: record.id,
dir: record.dir,
opts: record.opts
})
}
}
// Why: the last record gone leaves the child idle until app shutdown. Killing
// it releases native handles without running watcher.node's crash-prone async
// teardown (same rationale as disposeWatcherProcess) and reclaims the process;
// the next subscribe forks a fresh child. `child = null` before kill so the
// exit event neither counts as a crash nor respawns. Process death also
// completes any still-pending unsubscribe acks.
function killWatcherChildIfIdle(): void {
const proc = child
if (!proc || records.size > 0) {
return
}
child = null
for (const resolve of pendingUnsubscribes.values()) {
resolve()
}
pendingUnsubscribes.clear()
proc.kill()
}
function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscription {
return {
unsubscribe: (): Promise<void> => {
if (!records.delete(record.id)) {
return Promise.resolve()
}
const proc = child
if (!proc?.connected) {
return Promise.resolve()
}
if (records.size === 0) {
killWatcherChildIfIdle()
return Promise.resolve()
}
return new Promise((resolve) => {
pendingUnsubscribes.set(record.id, resolve)
sendToChild(proc, { op: 'unsubscribe', id: record.id })
})
}
}
}
/** Subscribe to a directory tree via the isolated watcher process. Falls back
* to an in-process @parcel/watcher subscription when the forked entry is not
* available (tests, unbuilt trees). `onInterruption` fires after the watcher
* process crashed and this subscription was transparently re-established —
* events in the gap were lost, so callers should refresh. */
export function subscribeViaWatcherProcess(
dir: string,
callback: WatcherProcessCallback,
opts: WatcherProcessSubscribeOptions,
onInterruption?: () => void
hooks: WatcherProcessHooks = {}
): Promise<WatcherProcessSubscription> {
if (shouldRunInProcess(getWatcherProcessEntryPath())) {
return subscribeInProcess(dir, callback, opts)
}
const record: SubscriptionRecord = {
id: nextSubscriptionId++,
dir,
opts,
callback,
onInterruption
}
return new Promise((resolve, reject) => {
const proc = ensureWatcherProcess()
if (!proc) {
reject(new Error('file watcher process unavailable'))
return
}
records.set(record.id, record)
record.pendingSubscribe = {
resolve: () => resolve(makeSubscription(record)),
reject
}
sendToChild(proc, { op: 'subscribe', id: record.id, dir, opts })
})
return sharedWatcherProcessSupervisor.subscribe(dir, callback, opts, hooks)
}
/** Kill the watcher process at app shutdown. Process death releases native
* handles without running watcher.node's crash-prone async teardown. */
export function subscribeViaRuntimeWatcherProcess(
dir: string,
callback: WatcherProcessCallback,
opts: WatcherProcessSubscribeOptions,
hooks: WatcherProcessHooks = {}
): Promise<WatcherProcessSubscription> {
return runtimeWatcherProcessPool.subscribe(dir, callback, opts, hooks)
}
/** Kill shared desktop and runtime watcher children at app/runtime shutdown. */
export function disposeWatcherProcess(): void {
shutdownRequested = true
const proc = child
child = null
for (const resolve of pendingUnsubscribes.values()) {
resolve()
// Why: Vitest reuses this module singleton after closeAllWatchers(), while
// production shutdown must remain final and reject any later subscription.
if (process.env.VITEST) {
sharedWatcherProcessSupervisor.resetForTest()
runtimeWatcherProcessPool.resetForTest()
} else {
sharedWatcherProcessSupervisor.dispose()
// Why: the runtime pool owns independent children that otherwise outlive a
// shutdown sequence that does not immediately exit the main process.
runtimeWatcherProcessPool.dispose()
}
pendingUnsubscribes.clear()
records.clear()
proc?.kill()
}
export function resetWatcherProcessForTest(): void {
disposeWatcherProcess()
shutdownRequested = false
crashTimes = []
loggedInProcessFallback = false
nextSubscriptionId = 1
sharedWatcherProcessSupervisor.resetForTest()
}
export function resetRuntimeWatcherProcessForTest(): void {
runtimeWatcherProcessPool.resetForTest()
}
export function forgetRuntimeWatcherProcessRoot(rootPath: string): void {
runtimeWatcherProcessPool.forgetRoot(rootPath)
}
@@ -0,0 +1,129 @@
import type { ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import { createHostWatcherSubscription } from './parcel-watcher-host-subscriptions'
import { subscribeWithInProcessWatcher } from './parcel-watcher-in-process-fallback'
import { installPendingSubscribeControls } from './parcel-watcher-pending-subscribe'
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
import type {
HostToWatcherMessage,
WatcherProcessSubscribeOptions
} from './parcel-watcher-process-protocol'
import type {
WatcherProcessCallback,
WatcherProcessHooks,
WatcherProcessSubscription,
WatcherProcessSubscriptionRecord
} from './parcel-watcher-process-subscription'
type WatcherSupervisorSubscribeOptions = {
dir: string
callback: WatcherProcessCallback
opts: WatcherProcessSubscribeOptions
hooks: WatcherProcessHooks
shutdownRequested: boolean
entryPath: string
useInProcessVitestFallback: boolean
allocateId: () => number
records: Map<number, WatcherProcessSubscriptionRecord>
pendingUnsubscribes: Map<number, () => void>
ensureWatcherProcess: (entryPath: string) => ChildProcess | null
getChild: () => ChildProcess | null
killWatcherChildIfIdle: () => void
sendSubscribe: (child: ChildProcess, record: WatcherProcessSubscriptionRecord) => void
sendToChild: (child: ChildProcess, message: HostToWatcherMessage) => void
cancelPendingSubscribe: (
record: WatcherProcessSubscriptionRecord,
error: WatcherProcessFailure
) => void
}
export function subscribeThroughWatcherSupervisor({
dir,
callback,
opts,
hooks,
shutdownRequested,
entryPath,
useInProcessVitestFallback,
allocateId,
records,
pendingUnsubscribes,
ensureWatcherProcess,
getChild,
killWatcherChildIfIdle,
sendSubscribe,
sendToChild,
cancelPendingSubscribe
}: WatcherSupervisorSubscribeOptions): Promise<WatcherProcessSubscription> {
if (shutdownRequested) {
return Promise.reject(
new WatcherProcessFailure(
'file watcher supervisor disposed',
'supervisor',
'supervisor_disposed'
)
)
}
if (hooks.signal?.aborted) {
return Promise.reject(
new WatcherProcessFailure(
'file watcher subscription aborted',
'subscription',
'subscribe_aborted'
)
)
}
// Why: under Vitest we cannot fork a real watcher child, so exercise the
// subscription path in-process (against mocked @parcel/watcher) instead.
if (process.env.VITEST && useInProcessVitestFallback) {
return subscribeWithInProcessWatcher(dir, callback, opts, hooks)
}
if (!existsSync(entryPath)) {
return Promise.reject(
new WatcherProcessFailure(
`watcher process entry is missing: ${entryPath}`,
'supervisor',
'entry_missing'
)
)
}
const record: WatcherProcessSubscriptionRecord = {
id: allocateId(),
dir,
opts,
callback,
hooks,
interrupted: false,
crawlStarted: false
}
return new Promise((resolve, reject) => {
const child = ensureWatcherProcess(entryPath)
if (!child) {
reject(
new WatcherProcessFailure(
'file watcher process unavailable',
'supervisor',
'process_unavailable'
)
)
return
}
records.set(record.id, record)
record.pendingSubscribe = {
resolve: () =>
resolve(
createHostWatcherSubscription({
record,
records,
pendingUnsubscribes,
getChild,
killWatcherChildIfIdle,
sendToChild
})
),
reject
}
installPendingSubscribeControls(record, (error) => cancelPendingSubscribe(record, error))
sendSubscribe(child, record)
})
}
@@ -0,0 +1,273 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
import type {
WatcherProcessCallback,
WatcherProcessHooks,
WatcherProcessSubscription
} from './parcel-watcher-process-subscription'
import { RuntimeWatcherProcessPool } from './runtime-watcher-process-pool'
type InstalledSubscription = {
dir: string
hooks: WatcherProcessHooks
unsubscribe: ReturnType<typeof vi.fn<() => Promise<void>>>
}
class FakeSupervisor {
readonly subscriptions: InstalledSubscription[] = []
readonly dispose = vi.fn()
subscribeError?: Error
async subscribe(
dir: string,
_callback: WatcherProcessCallback,
_opts: object,
hooks: WatcherProcessHooks
): Promise<WatcherProcessSubscription> {
if (this.subscribeError) {
throw this.subscribeError
}
const unsubscribe = vi.fn(async () => undefined)
this.subscriptions.push({ dir, hooks, unsubscribe })
return { unsubscribe }
}
}
describe('RuntimeWatcherProcessPool', () => {
let supervisors: FakeSupervisor[]
let pool: RuntimeWatcherProcessPool
beforeEach(() => {
supervisors = []
pool = new RuntimeWatcherProcessPool({
maxSharedSupervisors: 4,
createSupervisor: () => {
const supervisor = new FakeSupervisor()
supervisors.push(supervisor)
return supervisor
}
})
})
it('spreads roots across a bounded healthy pool before sharing children', async () => {
for (const dir of ['/a', '/b', '/c', '/d', '/e']) {
await pool.subscribe(dir, vi.fn(), {}, {})
}
expect(supervisors).toHaveLength(4)
expect(supervisors.map((supervisor) => supervisor.subscriptions.map(({ dir }) => dir))).toEqual(
[['/a', '/e'], ['/b'], ['/c'], ['/d']]
)
})
it('keeps a newer same-root lease assigned while an older lease tears down', async () => {
const first = await pool.subscribe('/same', vi.fn(), {}, {})
await pool.subscribe('/same', vi.fn(), {}, {})
await first.unsubscribe()
await pool.subscribe('/same', vi.fn(), {}, {})
expect(supervisors).toHaveLength(1)
expect(supervisors[0].subscriptions.map(({ dir }) => dir)).toEqual(['/same', '/same', '/same'])
})
it('isolates every root from a failed shard when callers recover', async () => {
pool = new RuntimeWatcherProcessPool({
maxSharedSupervisors: 1,
createSupervisor: () => {
const supervisor = new FakeSupervisor()
supervisors.push(supervisor)
return supervisor
}
})
const firstError = vi.fn()
const secondError = vi.fn()
await pool.subscribe('/a', vi.fn(), {}, { onTerminalError: firstError })
await pool.subscribe('/b', vi.fn(), {}, { onTerminalError: secondError })
const failedShard = supervisors[0]
const failure = new WatcherProcessFailure(
'file watcher process crashed repeatedly',
'supervisor',
'supervisor_crash_fuse'
)
for (const installed of failedShard.subscriptions) {
installed.hooks.onTerminalError?.(failure)
}
await pool.subscribe('/a', vi.fn(), {}, {})
await pool.subscribe('/b', vi.fn(), {}, {})
expect(firstError).toHaveBeenCalledWith(failure)
expect(secondError).toHaveBeenCalledWith(failure)
expect(supervisors).toHaveLength(3)
expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/a'])
expect(supervisors[2].subscriptions.map(({ dir }) => dir)).toEqual(['/b'])
})
it('bounds quarantine supervisors when a fused shard contains many roots', async () => {
pool = new RuntimeWatcherProcessPool({
maxSharedSupervisors: 1,
createSupervisor: () => {
const supervisor = new FakeSupervisor()
supervisors.push(supervisor)
return supervisor
}
})
const roots = Array.from({ length: 12 }, (_, index) => `/root-${index}`)
for (const root of roots) {
await pool.subscribe(root, vi.fn(), {}, {})
}
const failedShard = supervisors[0]
const failure = new WatcherProcessFailure(
'file watcher process crashed repeatedly',
'supervisor',
'supervisor_crash_fuse'
)
for (const installed of failedShard.subscriptions) {
installed.hooks.onTerminalError?.(failure)
}
for (const root of roots) {
await pool.subscribe(root, vi.fn(), {}, {})
}
expect(supervisors).toHaveLength(5)
expect(supervisors.slice(1).map(({ subscriptions }) => subscriptions.length)).toEqual([
3, 3, 3, 3
])
})
it('moves an explicitly timed-out root into quarantine for its retry', async () => {
const timeout = new WatcherProcessFailure(
'file watcher subscription timed out',
'subscription',
'subscribe_timeout'
)
pool = new RuntimeWatcherProcessPool({
maxSharedSupervisors: 1,
createSupervisor: () => {
const supervisor = new FakeSupervisor()
if (supervisors.length === 0) {
supervisor.subscribeError = timeout
}
supervisors.push(supervisor)
return supervisor
}
})
await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout)
await expect(pool.subscribe('/slow', vi.fn(), {}, {})).resolves.toBeDefined()
expect(supervisors).toHaveLength(2)
expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow'])
})
it('moves a live root into quarantine when crash resubscription times out', async () => {
const timeout = new WatcherProcessFailure(
'file watcher resubscription timed out',
'subscription',
'subscribe_timeout'
)
const onTerminalError = vi.fn()
await pool.subscribe('/slow-recovery', vi.fn(), {}, { onTerminalError })
supervisors[0].subscriptions[0].hooks.onTerminalError?.(timeout)
await pool.subscribe('/slow-recovery', vi.fn(), {}, {})
expect(onTerminalError).toHaveBeenCalledWith(timeout)
expect(supervisors).toHaveLength(2)
expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow-recovery'])
})
it('keeps healthy shard assignments after a root-specific failure', async () => {
pool = new RuntimeWatcherProcessPool({
maxSharedSupervisors: 1,
createSupervisor: () => {
const supervisor = new FakeSupervisor()
supervisors.push(supervisor)
return supervisor
}
})
await pool.subscribe('/healthy', vi.fn(), {}, {})
const onTerminalError = vi.fn()
await pool.subscribe('/gone', vi.fn(), {}, { onTerminalError })
const shared = supervisors[0]
const failed = shared.subscriptions.find(({ dir }) => dir === '/gone')
const failure = new WatcherProcessFailure(
'root unavailable',
'subscription',
'subscribe_failed'
)
failed?.hooks.onTerminalError?.(failure)
await pool.subscribe('/gone', vi.fn(), {}, {})
expect(onTerminalError).toHaveBeenCalledWith(failure)
expect(supervisors).toHaveLength(1)
expect(shared.subscriptions.map(({ dir }) => dir)).toEqual(['/healthy', '/gone', '/gone'])
expect(shared.dispose).not.toHaveBeenCalled()
})
it('stops replacing a root after its bounded quarantine shard also fuses', async () => {
pool = new RuntimeWatcherProcessPool({
maxSharedSupervisors: 1,
createSupervisor: () => {
const supervisor = new FakeSupervisor()
supervisors.push(supervisor)
return supervisor
}
})
const failure = new WatcherProcessFailure(
'file watcher process crashed repeatedly',
'supervisor',
'supervisor_crash_fuse'
)
await pool.subscribe('/unstable', vi.fn(), {}, {})
supervisors[0].subscriptions[0].hooks.onTerminalError?.(failure)
await pool.subscribe('/unstable', vi.fn(), {}, {})
supervisors[1].subscriptions[0].hooks.onTerminalError?.(failure)
await expect(pool.subscribe('/unstable', vi.fn(), {}, {})).rejects.toThrow(
'failed again in quarantine'
)
expect(supervisors).toHaveLength(2)
})
it('clears fused quarantine history when forgetRoot runs without an assignment', async () => {
pool = new RuntimeWatcherProcessPool({
maxSharedSupervisors: 1,
createSupervisor: () => {
const supervisor = new FakeSupervisor()
supervisors.push(supervisor)
return supervisor
}
})
const failure = new WatcherProcessFailure(
'file watcher process crashed repeatedly',
'supervisor',
'supervisor_crash_fuse'
)
await pool.subscribe('/unstable', vi.fn(), {}, {})
supervisors[0].subscriptions[0].hooks.onTerminalError?.(failure)
await pool.subscribe('/unstable', vi.fn(), {}, {})
supervisors[1].subscriptions[0].hooks.onTerminalError?.(failure)
await expect(pool.subscribe('/unstable', vi.fn(), {}, {})).rejects.toThrow(
'failed again in quarantine'
)
pool.forgetRoot('/unstable')
await expect(pool.subscribe('/unstable', vi.fn(), {}, {})).resolves.toBeDefined()
expect(supervisors).toHaveLength(3)
})
it('disposes every supervisor and clears isolation on reset', async () => {
await pool.subscribe('/a', vi.fn(), {}, {})
await pool.subscribe('/b', vi.fn(), {}, {})
pool.resetForTest()
expect(supervisors.every((supervisor) => supervisor.dispose.mock.calls.length === 1)).toBe(true)
})
})
@@ -0,0 +1,251 @@
import { isWatcherProcessFailure, WatcherProcessFailure } from './parcel-watcher-process-failure'
import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor'
import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol'
import type {
WatcherProcessCallback,
WatcherProcessHooks,
WatcherProcessSubscription
} from './parcel-watcher-process-subscription'
const DEFAULT_MAX_SHARED_SUPERVISORS = 4
const DEFAULT_MAX_QUARANTINE_SUPERVISORS = 4
type WatcherSupervisor = Pick<WatcherProcessSupervisor, 'dispose' | 'subscribe'>
type RuntimeWatcherPoolSlot = {
supervisor: WatcherSupervisor
roots: Set<string>
isolated: boolean
retired: boolean
disposed: boolean
}
type RuntimeWatcherPoolAssignment = {
slot: RuntimeWatcherPoolSlot
leases: number
}
export type RuntimeWatcherProcessPoolOptions = {
maxSharedSupervisors?: number
maxQuarantineSupervisors?: number
createSupervisor?: () => WatcherSupervisor
}
/**
* Share a bounded set of watcher children while healthy, then move roots from
* a failed shard into a separate bounded quarantine pool.
*/
export class RuntimeWatcherProcessPool {
private readonly maxSharedSupervisors: number
private readonly maxQuarantineSupervisors: number
private readonly createSupervisor: () => WatcherSupervisor
private readonly activeSlots = new Set<RuntimeWatcherPoolSlot>()
private readonly allSlots = new Set<RuntimeWatcherPoolSlot>()
private readonly assignments = new Map<string, RuntimeWatcherPoolAssignment>()
private readonly isolatedRoots = new Set<string>()
private readonly failedQuarantineRoots = new Set<string>()
constructor(options: RuntimeWatcherProcessPoolOptions = {}) {
this.maxSharedSupervisors = Math.max(
1,
options.maxSharedSupervisors ?? DEFAULT_MAX_SHARED_SUPERVISORS
)
this.maxQuarantineSupervisors = Math.max(
1,
options.maxQuarantineSupervisors ?? DEFAULT_MAX_QUARANTINE_SUPERVISORS
)
this.createSupervisor = options.createSupervisor ?? (() => new WatcherProcessSupervisor())
}
async subscribe(
dir: string,
callback: WatcherProcessCallback,
opts: WatcherProcessSubscribeOptions,
hooks: WatcherProcessHooks = {}
): Promise<WatcherProcessSubscription> {
const assignment = this.assignmentForRoot(dir)
const { slot } = assignment
let assignmentReleased = false
const releaseAssignment = (): void => {
if (assignmentReleased) {
return
}
assignmentReleased = true
this.releaseRoot(assignment, dir)
}
const onTerminalError = (error: Error): void => {
if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
this.retireSlot(slot)
} else {
releaseAssignment()
if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') {
this.isolatedRoots.add(dir)
}
}
hooks.onTerminalError?.(error)
}
let subscription: WatcherProcessSubscription
try {
subscription = await slot.supervisor.subscribe(dir, callback, opts, {
...hooks,
onTerminalError
})
} catch (error) {
if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
this.retireSlot(slot)
} else {
releaseAssignment()
if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') {
this.isolatedRoots.add(dir)
}
}
throw error
}
let unsubscribePromise: Promise<void> | undefined
return {
unsubscribe: (): Promise<void> => {
unsubscribePromise ??= subscription.unsubscribe().finally(() => {
releaseAssignment()
})
return unsubscribePromise
}
}
}
/** Kill every pooled watcher child (production shutdown or test reset). */
dispose(): void {
// disposeSlot deletes the current slot from allSlots; deleting the
// in-progress element during Set iteration is safe, so no snapshot needed.
for (const slot of this.allSlots) {
this.disposeSlot(slot)
}
this.activeSlots.clear()
this.allSlots.clear()
this.assignments.clear()
this.isolatedRoots.clear()
this.failedQuarantineRoots.clear()
}
resetForTest(): void {
this.dispose()
}
forgetRoot(dir: string): void {
// Physical subscriptions release their assignment through unsubscribe or
// terminal callbacks. This clears fault history after setup gives up.
if (!this.assignments.has(dir)) {
this.isolatedRoots.delete(dir)
this.failedQuarantineRoots.delete(dir)
}
}
private assignmentForRoot(dir: string): RuntimeWatcherPoolAssignment {
if (this.failedQuarantineRoots.has(dir)) {
throw new WatcherProcessFailure(
'file watcher process failed again in quarantine',
'supervisor',
'supervisor_crash_fuse'
)
}
const assigned = this.assignments.get(dir)
if (assigned && !assigned.slot.retired) {
assigned.leases++
return assigned
}
const slot = this.isolatedRoots.has(dir) ? this.quarantineSlot() : this.sharedSlot()
const assignment = { slot, leases: 1 }
slot.roots.add(dir)
this.assignments.set(dir, assignment)
return assignment
}
private sharedSlot(): RuntimeWatcherPoolSlot {
const sharedSlots = Array.from(this.activeSlots).filter(
(slot) => !slot.isolated && !slot.retired
)
if (sharedSlots.length < this.maxSharedSupervisors) {
return this.createSlot(false)
}
return sharedSlots.reduce((leastLoaded, candidate) =>
candidate.roots.size < leastLoaded.roots.size ? candidate : leastLoaded
)
}
private quarantineSlot(): RuntimeWatcherPoolSlot {
const quarantineSlots = Array.from(this.activeSlots).filter(
(slot) => slot.isolated && !slot.retired
)
if (quarantineSlots.length < this.maxQuarantineSupervisors) {
return this.createSlot(true)
}
return quarantineSlots.reduce((leastLoaded, candidate) =>
candidate.roots.size < leastLoaded.roots.size ? candidate : leastLoaded
)
}
private createSlot(isolated: boolean): RuntimeWatcherPoolSlot {
const slot: RuntimeWatcherPoolSlot = {
supervisor: this.createSupervisor(),
roots: new Set(),
isolated,
retired: false,
disposed: false
}
this.activeSlots.add(slot)
this.allSlots.add(slot)
return slot
}
private retireSlot(slot: RuntimeWatcherPoolSlot): void {
if (slot.retired) {
return
}
slot.retired = true
this.activeSlots.delete(slot)
for (const root of slot.roots) {
if (this.assignments.get(root)?.slot === slot) {
this.assignments.delete(root)
}
if (slot.isolated) {
// Why: one bounded quarantine attempt is the recovery budget for a
// watch lifetime; repeated fused replacements would recreate churn.
this.isolatedRoots.delete(root)
this.failedQuarantineRoots.add(root)
} else {
this.isolatedRoots.add(root)
}
}
slot.roots.clear()
// Why: failAllSubscriptions is still iterating callbacks; defer disposal
// so every logical root receives the supervisor failure first.
queueMicrotask(() => this.disposeSlot(slot))
}
private releaseRoot(assignment: RuntimeWatcherPoolAssignment, dir: string): void {
if (this.assignments.get(dir) !== assignment) {
return
}
assignment.leases--
if (assignment.leases > 0) {
return
}
const { slot } = assignment
this.assignments.delete(dir)
slot.roots.delete(dir)
this.isolatedRoots.delete(dir)
if (slot.isolated && slot.roots.size === 0 && !slot.retired) {
this.activeSlots.delete(slot)
this.disposeSlot(slot)
}
}
private disposeSlot(slot: RuntimeWatcherPoolSlot): void {
if (slot.disposed) {
return
}
slot.disposed = true
slot.supervisor.dispose()
this.allSlots.delete(slot)
}
}
+21
View File
@@ -108,6 +108,27 @@ describe('registerSettingsHandlers', () => {
expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false })
})
it('applies bot-author deltas against the authoritative settings snapshot', () => {
store.getSettings
.mockReturnValueOnce({ prBotAuthorOverrides: ['alice'] })
.mockReturnValueOnce({ prBotAuthorOverrides: ['alice', 'bob'] })
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find(
(call) => call[0] === 'settings:update-pr-bot-author-override'
)?.[1] as (
event: typeof settingsInvokeEvent,
args: { author: string; isBot: boolean }
) => unknown
const result = handler(settingsInvokeEvent, { author: ' Bob ', isBot: true })
expect(store.updateSettings).toHaveBeenCalledWith(
{ prBotAuthorOverrides: ['alice', 'bob'] },
{ notifyListeners: true, originWebContentsId: 1 }
)
expect(result).toEqual({ prBotAuthorOverrides: ['alice', 'bob'] })
})
it('registers settings:previewWarpThemeImport handler', () => {
registerSettingsHandlers(store as never)
const channels = handleMock.mock.calls.map((call) => call[0])
+14
View File
@@ -21,6 +21,7 @@ import { normalizeDesktopTerminalScrollbackRows } from '../../shared/terminal-sc
import { normalizeTerminalLineHeight } from '../../shared/terminal-line-height-settings'
import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation'
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@@ -67,6 +68,19 @@ export function registerSettingsHandlers(
return store.getSettings()
})
ipcMain.handle(
'settings:update-pr-bot-author-override',
(event, args: { author: string; isBot: boolean }) => {
const current = store.getSettings().prBotAuthorOverrides
const next = applyPRBotAuthorOverride(current, args.author, args.isBot)
store.updateSettings(
{ prBotAuthorOverrides: next },
{ notifyListeners: true, originWebContentsId: event.sender.id }
)
return store.getSettings()
}
)
// Why: terminal panes can bind PTYs before async settings hydration
// completes. The side-effect authority kill switch is consulted once at
// transport creation, so the renderer needs the persisted value
@@ -0,0 +1,95 @@
import type { FsChangeEvent } from '../../shared/types'
import {
classifyWorktreeBaseChange,
type WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
type WorktreeBaseWatcherEvent = {
type: 'create' | 'update' | 'delete'
path: string
}
export type WorktreeBaseCollectedChanges = {
overflow: boolean
structureRepoIds: string[]
gitStatusRepoIds: string[]
}
function emptyChanges(): WorktreeBaseCollectedChanges {
return { overflow: false, structureRepoIds: [], gitStatusRepoIds: [] }
}
function addMatchingChange(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent,
structureRepoIds: Set<string>,
gitStatusRepoIds: Set<string>
): void {
const change = classifyWorktreeBaseChange(target, event)
for (const repoId of change.structureRepoIds) {
structureRepoIds.add(repoId)
}
for (const repoId of change.gitStatusRepoIds) {
gitStatusRepoIds.add(repoId)
}
}
function toCollectedChanges(
structureRepoIds: Set<string>,
gitStatusRepoIds: Set<string>
): WorktreeBaseCollectedChanges {
return {
overflow: false,
structureRepoIds: [...structureRepoIds],
gitStatusRepoIds: [...gitStatusRepoIds]
}
}
export function collectLocalWorktreeBaseChanges(
target: WorktreeBaseWatchTarget,
events: WorktreeBaseWatcherEvent[]
): WorktreeBaseCollectedChanges {
const structureRepoIds = new Set<string>()
const gitStatusRepoIds = new Set<string>()
for (const event of events) {
addMatchingChange(target, event, structureRepoIds, gitStatusRepoIds)
}
return toCollectedChanges(structureRepoIds, gitStatusRepoIds)
}
export function collectRemoteWorktreeBaseChanges(
target: WorktreeBaseWatchTarget,
events: FsChangeEvent[]
): WorktreeBaseCollectedChanges {
const structureRepoIds = new Set<string>()
const gitStatusRepoIds = new Set<string>()
for (const event of events) {
if (event.kind === 'overflow') {
return { ...emptyChanges(), overflow: true }
}
if (event.kind === 'rename') {
if (event.oldAbsolutePath) {
addMatchingChange(
target,
{ type: 'delete', path: event.oldAbsolutePath },
structureRepoIds,
gitStatusRepoIds
)
}
addMatchingChange(
target,
{ type: 'create', path: event.absolutePath },
structureRepoIds,
gitStatusRepoIds
)
continue
}
addMatchingChange(
target,
{ type: event.kind, path: event.absolutePath },
structureRepoIds,
gitStatusRepoIds
)
}
return toCollectedChanges(structureRepoIds, gitStatusRepoIds)
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import { join, win32 } from 'node:path'
import {
classifyWorktreeBaseChange,
matchingWorktreeBaseRepoIds,
type WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
@@ -17,29 +18,121 @@ function makeGitCommonTarget(): WorktreeBaseWatchTarget {
}
describe('matchingWorktreeBaseRepoIds (git-common)', () => {
it('matches linked-worktree metadata under worktrees/', () => {
it('classifies linked-worktree structural metadata under worktrees/', () => {
const target = makeGitCommonTarget()
expect(
matchingWorktreeBaseRepoIds(target, {
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'HEAD')
})
).toEqual(['repo-1'])
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
expect(
classifyWorktreeBaseChange(target, {
type: 'create',
path: join(COMMON_DIR, 'worktrees', 'wt-b')
})
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
expect(
matchingWorktreeBaseRepoIds(target, {
type: 'create',
type: 'delete',
path: join(COMMON_DIR, 'worktrees', 'wt-b')
})
).toEqual(['repo-1'])
})
it('matches primary-checkout branch/index metadata at the common-dir top level', () => {
it('classifies primary-checkout branch metadata as structural and index as status-only', () => {
const target = makeGitCommonTarget()
for (const file of ['HEAD', 'packed-refs', 'index']) {
for (const file of ['HEAD', 'packed-refs']) {
expect(
matchingWorktreeBaseRepoIds(target, { type: 'update', path: join(COMMON_DIR, file) })
).toEqual(['repo-1'])
classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, file) })
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
}
expect(
classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'index') })
).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
})
it('classifies linked-worktree index as status-only', () => {
const target = makeGitCommonTarget()
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index.lock')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] })
})
it('classifies HEAD reflog appends as status-only for linked and primary checkouts', () => {
const target = makeGitCommonTarget()
// commit --amend / reset --soft move HEAD without touching index or HEAD.
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'HEAD')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'logs', 'HEAD')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
// Per-ref reflogs churn on fetches and stay ignored.
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'logs', 'refs', 'heads', 'main')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] })
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'refs', 'heads', 'main')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] })
})
it('classifies worktree-scoped config as structural for sparse-flag freshness', () => {
const target = makeGitCommonTarget()
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'config.worktree')
})
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
expect(
classifyWorktreeBaseChange(target, {
type: 'create',
path: join(COMMON_DIR, 'config.worktree')
})
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
})
it('classifies Windows-shaped linked metadata paths', () => {
const commonDir = win32.join('C:\\', 'repos', 'project', '.git')
const target: WorktreeBaseWatchTarget = {
...makeGitCommonTarget(),
key: `git-common:local:${commonDir}`,
path: commonDir
}
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: win32.join(commonDir, 'worktrees', 'wt a', 'gitdir')
})
).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] })
expect(
classifyWorktreeBaseChange(target, {
type: 'update',
path: win32.join(commonDir, 'worktrees', 'wt a', 'index')
})
).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] })
})
it('ignores non-status common-dir churn', () => {
@@ -8,6 +8,11 @@ type WorktreeBaseWatcherEvent = {
path: string
}
export type WorktreeBaseChangeClass = {
structureRepoIds: string[]
gitStatusRepoIds: string[]
}
export type WorktreeBaseWatchKind = 'base' | 'git-common'
export type WorktreeBaseRepoWatchConfig = {
@@ -85,29 +90,95 @@ function matchingBaseRepoIds(
// Why: branch switches and commits in the primary checkout rewrite these
// top-level common-dir files; matching them keeps root-checkout branch/status
// as fresh as linked worktrees. Deeper churn (objects, refs, logs) is ignored.
const GIT_COMMON_PRIMARY_METADATA_FILES = new Set(['HEAD', 'packed-refs', 'index'])
// `config.worktree` is structural because it is the only file whose write
// flips `git worktree list`'s sparse flag, and no status/commit path touches
// it — so it cannot re-open the index-churn fanout this classifier closes.
const GIT_COMMON_PRIMARY_STRUCTURAL_FILES = new Set(['HEAD', 'packed-refs', 'config.worktree'])
const GIT_COMMON_PRIMARY_STATUS_FILES = new Set(['index'])
const GIT_COMMON_LINKED_STRUCTURAL_FILES = new Set(['HEAD', 'gitdir', 'locked', 'config.worktree'])
const GIT_COMMON_LINKED_STATUS_FILES = new Set(['index'])
// `logs/HEAD` is the status-only trigger for head moves that rewrite no
// watched leaf (commit --amend, reset --soft): every ref update through a
// checkout appends there, while `git status` churn never touches it.
function isHeadLogParts(parts: string[], offset: number): boolean {
return parts.length === offset + 2 && parts[offset] === 'logs' && parts[offset + 1] === 'HEAD'
}
function allRepoIds(target: WorktreeBaseWatchTarget): string[] {
return [...target.repos.keys()]
}
// Why: Git records linked worktrees under the common dir's `worktrees`
// metadata, which is lower churn than watching checkout contents.
function matchingGitCommonRepoIds(target: WorktreeBaseWatchTarget, eventPath: string): string[] {
const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath)
function classifyGitCommonEvent(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent
): WorktreeBaseChangeClass {
const parts = pathRelativeToWorktreeWatchRoot(target.path, event.path)
if (!parts) {
return []
return { structureRepoIds: [], gitStatusRepoIds: [] }
}
if (
parts[0] !== 'worktrees' &&
!(parts.length === 1 && GIT_COMMON_PRIMARY_METADATA_FILES.has(parts[0]))
) {
return []
const repoIds = allRepoIds(target)
if (parts.length === 1) {
if (parts[0] === 'worktrees') {
return { structureRepoIds: repoIds, gitStatusRepoIds: [] }
}
if (GIT_COMMON_PRIMARY_STRUCTURAL_FILES.has(parts[0])) {
return { structureRepoIds: repoIds, gitStatusRepoIds: [] }
}
if (GIT_COMMON_PRIMARY_STATUS_FILES.has(parts[0])) {
return { structureRepoIds: [], gitStatusRepoIds: repoIds }
}
return { structureRepoIds: [], gitStatusRepoIds: [] }
}
return [...target.repos.keys()]
if (parts[0] !== 'worktrees') {
if (isHeadLogParts(parts, 0)) {
return { structureRepoIds: [], gitStatusRepoIds: repoIds }
}
return { structureRepoIds: [], gitStatusRepoIds: [] }
}
if (parts.length === 2) {
return event.type === 'update'
? { structureRepoIds: [], gitStatusRepoIds: [] }
: { structureRepoIds: repoIds, gitStatusRepoIds: [] }
}
if (parts.length === 3) {
if (GIT_COMMON_LINKED_STRUCTURAL_FILES.has(parts[2])) {
return { structureRepoIds: repoIds, gitStatusRepoIds: [] }
}
if (GIT_COMMON_LINKED_STATUS_FILES.has(parts[2])) {
return { structureRepoIds: [], gitStatusRepoIds: repoIds }
}
}
if (isHeadLogParts(parts, 2)) {
return { structureRepoIds: [], gitStatusRepoIds: repoIds }
}
return { structureRepoIds: [], gitStatusRepoIds: [] }
}
function classifyBaseEvent(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent
): WorktreeBaseChangeClass {
return {
structureRepoIds: matchingBaseRepoIds(target, event.path, event.type),
gitStatusRepoIds: []
}
}
export function classifyWorktreeBaseChange(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent
): WorktreeBaseChangeClass {
return target.kind === 'git-common'
? classifyGitCommonEvent(target, event)
: classifyBaseEvent(target, event)
}
export function matchingWorktreeBaseRepoIds(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent
): string[] {
return target.kind === 'git-common'
? matchingGitCommonRepoIds(target, event.path)
: matchingBaseRepoIds(target, event.path, event.type)
return classifyWorktreeBaseChange(target, event).structureRepoIds
}
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, mkdir, realpath, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
@@ -171,7 +171,7 @@ describe('worktree base directory poller', () => {
expect(fullScans.length).toBeGreaterThan(0)
})
it('reports git-common worktrees metadata adds, updates, and removals via polling', async () => {
it('reports git-common entry creates, allowlisted leaf updates, and removals via polling', async () => {
const commonDir = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
@@ -190,12 +190,16 @@ describe('worktree base directory poller', () => {
flat.some((event) => event.type === 'create' && event.path === entry)
)
// HEAD/gitdir metadata writes land as new files in the entry dir, which
// bumps the entry dir mtime the poller compares.
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'update' && event.path === entry)
flat.some((event) => event.type === 'create' && event.path === join(entry, 'HEAD'))
)
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(join(entry, 'index'), 'status')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === join(entry, 'index'))
)
await rm(entry, { recursive: true })
@@ -204,6 +208,106 @@ describe('worktree base directory poller', () => {
)
})
it('detects linked HEAD rewrites even when the entry directory mtime is restored', async () => {
const commonDir = await makeRoot()
const entry = join(commonDir, 'worktrees', 'external-head')
await mkdir(entry, { recursive: true })
await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/main')
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS, platform: 'linux' }
)
cleanups.push(() => poller.unsubscribe())
const before = await stat(entry)
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/next')
await utimes(entry, before.atime, before.mtime)
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'update' && event.path === join(entry, 'HEAD'))
)
})
it('reports linked HEAD reflog appends that bump no watched leaf or entry dir', async () => {
const commonDir = await makeRoot()
const entry = join(commonDir, 'worktrees', 'external-amend')
await mkdir(join(entry, 'logs'), { recursive: true })
await writeFile(join(entry, 'logs', 'HEAD'), 'baseline\n')
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS, platform: 'linux' }
)
cleanups.push(() => poller.unsubscribe())
// commit --amend appends to the reflog inside logs/ — the entry dir mtime
// never moves, so the leaf must be stat'd directly.
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(join(entry, 'logs', 'HEAD'), 'baseline\namended\n')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'update' && event.path === join(entry, 'logs', 'HEAD'))
)
})
it('surfaces in-place index rewrites through the backstop re-stat', async () => {
const commonDir = await makeRoot()
const entry = join(commonDir, 'worktrees', 'external-inplace')
await mkdir(entry, { recursive: true })
await writeFile(join(entry, 'index'), 'index-v1')
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS, platform: 'linux' }
)
cleanups.push(() => poller.unsubscribe())
// An in-place rewrite leaves the entry-dir signature untouched, so only
// the periodic ungated re-stat can observe it.
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(join(entry, 'index'), 'index-v2-longer')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'update' && event.path === join(entry, 'index'))
)
})
it('reports primary-checkout HEAD reflog appends via polling', async () => {
const commonDir = await makeRoot()
await mkdir(join(commonDir, 'logs'), { recursive: true })
await writeFile(join(commonDir, 'logs', 'HEAD'), 'baseline\n')
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS, platform: 'linux' }
)
cleanups.push(() => poller.unsubscribe())
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(join(commonDir, 'logs', 'HEAD'), 'baseline\namended\n')
await waitForEvents(received, (flat) =>
flat.some(
(event) => event.type === 'update' && event.path === join(commonDir, 'logs', 'HEAD')
)
)
})
it('reports primary-checkout HEAD changes via polling', async () => {
const commonDir = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
@@ -14,6 +14,8 @@ vi.mock('./worktree-base-directory-poller', () => ({
}))
vi.mock('./worktree-remote', () => ({
notifyWorktreeGitStatusMetadataChanged: vi.fn(),
notifyWorktreeHeadIdentitiesChanged: vi.fn(),
notifyWorktreesChanged: vi.fn()
}))
@@ -21,8 +23,17 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn()
}))
vi.mock('./worktree-head-identity-reader', () => ({
readGitCommonHeadIdentities: vi.fn(async () => [])
}))
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { notifyWorktreesChanged } from './worktree-remote'
import {
notifyWorktreeGitStatusMetadataChanged,
notifyWorktreeHeadIdentitiesChanged,
notifyWorktreesChanged
} from './worktree-remote'
import { readGitCommonHeadIdentities } from './worktree-head-identity-reader'
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
import {
disposeWorktreeBaseDirectoryWatchers,
@@ -83,6 +94,7 @@ describe('worktree base directory watcher', () => {
watcherCallbacks.clear()
unsubscribeMocks.clear()
vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined)
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([])
vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation(
async (target, _getRepos, onEvents) => {
const unsubscribe = vi.fn(async () => {})
@@ -151,6 +163,140 @@ describe('worktree base directory watcher', () => {
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
})
it('debounces linked index bursts as status-only without structural notification', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') },
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') },
{ type: 'delete', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }
])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).not.toHaveBeenCalled()
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
})
it('keeps linked HEAD and lock metadata structural', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') },
{ type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'locked') }
])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled()
})
it('emits head identities for status-only head moves without structural fanout', async () => {
const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' }
])
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
await vi.advanceTimersByTimeAsync(0)
// External commit --amend: only logs/HEAD moves, no index write.
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: linkedWorktree, head: 'bbb222', branch: 'refs/heads/feature' }
])
emit(PROJECT_GIT_COMMON_DIR, [
{
type: 'update',
path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD')
}
])
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(0)
expect(notifyWorktreesChanged).not.toHaveBeenCalled()
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [
{ worktreePath: linkedWorktree, head: 'bbb222', branch: 'refs/heads/feature' }
])
})
it('does not emit head identities when a status-only burst leaves heads unchanged', async () => {
const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' }
])
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
await vi.advanceTimersByTimeAsync(0)
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }
])
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(0)
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
})
it('re-baselines head identities silently on structural notifications', async () => {
const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104')
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' }
])
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
await vi.advanceTimersByTimeAsync(0)
// Branch switch: structural path owns the refresh via the full listing.
vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([
{ worktreePath: linkedWorktree, head: 'ccc333', branch: 'refs/heads/other' }
])
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') }
])
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(0)
expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
// A later status-only event diffs against the re-baselined heads.
emit(PROJECT_GIT_COMMON_DIR, [
{ type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }
])
await vi.advanceTimersByTimeAsync(300)
await vi.advanceTimersByTimeAsync(0)
expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
})
it('never reads head identities for SSH watches', async () => {
const remoteCallbacks = new Map<string, (events: never[]) => void>()
const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => {
remoteCallbacks.set(root, callback)
return vi.fn()
})
vi.mocked(getSshFilesystemProvider).mockReturnValue({
stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
realpath: vi.fn(async (path: string) => path),
readFile: vi.fn(async () => ({ content: '', isBinary: false })),
watch: remoteWatch
} as never)
await syncWorktreeBaseDirectoryWatchers(
makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never,
makeWindow() as never
)
remoteCallbacks.get('/home/alice/project/.git')?.([
{
kind: 'update',
absolutePath: '/home/alice/project/.git/worktrees/wt/logs/HEAD'
}
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
expect(readGitCommonHeadIdentities).not.toHaveBeenCalled()
expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled()
})
it('does not install local desktop watchers for runtime or folder repos', async () => {
await syncWorktreeBaseDirectoryWatchers(
makeStore([
@@ -197,6 +343,42 @@ describe('worktree base directory watcher', () => {
expect(remoteUnwatch).toHaveBeenCalled()
})
it('treats remote index renames as status-only and overflow as structural', async () => {
const remoteCallbacks = new Map<string, (events: never[]) => void>()
const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => {
remoteCallbacks.set(root, callback)
return vi.fn()
})
vi.mocked(getSshFilesystemProvider).mockReturnValue({
stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
realpath: vi.fn(async (path: string) => path),
readFile: vi.fn(async () => ({ content: '', isBinary: false })),
watch: remoteWatch
} as never)
await syncWorktreeBaseDirectoryWatchers(
makeStore([makeRepo({ connectionId: 'ssh-1', path: '/home/alice/project' })]) as never,
makeWindow() as never
)
remoteCallbacks.get('/home/alice/project/.git')?.([
{
kind: 'rename',
oldAbsolutePath: '/home/alice/project/.git/worktrees/wt/index.lock',
absolutePath: '/home/alice/project/.git/worktrees/wt/index'
}
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).not.toHaveBeenCalled()
expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1)
vi.mocked(notifyWorktreeGitStatusMetadataChanged).mockClear()
remoteCallbacks.get('/home/alice/project/.git')?.([{ kind: 'overflow' }] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled()
})
it('unsubscribes roots that disappear after repo settings change', async () => {
const repo = makeRepo()
const store = makeStore([repo])
+83 -49
View File
@@ -1,12 +1,18 @@
import type { BrowserWindow } from 'electron'
import type { FsChangeEvent } from '../../shared/types'
import type { Store } from '../persistence'
import { notifyWorktreesChanged } from './worktree-remote'
import { notifyWorktreeGitStatusMetadataChanged, notifyWorktreesChanged } from './worktree-remote'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
matchingWorktreeBaseRepoIds,
type WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
createWorktreeHeadIdentityRefreshState,
refreshWorktreeHeadIdentities,
type WorktreeHeadIdentityRefreshState
} from './worktree-head-identity-refresh'
import {
collectLocalWorktreeBaseChanges,
collectRemoteWorktreeBaseChanges,
type WorktreeBaseCollectedChanges
} from './worktree-base-directory-change-collector'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import {
buildWorktreeBaseDirectoryWatchTargets,
clearWorktreeBaseDirectoryWatchTargetWarnings
@@ -17,7 +23,9 @@ type ActiveWatch = WorktreeBaseWatchTarget & {
mainWindow: BrowserWindow
subscription: { unsubscribe: () => Promise<void> }
notifyTimer: ReturnType<typeof setTimeout> | null
pendingRepoIds: Set<string>
pendingStructureRepoIds: Set<string>
pendingGitStatusRepoIds: Set<string>
headIdentityRefresh: WorktreeHeadIdentityRefreshState
disposed: boolean
}
@@ -27,13 +35,20 @@ let syncGeneration = 0
let scheduledSync: ReturnType<typeof setTimeout> | null = null
let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null
function scheduleNotification(watch: ActiveWatch, repoIds: readonly string[]): void {
function scheduleNotification(
watch: ActiveWatch,
changes: { structureRepoIds?: readonly string[]; gitStatusRepoIds?: readonly string[] }
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
watch.pendingRepoIds.clear()
watch.pendingStructureRepoIds.clear()
watch.pendingGitStatusRepoIds.clear()
return
}
for (const repoId of repoIds) {
watch.pendingRepoIds.add(repoId)
for (const repoId of changes.structureRepoIds ?? []) {
watch.pendingStructureRepoIds.add(repoId)
}
for (const repoId of changes.gitStatusRepoIds ?? []) {
watch.pendingGitStatusRepoIds.add(repoId)
}
if (watch.notifyTimer) {
clearTimeout(watch.notifyTimer)
@@ -41,26 +56,43 @@ function scheduleNotification(watch: ActiveWatch, repoIds: readonly string[]): v
watch.notifyTimer = setTimeout(() => {
watch.notifyTimer = null
if (watch.disposed || watch.mainWindow.isDestroyed()) {
watch.pendingRepoIds.clear()
watch.pendingStructureRepoIds.clear()
watch.pendingGitStatusRepoIds.clear()
return
}
const pending = [...watch.pendingRepoIds]
watch.pendingRepoIds.clear()
for (const repoId of pending) {
const pendingStructure = [...watch.pendingStructureRepoIds]
const pendingGitStatus = [...watch.pendingGitStatusRepoIds].filter(
(repoId) => !watch.pendingStructureRepoIds.has(repoId)
)
watch.pendingStructureRepoIds.clear()
watch.pendingGitStatusRepoIds.clear()
for (const repoId of pendingStructure) {
notifyWorktreesChanged(watch.mainWindow, repoId)
}
for (const repoId of pendingGitStatus) {
notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId)
}
if (supportsHeadIdentityRefresh(watch)) {
// Emit only when no structural notification fired this tick: structural
// paths already run the authoritative worktree listing; the silent pass
// just re-baselines so later status-only diffs stay accurate.
void refreshWorktreeHeadIdentities(
watch,
watch.headIdentityRefresh,
pendingStructure.length === 0
)
}
}, WATCH_DEBOUNCE_MS)
}
function collectMatchingRepoIds(
watch: ActiveWatch,
eventType: 'create' | 'update' | 'delete',
eventPath: string,
repoIds: Set<string>
): void {
for (const repoId of matchingWorktreeBaseRepoIds(watch, { type: eventType, path: eventPath })) {
repoIds.add(repoId)
}
// Why: SSH common dirs would need per-signal network reads to diff heads;
// remote background freshness stays on the structural path for now.
function supportsHeadIdentityRefresh(watch: ActiveWatch): boolean {
return watch.kind === 'git-common' && !watch.connectionId
}
function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean {
return changes.structureRepoIds.length > 0 || changes.gitStatusRepoIds.length > 0
}
function handleLocalWatchEvents(
@@ -73,39 +105,29 @@ function handleLocalWatchEvents(
}
if (error) {
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
scheduleNotification(watch, [...watch.repos.keys()])
scheduleNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
return
}
const repoIds = new Set<string>()
for (const event of events) {
collectMatchingRepoIds(watch, event.type, event.path, repoIds)
}
if (repoIds.size > 0) {
scheduleNotification(watch, [...repoIds])
const changes = collectLocalWorktreeBaseChanges(watch, events)
if (hasCollectedChanges(changes)) {
scheduleNotification(watch, changes)
}
}
function handleRemoteWatchEvents(watch: ActiveWatch, events: FsChangeEvent[]): void {
function handleRemoteWatchEvents(
watch: ActiveWatch,
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1]
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
const repoIds = new Set<string>()
for (const event of events) {
if (event.kind === 'overflow') {
scheduleNotification(watch, [...watch.repos.keys()])
return
}
if (event.kind === 'rename') {
if (event.oldAbsolutePath) {
collectMatchingRepoIds(watch, 'delete', event.oldAbsolutePath, repoIds)
}
collectMatchingRepoIds(watch, 'create', event.absolutePath, repoIds)
continue
}
collectMatchingRepoIds(watch, event.kind, event.absolutePath, repoIds)
const changes = collectRemoteWorktreeBaseChanges(watch, events)
if (changes.overflow) {
scheduleNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
return
}
if (repoIds.size > 0) {
scheduleNotification(watch, [...repoIds])
if (hasCollectedChanges(changes)) {
scheduleNotification(watch, changes)
}
}
@@ -131,7 +153,9 @@ async function subscribeTarget(
mainWindow,
subscription: { unsubscribe: async () => unwatch() },
notifyTimer: null,
pendingRepoIds: new Set(),
pendingStructureRepoIds: new Set(),
pendingGitStatusRepoIds: new Set(),
headIdentityRefresh: createWorktreeHeadIdentityRefreshState(),
disposed: false
}
return activeWatch
@@ -157,9 +181,17 @@ async function subscribeTarget(
mainWindow,
subscription,
notifyTimer: null,
pendingRepoIds: new Set(),
pendingStructureRepoIds: new Set(),
pendingGitStatusRepoIds: new Set(),
headIdentityRefresh: createWorktreeHeadIdentityRefreshState(),
disposed: false
}
if (supportsHeadIdentityRefresh(activeWatch)) {
// Baseline eagerly so the first status-only signal — possibly hours after
// subscribe — diffs against subscribe-time heads instead of silently
// re-baselining past an external commit.
void refreshWorktreeHeadIdentities(activeWatch, activeWatch.headIdentityRefresh, false)
}
return activeWatch
}
@@ -199,6 +231,8 @@ async function removeWatch(key: string): Promise<void> {
if (watch.notifyTimer) {
clearTimeout(watch.notifyTimer)
}
watch.pendingStructureRepoIds.clear()
watch.pendingGitStatusRepoIds.clear()
await watch.subscription.unsubscribe().catch((error) => {
console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error)
})
+273
View File
@@ -0,0 +1,273 @@
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type {
WorktreeBasePollEvent,
WorktreeBaseSubscription
} from './worktree-base-directory-poller'
// Shared with the darwin primary-metadata poll so the platforms cannot drift
// on which shallow leaves count as watchable metadata. `logs/HEAD` catches
// head moves that rewrite no other watched leaf (commit --amend, reset
// --soft); `config.worktree` carries the sparse flag.
export const PRIMARY_CHECKOUT_METADATA_FILES = [
'HEAD',
'packed-refs',
'index',
'config.worktree',
'logs/HEAD'
]
const LINKED_WORKTREE_STRUCTURAL_METADATA_FILES = ['HEAD', 'gitdir', 'locked', 'config.worktree']
const LINKED_WORKTREE_INDEX_FILE = 'index'
const LINKED_WORKTREE_HEAD_LOG_FILE = join('logs', 'HEAD')
// Why: the entry-dir signature gate can miss same-granule index rewrites on
// coarse-mtime filesystems; a periodic ungated re-stat bounds that miss the
// same way the base poller's backstop rescan does.
const INDEX_BACKSTOP_TICKS = 15
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number; size: number }): string {
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}:${s.size}`
}
async function fileSignature(path: string): Promise<string | null> {
try {
const s = await stat(path)
return s.isFile() ? statSignature(s) : null
} catch {
return null
}
}
async function pathSignature(path: string): Promise<string | null> {
try {
const s = await stat(path)
// Why: omitting ctime keeps unrelated metadata churn from re-opening the
// index gate, which would make the HEAD regression test vacuous. The gate
// is load-bearing for index-event emission between backstop ticks; the
// renderer's status poll is the ultimate freshness net.
return `${s.mtimeMs}:${s.ino}:${s.size}`
} catch {
return null
}
}
type GitCommonEntrySnapshot = {
dirSignature: string | null
structuralSignatures: Map<string, string>
indexSignature: string | null
headLogSignature: string | null
}
type GitCommonSnapshot = {
worktreesDirSignature: string | null
entries: Map<string, GitCommonEntrySnapshot>
primarySignatures: Map<string, string>
}
async function snapshotGitCommonEntry(
entryPath: string,
previous: GitCommonEntrySnapshot | undefined,
forceIndexRead: boolean
): Promise<GitCommonEntrySnapshot> {
const dirSignature = await pathSignature(entryPath)
const structuralSignatures = new Map<string, string>()
await Promise.all(
LINKED_WORKTREE_STRUCTURAL_METADATA_FILES.map(async (name) => {
const signature = await fileSignature(join(entryPath, name))
if (signature !== null) {
structuralSignatures.set(name, signature)
}
})
)
// `logs/HEAD` lives in a subdirectory, so appends never bump the entry-dir
// mtime — it must be stat'd every tick rather than gated like `index`.
const headLogSignature = await fileSignature(join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE))
const shouldReadIndex = forceIndexRead || !previous || previous.dirSignature !== dirSignature
const indexSignature = shouldReadIndex
? await fileSignature(join(entryPath, LINKED_WORKTREE_INDEX_FILE))
: previous.indexSignature
return { dirSignature, structuralSignatures, indexSignature, headLogSignature }
}
async function snapshotPrimaryCheckoutSignatures(
commonDirPath: string
): Promise<Map<string, string>> {
const signatures = new Map<string, string>()
await Promise.all(
PRIMARY_CHECKOUT_METADATA_FILES.map(async (name) => {
const signature = await fileSignature(join(commonDirPath, name))
if (signature !== null) {
signatures.set(name, signature)
}
})
)
return signatures
}
async function snapshotGitCommon(
commonDirPath: string,
previous?: GitCommonSnapshot,
includePrimary = true,
forceIndexRead = false
): Promise<GitCommonSnapshot> {
const entriesByPath = new Map<string, GitCommonEntrySnapshot>()
const worktreesDir = join(commonDirPath, 'worktrees')
const worktreesDirSignature = await pathSignature(worktreesDir)
const primarySignatures = includePrimary
? await snapshotPrimaryCheckoutSignatures(commonDirPath)
: new Map<string, string>()
let entries
try {
entries = await readdir(worktreesDir, { withFileTypes: true })
} catch {
// Missing worktrees dir is normal for repos without linked worktrees.
return {
worktreesDirSignature,
entries: entriesByPath,
primarySignatures
}
}
await Promise.all(
entries.map(async (entry) => {
if (!entry.isDirectory()) {
return
}
const entryPath = join(worktreesDir, entry.name)
entriesByPath.set(
entryPath,
await snapshotGitCommonEntry(entryPath, previous?.entries.get(entryPath), forceIndexRead)
)
})
)
return {
worktreesDirSignature,
entries: entriesByPath,
primarySignatures
}
}
function classifySignatureDiff(
prevSignature: string | null | undefined,
nextSignature: string | null | undefined
): 'create' | 'update' | 'delete' | null {
if (prevSignature == null && nextSignature == null) {
return null
}
if (prevSignature == null) {
return 'create'
}
if (nextSignature == null) {
return 'delete'
}
return prevSignature === nextSignature ? null : 'update'
}
function diffSignatureMaps(
prev: Map<string, string>,
next: Map<string, string>,
resolvePath: (name: string) => string
): WorktreeBasePollEvent[] {
const events: WorktreeBasePollEvent[] = []
const names = new Set([...prev.keys(), ...next.keys()])
for (const name of names) {
const type = classifySignatureDiff(prev.get(name), next.get(name))
if (type) {
events.push({ type, path: resolvePath(name) })
}
}
return events
}
function diffGitCommon(
commonDirPath: string,
prev: GitCommonSnapshot,
next: GitCommonSnapshot
): WorktreeBasePollEvent[] {
const events: WorktreeBasePollEvent[] = []
const worktreesDir = join(commonDirPath, 'worktrees')
const worktreesDirDiff = classifySignatureDiff(
prev.worktreesDirSignature,
next.worktreesDirSignature
)
if (worktreesDirDiff) {
events.push({ type: worktreesDirDiff, path: worktreesDir })
}
for (const [entryPath, entry] of next.entries) {
const prevEntry = prev.entries.get(entryPath)
if (!prevEntry) {
events.push({ type: 'create', path: entryPath })
continue
}
events.push(
...diffSignatureMaps(prevEntry.structuralSignatures, entry.structuralSignatures, (name) =>
join(entryPath, name)
)
)
const indexDiff = classifySignatureDiff(prevEntry.indexSignature, entry.indexSignature)
if (indexDiff) {
events.push({ type: indexDiff, path: join(entryPath, LINKED_WORKTREE_INDEX_FILE) })
}
const headLogDiff = classifySignatureDiff(prevEntry.headLogSignature, entry.headLogSignature)
if (headLogDiff) {
events.push({ type: headLogDiff, path: join(entryPath, LINKED_WORKTREE_HEAD_LOG_FILE) })
}
}
for (const entryPath of prev.entries.keys()) {
if (!next.entries.has(entryPath)) {
events.push({ type: 'delete', path: entryPath })
}
}
events.push(
...diffSignatureMaps(prev.primarySignatures, next.primarySignatures, (name) =>
join(commonDirPath, name)
)
)
return events
}
export async function startGitCommonPolling(
commonDirPath: string,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
onFullScan?: () => void,
includePrimary = true
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
let snapshot = await snapshotGitCommon(commonDirPath, undefined, includePrimary)
const timer = setInterval(() => {
if (disposed || ticking) {
return
}
ticking = true
tickCount++
const forceIndexRead = tickCount % INDEX_BACKSTOP_TICKS === 0
onFullScan?.()
void snapshotGitCommon(commonDirPath, snapshot, includePrimary, forceIndexRead)
.then((next) => {
if (disposed) {
return
}
const events = diffGitCommon(commonDirPath, snapshot, next)
snapshot = next
if (events.length > 0) {
onEvents(events)
}
})
.catch(() => {
// Transient fs error: keep the previous snapshot and retry next tick.
})
.finally(() => {
ticking = false
})
}, pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
clearInterval(timer)
}
}
}
+17 -55
View File
@@ -1,10 +1,14 @@
import { readdir, stat } from 'node:fs/promises'
import { stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import type {
WorktreeBasePollEvent,
WorktreeBaseSubscription
} from './worktree-base-directory-poller'
import {
PRIMARY_CHECKOUT_METADATA_FILES,
startGitCommonPolling
} from './worktree-git-common-polling'
// Watches a repo's `<common>/.git/worktrees` metadata plus the primary
// checkout's shallow branch/index files — the only paths the git-common event
@@ -20,8 +24,6 @@ import type {
// Why: branch switches and commits made in the primary checkout rewrite these
// top-level files (linked-worktree equivalents live under `worktrees/`).
// Deliberately excludes FETCH_HEAD-style churn that carries no status change.
const PRIMARY_CHECKOUT_METADATA_FILES = ['HEAD', 'packed-refs', 'index']
async function snapshotPrimaryCheckoutMetadata(
commonDirPath: string
): Promise<Map<string, number>> {
@@ -37,46 +39,22 @@ async function snapshotPrimaryCheckoutMetadata(
return mtimes
}
async function snapshotGitCommon(commonDirPath: string): Promise<Map<string, number>> {
const mtimes = new Map<string, number>()
const worktreesDir = join(commonDirPath, 'worktrees')
let entries
try {
entries = await readdir(worktreesDir, { withFileTypes: true })
} catch {
// Missing worktrees dir is normal for repos without linked worktrees.
return mtimes
}
for (const entry of entries) {
const entryPath = join(worktreesDir, entry.name)
try {
// Entry-dir mtime covers the metadata writes the old recursive watcher
// reacted to (HEAD/gitdir/locked are written via rename into the entry
// dir, which bumps its mtime).
mtimes.set(entryPath, (await stat(entryPath)).mtimeMs)
} catch {
// Entry removed between readdir and stat.
}
}
return mtimes
}
function diffGitCommon(
function diffMtimeMap(
prev: Map<string, number>,
next: Map<string, number>
): WorktreeBasePollEvent[] {
const events: WorktreeBasePollEvent[] = []
for (const [entryPath, mtime] of next) {
const prevMtime = prev.get(entryPath)
for (const [path, mtime] of next) {
const prevMtime = prev.get(path)
if (prevMtime === undefined) {
events.push({ type: 'create', path: entryPath })
events.push({ type: 'create', path })
} else if (prevMtime !== mtime) {
events.push({ type: 'update', path: entryPath })
events.push({ type: 'update', path })
}
}
for (const entryPath of prev.keys()) {
if (!next.has(entryPath)) {
events.push({ type: 'delete', path: entryPath })
for (const path of prev.keys()) {
if (!next.has(path)) {
events.push({ type: 'delete', path })
}
}
return events
@@ -103,7 +81,7 @@ async function startSnapshotDiffPoller(
if (disposed) {
return
}
const events = diffGitCommon(snapshot, next)
const events = diffMtimeMap(snapshot, next)
snapshot = next
if (events.length > 0) {
onEvents(events)
@@ -126,19 +104,6 @@ async function startSnapshotDiffPoller(
}
}
async function snapshotGitCommonAndPrimaryMetadata(
commonDirPath: string
): Promise<Map<string, number>> {
const [worktrees, primary] = await Promise.all([
snapshotGitCommon(commonDirPath),
snapshotPrimaryCheckoutMetadata(commonDirPath)
])
for (const [path, mtime] of primary) {
worktrees.set(path, mtime)
}
return worktrees
}
async function startGitCommonNarrowWatch(
target: WorktreeBaseWatchTarget,
onEvents: (events: WorktreeBasePollEvent[]) => void,
@@ -239,6 +204,8 @@ async function startGitCommonNarrowWatch(
}
if (!(await trySubscribe())) {
// Why: repos commonly start without linked worktrees; retrying the narrow
// subscription lets macOS upgrade to native events when the directory appears.
armExistencePoll()
}
@@ -278,10 +245,5 @@ export async function startGitCommonWatch(
}
}
}
return startSnapshotDiffPoller(
() => snapshotGitCommonAndPrimaryMetadata(target.path),
onEvents,
pollIntervalMs,
onFullScan
)
return startGitCommonPolling(target.path, onEvents, pollIntervalMs, onFullScan)
}
@@ -0,0 +1,152 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { readGitCommonHeadIdentities } from './worktree-head-identity-reader'
const OID_A = 'a'.repeat(40)
const OID_B = 'b'.repeat(40)
const OID_C = 'c'.repeat(40)
describe('readGitCommonHeadIdentities', () => {
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function makeCommonDir(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-head-reader-'))
roots.push(root)
const commonDir = join(root, 'checkout', '.git')
await mkdir(commonDir, { recursive: true })
return commonDir
}
async function writeLooseRef(commonDir: string, ref: string, oid: string): Promise<void> {
const refPath = join(commonDir, ...ref.split('/'))
await mkdir(dirname(refPath), { recursive: true })
await writeFile(refPath, `${oid}\n`)
}
async function addLinkedWorktree(
commonDir: string,
name: string,
worktreePath: string,
headContent: string
): Promise<void> {
const entry = join(commonDir, 'worktrees', name)
await mkdir(entry, { recursive: true })
await writeFile(join(entry, 'HEAD'), `${headContent}\n`)
await writeFile(join(entry, 'gitdir'), `${join(worktreePath, '.git')}\n`)
}
it('resolves the primary checkout and linked worktrees from loose refs', async () => {
const commonDir = await makeCommonDir()
await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n')
await writeLooseRef(commonDir, 'refs/heads/main', OID_A)
await writeLooseRef(commonDir, 'refs/heads/feature', OID_B)
const linkedPath = join(dirname(commonDir), '..', 'linked-wt')
await addLinkedWorktree(commonDir, 'linked-wt', linkedPath, 'ref: refs/heads/feature')
const identities = await readGitCommonHeadIdentities(commonDir)
expect(identities).toContainEqual({
worktreePath: dirname(commonDir),
head: OID_A,
branch: 'refs/heads/main'
})
expect(identities).toContainEqual({
worktreePath: linkedPath,
head: OID_B,
branch: 'refs/heads/feature'
})
})
it('falls back to packed-refs when the loose ref is absent', async () => {
const commonDir = await makeCommonDir()
await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n')
await writeFile(
join(commonDir, 'packed-refs'),
`# pack-refs with: peeled fully-peeled sorted\n${OID_C} refs/heads/main\n^${OID_A}\n`
)
const identities = await readGitCommonHeadIdentities(commonDir)
expect(identities).toEqual([
{ worktreePath: dirname(commonDir), head: OID_C, branch: 'refs/heads/main' }
])
})
it('reports detached HEAD as a raw oid with a null branch', async () => {
const commonDir = await makeCommonDir()
await writeFile(join(commonDir, 'HEAD'), `${OID_B}\n`)
const identities = await readGitCommonHeadIdentities(commonDir)
expect(identities).toEqual([{ worktreePath: dirname(commonDir), head: OID_B, branch: null }])
})
it('skips unborn branches instead of emitting partial identities', async () => {
const commonDir = await makeCommonDir()
await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/unborn\n')
expect(await readGitCommonHeadIdentities(commonDir)).toEqual([])
})
it('resolves relative gitdir entries against the metadata dir', async () => {
const commonDir = await makeCommonDir()
await writeLooseRef(commonDir, 'refs/heads/feature', OID_B)
const entry = join(commonDir, 'worktrees', 'rel-wt')
await mkdir(entry, { recursive: true })
await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/feature\n')
await writeFile(join(entry, 'gitdir'), `${join('..', '..', '..', '..', 'rel-wt', '.git')}\n`)
const identities = await readGitCommonHeadIdentities(commonDir)
expect(identities).toEqual([
{
worktreePath: join(dirname(commonDir), '..', 'rel-wt'),
head: OID_B,
branch: 'refs/heads/feature'
}
])
})
it('rejects traversal-shaped symrefs instead of reading outside the common dir', async () => {
const commonDir = await makeCommonDir()
// A crafted repo can put arbitrary content in HEAD; backslash segments
// would traverse on Windows where join treats them as separators too.
await writeFile(join(dirname(commonDir), '..', 'outside.txt'), 'secret\n')
for (const ref of [
'refs/heads/../../../outside.txt',
'refs\\..\\..\\..\\outside.txt',
'refs/heads/evil:name',
'refs//heads'
]) {
await writeFile(join(commonDir, 'HEAD'), `ref: ${ref}\n`)
expect(await readGitCommonHeadIdentities(commonDir)).toEqual([])
}
})
it('never emits resolved content that is not a hex object id', async () => {
const commonDir = await makeCommonDir()
await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n')
await writeLooseRef(commonDir, 'refs/heads/main', 'not-an-object-id')
expect(await readGitCommonHeadIdentities(commonDir)).toEqual([])
await writeFile(join(commonDir, 'HEAD'), 'this is not a detached oid\n')
expect(await readGitCommonHeadIdentities(commonDir)).toEqual([])
})
it('omits the primary row for non-standard common dir layouts', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-head-reader-'))
roots.push(root)
const commonDir = join(root, 'bare-repo')
await mkdir(commonDir, { recursive: true })
await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n')
await writeLooseRef(commonDir, 'refs/heads/main', OID_A)
expect(await readGitCommonHeadIdentities(commonDir)).toEqual([])
})
})
@@ -0,0 +1,161 @@
import { readdir, readFile } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join } from 'node:path'
import type { WorktreeHeadIdentity } from '../../shared/types'
// Why: the whole point of this reader is replacing `git worktree list` fanout
// with bounded metadata-file reads, so head freshness never re-creates the
// spawn pressure that stalled terminal input. Keep it spawn-free.
const MAX_SYMREF_DEPTH = 5
async function readTrimmedFile(path: string): Promise<string | null> {
try {
return (await readFile(path, 'utf8')).trim()
} catch {
return null
}
}
// packed-refs lines are `<oid> <ref>`; `#` headers and `^` peel lines skipped.
async function readPackedRefs(commonDirPath: string): Promise<Map<string, string>> {
const refs = new Map<string, string>()
const content = await readTrimmedFile(join(commonDirPath, 'packed-refs'))
if (content === null) {
return refs
}
for (const line of content.split('\n')) {
if (!line || line.startsWith('#') || line.startsWith('^')) {
continue
}
const separator = line.indexOf(' ')
if (separator <= 0) {
continue
}
refs.set(line.slice(separator + 1).trim(), line.slice(0, separator))
}
return refs
}
// Why: ref content comes from repo files an attacker can craft. Git forbids
// `\` and `:` in ref names, and on Windows `join` also treats `\` as a
// separator — both must be rejected before splicing the ref into a file path.
function isSafeRefName(ref: string): boolean {
if (ref.length === 0 || ref.includes('\\') || ref.includes(':')) {
return false
}
return !ref.split('/').some((part) => part === '..' || part === '')
}
// SHA-1 (40) or SHA-256 (64) object id. Anything else read from disk is not a
// head and must never be emitted — this also caps what any path escape could leak.
const OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
function asObjectId(value: string | null | undefined): string | null {
return value != null && OBJECT_ID_PATTERN.test(value) ? value : null
}
async function resolveRefToOid(
commonDirPath: string,
ref: string,
packedRefs: () => Promise<Map<string, string>>
): Promise<string | null> {
let current = ref
for (let depth = 0; depth < MAX_SYMREF_DEPTH; depth++) {
if (!isSafeRefName(current)) {
return null
}
// Branch refs are shared repo state, so loose files live in the common dir.
const loose = await readTrimmedFile(join(commonDirPath, ...current.split('/')))
if (loose === null) {
return asObjectId((await packedRefs()).get(current))
}
if (loose.startsWith('ref: ')) {
current = loose.slice('ref: '.length).trim()
continue
}
return asObjectId(loose)
}
return null
}
async function readHeadIdentity(
commonDirPath: string,
headFilePath: string,
worktreePath: string,
packedRefs: () => Promise<Map<string, string>>
): Promise<WorktreeHeadIdentity | null> {
const head = await readTrimmedFile(headFilePath)
if (!head) {
return null
}
if (head.startsWith('ref: ')) {
const ref = head.slice('ref: '.length).trim()
const oid = await resolveRefToOid(commonDirPath, ref, packedRefs)
// Unborn branches (no commit yet) stay covered by the structural listing.
if (!oid) {
return null
}
return { worktreePath, head: oid, branch: ref }
}
const detachedOid = asObjectId(head)
return detachedOid ? { worktreePath, head: detachedOid, branch: null } : null
}
/** Reads head/branch for the primary checkout and every linked worktree of a
* Git common dir using only metadata-file reads (HEAD, gitdir, loose refs,
* packed-refs) — no Git subprocess. Unresolvable entries are skipped so
* callers never overwrite store state with partial reads. */
export async function readGitCommonHeadIdentities(
commonDirPath: string
): Promise<WorktreeHeadIdentity[]> {
let packedRefsPromise: Promise<Map<string, string>> | null = null
const packedRefs = (): Promise<Map<string, string>> =>
(packedRefsPromise ??= readPackedRefs(commonDirPath))
const identities: WorktreeHeadIdentity[] = []
// Only the standard `<checkout>/.git` layout maps a common dir back to its
// primary checkout path; bare/custom GIT_DIR layouts have no primary row.
if (basename(commonDirPath) === '.git') {
const primary = await readHeadIdentity(
commonDirPath,
join(commonDirPath, 'HEAD'),
dirname(commonDirPath),
packedRefs
)
if (primary) {
identities.push(primary)
}
}
let entries
try {
entries = await readdir(join(commonDirPath, 'worktrees'), { withFileTypes: true })
} catch {
return identities
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue
}
const entryPath = join(commonDirPath, 'worktrees', entry.name)
const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir'))
if (!gitdirContent) {
continue
}
// `gitdir` holds `<worktree>/.git`, absolute or (with relative-path
// worktrees) relative to the entry dir.
const gitdirAbsolute = isAbsolute(gitdirContent)
? gitdirContent
: join(entryPath, gitdirContent)
const identity = await readHeadIdentity(
commonDirPath,
join(entryPath, 'HEAD'),
dirname(gitdirAbsolute),
packedRefs
)
if (identity) {
identities.push(identity)
}
}
return identities
}
@@ -0,0 +1,79 @@
import type { BrowserWindow } from 'electron'
import { notifyWorktreeHeadIdentitiesChanged } from './worktree-remote'
import { readGitCommonHeadIdentities } from './worktree-head-identity-reader'
type HeadIdentityWatchHost = {
path: string
repos: ReadonlyMap<string, unknown>
mainWindow: BrowserWindow
disposed: boolean
}
export type WorktreeHeadIdentityRefreshState = {
/** worktreePath → `${head} ${branch}` from the last metadata-file read. */
baseline: Map<string, string> | null
inFlight: boolean
queued: boolean
queuedEmit: boolean
}
export function createWorktreeHeadIdentityRefreshState(): WorktreeHeadIdentityRefreshState {
return { baseline: null, inFlight: false, queued: false, queuedEmit: false }
}
function headIdentitySignature(identity: { head: string; branch: string | null }): string {
return `${identity.head} ${identity.branch ?? ''}`
}
/** Diffs metadata-file head reads against the previous baseline and notifies
* only actual head moves, so status-only churn (index rewrites from external
* `git status`) stays silent and never re-enters structural fanout. Passing
* `emit: false` re-baselines without notifying — structural ticks already
* run the authoritative worktree listing. */
export async function refreshWorktreeHeadIdentities(
host: HeadIdentityWatchHost,
state: WorktreeHeadIdentityRefreshState,
emit: boolean
): Promise<void> {
if (host.disposed || host.mainWindow.isDestroyed()) {
return
}
if (state.inFlight) {
state.queued = true
state.queuedEmit ||= emit
return
}
state.inFlight = true
try {
const identities = await readGitCommonHeadIdentities(host.path)
if (host.disposed || host.mainWindow.isDestroyed()) {
return
}
const baseline = state.baseline
state.baseline = new Map(
identities.map((identity) => [identity.worktreePath, headIdentitySignature(identity)])
)
if (!baseline || !emit) {
return
}
const changed = identities.filter(
(identity) => baseline.get(identity.worktreePath) !== headIdentitySignature(identity)
)
if (changed.length === 0) {
return
}
for (const repoId of host.repos.keys()) {
notifyWorktreeHeadIdentitiesChanged(host.mainWindow, repoId, changed)
}
} catch (error) {
console.warn(`[worktree-base-watcher] head identity read failed for ${host.path}:`, error)
} finally {
state.inFlight = false
if (state.queued && !host.disposed) {
const queuedEmit = state.queuedEmit
state.queued = false
state.queuedEmit = false
void refreshWorktreeHeadIdentities(host, state, queuedEmit)
}
}
}
+25
View File
@@ -22,6 +22,7 @@ import type {
LocalBaseRefUpdateSuggestion,
Repo,
Worktree,
WorktreeHeadIdentity,
WorktreeMeta
} from '../../shared/types'
import { getPRForBranch } from '../github/client'
@@ -1516,6 +1517,30 @@ export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string
}
}
export function notifyWorktreeGitStatusMetadataChanged(
mainWindow: BrowserWindow,
repoId: string
): void {
// Why: index churn is a Source Control freshness hint, not a worktree graph
// mutation; keep structural caches and runtime/mobile events untouched.
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:gitStatusMetadataChanged', { repoId })
}
}
export function notifyWorktreeHeadIdentitiesChanged(
mainWindow: BrowserWindow,
repoId: string,
identities: WorktreeHeadIdentity[]
): void {
// Why: background worktrees have no active-scoped status refresh, so head
// moves detected from metadata files ride this targeted desktop event
// instead of re-entering the structural fanout or runtime/mobile events.
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:headIdentitiesChanged', { repoId, identities })
}
}
// Why: two-phase spinner. Main process fires `'fetching'` before waiting on
// pre-create fetch work and `'creating'` immediately before `git worktree add`.
// Renderer swaps its spinner label in response; fallback is the static
+5
View File
@@ -511,6 +511,11 @@ describe('registerWorktreeHandlers', () => {
expect(handlers['worktrees:resolveMrBase']).toBeDefined()
})
it('clears the branch rename failure-output handler before re-registering IPC handlers', () => {
expect(removeHandlerMock).toHaveBeenCalledWith('worktrees:getBranchRenameFailureOutput')
expect(handlers['worktrees:getBranchRenameFailureOutput']).toBeDefined()
})
it('prefetches the local default create base through the runtime refresh cache', async () => {
const repo = {
id: 'repo-1',
+14
View File
@@ -5,6 +5,7 @@ import { readFile, stat } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import type { Store } from '../persistence'
import { isFolderRepo } from '../../shared/repo-kind'
import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output'
import {
isWorkspaceKey,
parseWorkspaceKey,
@@ -986,6 +987,7 @@ export function registerWorktreeHandlers(
ipcMain.removeHandler('worktrees:listLineage')
ipcMain.removeHandler('worktrees:updateLineage')
ipcMain.removeHandler('worktrees:persistSortOrder')
ipcMain.removeHandler('worktrees:getBranchRenameFailureOutput')
ipcMain.removeHandler('hooks:check')
ipcMain.removeHandler('hooks:inspectSetupScriptImports')
ipcMain.removeHandler('hooks:createIssueCommandRunner')
@@ -2098,6 +2100,18 @@ export function registerWorktreeHandlers(
}
})
// Why: the full generation-failure output is main-memory only (never in
// worktree metadata), so the rename-failed dialog pulls it on demand.
ipcMain.handle(
'worktrees:getBranchRenameFailureOutput',
(_event, args: { worktreeId: string }) => {
if (typeof args?.worktreeId !== 'string' || args.worktreeId.length === 0) {
return null
}
return readBranchRenameFailureOutputForDisplay(args.worktreeId)
}
)
ipcMain.handle(
'hooks:check',
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
+21
View File
@@ -4477,6 +4477,27 @@ describe('Store', () => {
expect(updated.branchPrefix).toBe('git-username')
})
it('normalizes bot-author overrides on load and every settings write', async () => {
writeDataFile({
settings: {
prBotAuthorOverrides: [' GretelFlux ', 'gretelflux', 42, '', 'another-bot']
}
})
const store = await createStore()
expect(store.getSettings().prBotAuthorOverrides).toEqual(['another-bot', 'gretelflux'])
const oversized = Array.from(
{ length: 600 },
(_, index) => ` bot-${String(index).padStart(4, '0')} `
)
const updated = store.updateSettings({ prBotAuthorOverrides: oversized })
expect(updated.prBotAuthorOverrides).toHaveLength(500)
expect(updated.prBotAuthorOverrides[0]).toBe('bot-0000')
expect(updated.prBotAuthorOverrides[499]).toBe('bot-0499')
})
it('notifies settings listeners with changed keys only', async () => {
const store = await createStore()
const listener = vi.fn()
+11
View File
@@ -112,6 +112,7 @@ import { parseWorkspaceSession } from '../shared/workspace-session-schema'
import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display'
import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice'
import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice'
import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostOrder,
@@ -3136,6 +3137,9 @@ export class Store {
// product intent was only to change the default, and the setting
// stays user-toggleable.
...stripLegacyTerminalScrollbackBytes(parsed.settings),
prBotAuthorOverrides: normalizePRBotAuthorOverrides(
parsed.settings?.prBotAuthorOverrides
),
// Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry
// the old persisted flag forward once so enabled users don't lose it.
experimentalPet:
@@ -5332,6 +5336,13 @@ export class Store {
if ('uiLanguage' in updates) {
sanitizedUpdates.uiLanguage = normalizeUiLanguage(updates.uiLanguage)
}
if ('prBotAuthorOverrides' in updates) {
// Why: every writer (desktop IPC, paired web RPC, and migrations) reaches
// this boundary, so the persisted list stays bounded and well-formed.
sanitizedUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides(
updates.prBotAuthorOverrides
)
}
const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate(
this.state.settings,
sanitizedUpdates

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