mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/skill-auto-update-research
# Conflicts: # config/reliability-gates.jsonc
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+714
-569
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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'),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
])
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user