mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/remote-runtime-shared-control
This commit is contained in:
@@ -9,6 +9,12 @@ on:
|
||||
- 'config/scripts/build-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.test.mjs'
|
||||
- 'config/scripts/computer-e2e-workflow.test.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-processes.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs'
|
||||
- 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
- 'config/scripts/computer-use-modifier-safety.test.mjs'
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
@@ -59,6 +65,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
@@ -85,6 +93,8 @@ jobs:
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
|
||||
config/scripts/computer-use-modifier-safety.test.mjs
|
||||
config/scripts/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
@@ -140,6 +150,32 @@ jobs:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts
|
||||
|
||||
mac-native-owner-smoke:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Owner-loss benchmark process cleanup
|
||||
run: >-
|
||||
pnpm vitest run
|
||||
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
|
||||
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
|
||||
- name: Authenticated helper owner-loss smoke
|
||||
run: pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1
|
||||
- name: Swift tests and signed universal helper verification
|
||||
run: pnpm verify:computer-native
|
||||
|
||||
mac:
|
||||
# macOS Accessibility and Screen Recording require user-granted TCC entries.
|
||||
# Keep this on manual/scheduled permission-bearing runners instead of PR CI.
|
||||
|
||||
@@ -273,6 +273,9 @@ jobs:
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
|
||||
|
||||
- name: Smoke packaged hang watchdog worker
|
||||
run: xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked
|
||||
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
runs-on: windows-2022
|
||||
|
||||
@@ -21,7 +21,6 @@ type OutputChunk = Rollup.OutputChunk
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'computer-sidecar',
|
||||
'agent-hooks/managed-agent-hook-controls',
|
||||
'codex/codex-app-server-grant-entry'
|
||||
|
||||
@@ -154,7 +154,6 @@ module.exports = {
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/main-thread-hang-watchdog-entry.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.mjs',
|
||||
@@ -70,9 +71,14 @@ describe('computer-use e2e workflow', () => {
|
||||
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const checkout = workflow.jobs['native-smoke'].steps.find(
|
||||
(step) => step.uses === 'actions/checkout@v6'
|
||||
)
|
||||
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
|
||||
const expectedRegressionFiles = [
|
||||
'config/scripts/computer-e2e-workflow.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
|
||||
'config/scripts/computer-use-modifier-safety.test.mjs',
|
||||
'config/scripts/computer-use-skill-guidance.test.mjs',
|
||||
'config/scripts/computer-use-smoke.test.mjs',
|
||||
@@ -106,12 +112,68 @@ describe('computer-use e2e workflow', () => {
|
||||
'src/shared/remote-runtime-client.test.ts'
|
||||
]
|
||||
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(regressionRun).toBeTruthy()
|
||||
for (const file of expectedRegressionFiles) {
|
||||
expect(regressionRun).toContain(file)
|
||||
}
|
||||
})
|
||||
|
||||
it('builds and tests the macOS helper on pull requests without TCC e2e', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const job = workflow.jobs['mac-native-owner-smoke']
|
||||
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
|
||||
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
|
||||
|
||||
expect(job.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(job['runs-on']).toBe('macos-15')
|
||||
expect(checkout.with['persist-credentials']).toBe(false)
|
||||
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
|
||||
const cleanupRun = runs.find((run) =>
|
||||
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
|
||||
)
|
||||
expect(cleanupRun).toContain(
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
|
||||
)
|
||||
expect(runs).toContain('pnpm verify:computer-native')
|
||||
expect(runs.join('\n')).not.toContain('test:e2e:computer')
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-metrics.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
|
||||
'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => {
|
||||
const benchmark = readFileSync(
|
||||
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
const cleanup = readFileSync(
|
||||
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(benchmark).toContain('spawnBenchmarkProcess(executable, [launcherDir]')
|
||||
expect(benchmark).toContain("stdio: ['ignore', stdoutDescriptor, stderrDescriptor]")
|
||||
expect(benchmark).toContain('cleanupOwnerLossTrial({')
|
||||
const parseIndex = benchmark.indexOf('parseBenchmarkTrialResult(serializedResult)')
|
||||
const cleanupIndex = benchmark.indexOf('cleanupOwnerLossTrial({')
|
||||
expect(parseIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(cleanupIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(parseIndex).toBeLessThan(cleanupIndex)
|
||||
expect(benchmark).toContain('trialCleanupSha256: artifactSha256(trialCleanupPath)')
|
||||
expect(cleanup).toContain('killRecordedAndMatchingProcesses(options.recordPath')
|
||||
expect(cleanup).toContain("signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL'")
|
||||
})
|
||||
|
||||
it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
|
||||
@@ -171,11 +171,9 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
|
||||
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
|
||||
it('unpacks the forked main-thread hang-watchdog entry', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
|
||||
it('keeps the worker-thread hang watchdog inside app.asar', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).not.toContain(
|
||||
'out/main/main-thread-hang-watchdog-entry.js'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, fork, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import {
|
||||
childRssBytes,
|
||||
median,
|
||||
physicalFootprintBytes,
|
||||
sampleMemory,
|
||||
sampleProductionPerformance
|
||||
} from './hang-watchdog-process-metrics.mjs'
|
||||
|
||||
const INTERNAL_ENV = 'ORCA_HANG_WATCHDOG_BENCH_INTERNAL'
|
||||
const BOUNDARY_ENV = 'ORCA_HANG_WATCHDOG_BENCH_BOUNDARY'
|
||||
const RESULT_PREFIX = 'ORCA_HANG_WATCHDOG_BENCH_RESULT='
|
||||
const DEFAULT_TRIALS = 7
|
||||
const SETTLE_MS = 2_000
|
||||
const SAMPLE_COUNT = 5
|
||||
const SAMPLE_INTERVAL_MS = 200
|
||||
const VERIFY_TIMEOUT_MS = 500
|
||||
const VERIFY_CHECK_INTERVAL_MS = 50
|
||||
const VERIFY_BLOCK_MS = 1_200
|
||||
const PRODUCTION_HEARTBEAT_INTERVAL_MS = 2_000
|
||||
const PRODUCTION_TIMEOUT_MS = 45_000
|
||||
const PRODUCTION_CHECK_INTERVAL_MS = 5_000
|
||||
const PRODUCTION_SAMPLE_MS = 30_000
|
||||
const MAX_LAUNCH_ATTEMPTS = 3
|
||||
const MIB = 1024 * 1024
|
||||
const scriptPath = import.meta.filename
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
|
||||
const entryPath = path.join(repoRoot, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function forceGc() {
|
||||
if (typeof global.gc !== 'function') {
|
||||
throw new Error('Electron did not expose GC; keep --js-flags=--expose-gc in the harness')
|
||||
}
|
||||
global.gc()
|
||||
global.gc()
|
||||
}
|
||||
|
||||
function blockMainThread(ms) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < ms) {
|
||||
// Intentional synchronous stall.
|
||||
}
|
||||
return { startedAt, endedAt: Date.now() }
|
||||
}
|
||||
|
||||
function readMarker(markerPath) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(markerPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBlockedMainDetection(markerPath, sendHeartbeat) {
|
||||
const block = blockMainThread(VERIFY_BLOCK_MS)
|
||||
const detected = readMarker(markerPath)
|
||||
sendHeartbeat()
|
||||
const deadline = Date.now() + VERIFY_TIMEOUT_MS
|
||||
let resolved
|
||||
do {
|
||||
resolved = readMarker(markerPath)
|
||||
if (resolved?.selfRecovered === true) {
|
||||
break
|
||||
}
|
||||
await sleep(VERIFY_CHECK_INTERVAL_MS)
|
||||
} while (Date.now() < deadline)
|
||||
const verified =
|
||||
detected?.detectedAt >= block.startedAt &&
|
||||
detected.detectedAt <= block.endedAt &&
|
||||
detected.selfRecovered === false &&
|
||||
resolved?.selfRecovered === true
|
||||
if (!verified) {
|
||||
throw new Error(
|
||||
`Built watchdog failed blocked-main verification: ${JSON.stringify({ detected, resolved })}`
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function startChild(markerPath, timeoutMs, checkIntervalMs) {
|
||||
const startedAt = process.hrtime.bigint()
|
||||
const child = fork(entryPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid),
|
||||
ORCA_HANG_WATCHDOG_MARKER_PATH: markerPath,
|
||||
ORCA_HANG_WATCHDOG_TIMEOUT_MS: String(timeoutMs),
|
||||
ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS: String(checkIntervalMs)
|
||||
}
|
||||
})
|
||||
const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6
|
||||
return {
|
||||
pids: [process.pid, child.pid],
|
||||
startupMs,
|
||||
sendHeartbeat: () => child.send?.({ type: 'heartbeat' }),
|
||||
shutdown: async () => {
|
||||
if (child.exitCode !== null) {
|
||||
return
|
||||
}
|
||||
const exitPromise = new Promise((resolve) => child.once('exit', resolve))
|
||||
child.send?.({ type: 'shutdown' })
|
||||
if (child.connected) {
|
||||
child.disconnect()
|
||||
}
|
||||
await exitPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startWorker(markerPath, timeoutMs, checkIntervalMs) {
|
||||
const startedAt = process.hrtime.bigint()
|
||||
const worker = new Worker(entryPath, {
|
||||
workerData: {
|
||||
parentPid: process.pid,
|
||||
markerPath,
|
||||
timeoutMs,
|
||||
checkIntervalMs
|
||||
}
|
||||
})
|
||||
const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6
|
||||
return {
|
||||
pids: [process.pid],
|
||||
startupMs,
|
||||
sendHeartbeat: () => worker.postMessage({ type: 'heartbeat' }),
|
||||
shutdown: async () => {
|
||||
if (worker.threadId === -1) {
|
||||
return
|
||||
}
|
||||
const exitPromise = new Promise((resolve) => worker.once('exit', resolve))
|
||||
worker.postMessage({ type: 'shutdown' })
|
||||
await exitPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBoundary(markerPath, startBoundary) {
|
||||
const boundary = startBoundary(markerPath, VERIFY_TIMEOUT_MS, VERIFY_CHECK_INTERVAL_MS)
|
||||
const heartbeat = setInterval(boundary.sendHeartbeat, 100)
|
||||
try {
|
||||
await sleep(SETTLE_MS)
|
||||
return await verifyBlockedMainDetection(markerPath, boundary.sendHeartbeat)
|
||||
} finally {
|
||||
clearInterval(heartbeat)
|
||||
await boundary.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
async function measureChild(markerPath) {
|
||||
forceGc()
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const before = await sampleMemory(
|
||||
() => process.memoryUsage().rss,
|
||||
() => physicalFootprintBytes([process.pid]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const child = startChild(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS)
|
||||
let measurements
|
||||
try {
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const childRss = await sampleMemory(
|
||||
() => childRssBytes(child.pids[1]),
|
||||
() => physicalFootprintBytes([child.pids[1]]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const total = await sampleMemory(
|
||||
() => process.memoryUsage().rss + childRssBytes(child.pids[1]),
|
||||
() => physicalFootprintBytes(child.pids),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const performance = await sampleProductionPerformance(child, {
|
||||
heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
|
||||
sampleMs: PRODUCTION_SAMPLE_MS,
|
||||
sleep
|
||||
})
|
||||
measurements = {
|
||||
rssBytes: childRss.rssBytes,
|
||||
summedProcessRssDeltaBytes: Math.max(0, total.rssBytes - before.rssBytes),
|
||||
physicalFootprintDeltaBytes: Math.max(
|
||||
0,
|
||||
total.physicalFootprintBytes - before.physicalFootprintBytes
|
||||
),
|
||||
startupMs: child.startupMs,
|
||||
...performance
|
||||
}
|
||||
} finally {
|
||||
await child.shutdown()
|
||||
}
|
||||
rmSync(markerPath, { force: true })
|
||||
return {
|
||||
...measurements,
|
||||
blockedMainThreadVerified: await verifyBoundary(markerPath, startChild)
|
||||
}
|
||||
}
|
||||
|
||||
async function measureWorker(markerPath) {
|
||||
forceGc()
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const before = await sampleMemory(
|
||||
() => process.memoryUsage().rss,
|
||||
() => physicalFootprintBytes([process.pid]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const worker = startWorker(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS)
|
||||
let measurements
|
||||
try {
|
||||
await sleep(SETTLE_MS)
|
||||
forceGc()
|
||||
const after = await sampleMemory(
|
||||
() => process.memoryUsage().rss,
|
||||
() => physicalFootprintBytes([process.pid]),
|
||||
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
|
||||
)
|
||||
const performance = await sampleProductionPerformance(worker, {
|
||||
heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
|
||||
sampleMs: PRODUCTION_SAMPLE_MS,
|
||||
sleep
|
||||
})
|
||||
const rssBytes = Math.max(0, after.rssBytes - before.rssBytes)
|
||||
measurements = {
|
||||
rssBytes,
|
||||
summedProcessRssDeltaBytes: rssBytes,
|
||||
physicalFootprintDeltaBytes: Math.max(
|
||||
0,
|
||||
after.physicalFootprintBytes - before.physicalFootprintBytes
|
||||
),
|
||||
startupMs: worker.startupMs,
|
||||
...performance
|
||||
}
|
||||
} finally {
|
||||
await worker.shutdown()
|
||||
}
|
||||
rmSync(markerPath, { force: true })
|
||||
return {
|
||||
...measurements,
|
||||
blockedMainThreadVerified: await verifyBoundary(markerPath, startWorker)
|
||||
}
|
||||
}
|
||||
|
||||
async function runInternal() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('The production watchdog is macOS-only; run this benchmark on macOS')
|
||||
}
|
||||
const { app } = await import('electron')
|
||||
const boundary = process.env[BOUNDARY_ENV]
|
||||
const profileDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-'))
|
||||
app.setPath('userData', profileDir)
|
||||
try {
|
||||
await app.whenReady()
|
||||
const markerPath = path.join(profileDir, 'main-thread-hang.json')
|
||||
const result =
|
||||
boundary === 'child'
|
||||
? await measureChild(markerPath)
|
||||
: boundary === 'worker'
|
||||
? await measureWorker(markerPath)
|
||||
: (() => {
|
||||
throw new Error(`Unsupported boundary: ${boundary}`)
|
||||
})()
|
||||
process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(result)}\n`)
|
||||
} finally {
|
||||
app.quit()
|
||||
rmSync(profileDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { boundary: '', trials: DEFAULT_TRIALS, output: '' }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (arg === '--boundary' || arg === '--trials' || arg === '--output') {
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${arg}`)
|
||||
}
|
||||
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
|
||||
index += 1
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
if (!['child', 'worker'].includes(options.boundary)) {
|
||||
throw new Error('--boundary must be child or worker')
|
||||
}
|
||||
if (!Number.isInteger(options.trials) || options.trials < 1) {
|
||||
throw new Error('--trials must be a positive integer')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function electronPath() {
|
||||
const requirePath = import.meta.resolve('electron')
|
||||
const electronModulePath = fileURLToPath(requirePath)
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
|
||||
{
|
||||
encoding: 'utf8'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function runTrial(executable, boundary) {
|
||||
for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt += 1) {
|
||||
const env = { ...process.env, [INTERNAL_ENV]: '1', [BOUNDARY_ENV]: boundary }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-launcher-'))
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'package.json'),
|
||||
JSON.stringify({ name: 'orca-watchdog-benchmark', main: 'main.cjs' })
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'main.cjs'),
|
||||
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})\n`
|
||||
)
|
||||
let result
|
||||
try {
|
||||
result = spawnSync(executable, ['--js-flags=--expose-gc', launcherDir], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 90_000
|
||||
})
|
||||
} finally {
|
||||
rmSync(launcherDir, { recursive: true, force: true })
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n` +
|
||||
`${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
const line = result.stdout.split('\n').find((candidate) => candidate.startsWith(RESULT_PREFIX))
|
||||
if (line) {
|
||||
return { ...JSON.parse(line.slice(RESULT_PREFIX.length)), launchAttempts: attempt }
|
||||
}
|
||||
if (attempt === MAX_LAUNCH_ATTEMPTS || result.stderr || result.stdout) {
|
||||
throw new Error(`Electron trial did not report a result (status ${result.status})`)
|
||||
}
|
||||
}
|
||||
throw new Error('Electron trial exhausted launcher attempts')
|
||||
}
|
||||
|
||||
function runBenchmark() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('The production watchdog is macOS-only; run this benchmark on macOS')
|
||||
}
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`Missing ${entryPath}; run pnpm exec electron-vite build first`)
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const builtEntry = readFileSync(entryPath, 'utf8')
|
||||
const hasChildContract = builtEntry.includes('ORCA_HANG_WATCHDOG_PARENT_PID')
|
||||
const hasWorkerContract = builtEntry.includes('workerData') && builtEntry.includes('parentPort')
|
||||
if (
|
||||
(options.boundary === 'child' && !hasChildContract) ||
|
||||
(options.boundary === 'worker' && !hasWorkerContract)
|
||||
) {
|
||||
throw new Error(
|
||||
`Built watchdog does not implement the requested ${options.boundary} boundary; rebuild the matching revision`
|
||||
)
|
||||
}
|
||||
const executable = electronPath()
|
||||
const results = Array.from({ length: options.trials }, () =>
|
||||
runTrial(executable, options.boundary)
|
||||
)
|
||||
const rssBytes = results.map((result) => result.rssBytes)
|
||||
const summedProcessRssDeltaBytes = results.map((result) => result.summedProcessRssDeltaBytes)
|
||||
const physicalFootprintDeltaBytes = results.map((result) => result.physicalFootprintDeltaBytes)
|
||||
const startupMs = results.map((result) => result.startupMs)
|
||||
const cpuMs = results.map((result) => result.cpuMs)
|
||||
const eventLoopDelayP95Ms = results.map((result) => result.eventLoopDelayP95Ms)
|
||||
const eventLoopDelayP99Ms = results.map((result) => result.eventLoopDelayP99Ms)
|
||||
const eventLoopDelayMaxMs = results.map((result) => result.eventLoopDelayMaxMs)
|
||||
const report = {
|
||||
benchmark: 'hang-watchdog-memory',
|
||||
boundary: options.boundary,
|
||||
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim(),
|
||||
electron: execFileSync(executable, ['-e', 'process.stdout.write(process.versions.electron)'], {
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
encoding: 'utf8'
|
||||
}).trim(),
|
||||
settleMs: SETTLE_MS,
|
||||
samplesPerTrial: SAMPLE_COUNT,
|
||||
productionHeartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
|
||||
productionCheckIntervalMs: PRODUCTION_CHECK_INTERVAL_MS,
|
||||
productionSampleMs: PRODUCTION_SAMPLE_MS,
|
||||
trials: options.trials,
|
||||
rssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
|
||||
medianRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
|
||||
summedProcessRssDeltaMiB: summedProcessRssDeltaBytes.map((value) =>
|
||||
Number((value / MIB).toFixed(2))
|
||||
),
|
||||
medianSummedProcessRssDeltaMiB: Number((median(summedProcessRssDeltaBytes) / MIB).toFixed(2)),
|
||||
physicalFootprintDeltaMiB: physicalFootprintDeltaBytes.map((value) =>
|
||||
Number((value / MIB).toFixed(2))
|
||||
),
|
||||
medianPhysicalFootprintDeltaMiB: Number((median(physicalFootprintDeltaBytes) / MIB).toFixed(2)),
|
||||
startupMs: startupMs.map((value) => Number(value.toFixed(3))),
|
||||
medianStartupMs: Number(median(startupMs).toFixed(3)),
|
||||
cpuMs: cpuMs.map((value) => Number(value.toFixed(2))),
|
||||
medianCpuMs: Number(median(cpuMs).toFixed(2)),
|
||||
eventLoopDelayP95Ms: eventLoopDelayP95Ms.map((value) => Number(value.toFixed(3))),
|
||||
medianEventLoopDelayP95Ms: Number(median(eventLoopDelayP95Ms).toFixed(3)),
|
||||
eventLoopDelayP99Ms: eventLoopDelayP99Ms.map((value) => Number(value.toFixed(3))),
|
||||
medianEventLoopDelayP99Ms: Number(median(eventLoopDelayP99Ms).toFixed(3)),
|
||||
eventLoopDelayMaxMs: eventLoopDelayMaxMs.map((value) => Number(value.toFixed(3))),
|
||||
medianEventLoopDelayMaxMs: Number(median(eventLoopDelayMaxMs).toFixed(3)),
|
||||
heartbeatCounts: results.map((result) => result.heartbeatCount),
|
||||
launchAttempts: results.map((result) => result.launchAttempts),
|
||||
blockedMainThreadVerified: results.every((result) => result.blockedMainThreadVerified)
|
||||
}
|
||||
const serialized = `${JSON.stringify(report, null, 2)}\n`
|
||||
process.stdout.write(serialized)
|
||||
if (options.output) {
|
||||
writeFileSync(path.resolve(options.output), serialized)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env[INTERNAL_ENV] === '1') {
|
||||
await runInternal()
|
||||
} else {
|
||||
runBenchmark()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { monitorEventLoopDelay } from 'node:perf_hooks'
|
||||
|
||||
export function median(values) {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
|
||||
export async function sampleMemory(readRss, readPhysicalFootprint, options) {
|
||||
const rssSamples = []
|
||||
const physicalFootprintSamples = []
|
||||
for (let index = 0; index < options.sampleCount; index += 1) {
|
||||
rssSamples.push(readRss())
|
||||
physicalFootprintSamples.push(readPhysicalFootprint())
|
||||
await options.sleep(options.sampleIntervalMs)
|
||||
}
|
||||
return {
|
||||
rssBytes: median(rssSamples),
|
||||
physicalFootprintBytes: median(physicalFootprintSamples)
|
||||
}
|
||||
}
|
||||
|
||||
export function childRssBytes(pid) {
|
||||
const raw = execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const rssKiB = Number(raw)
|
||||
if (!Number.isFinite(rssKiB) || rssKiB <= 0) {
|
||||
throw new Error(`Could not read watchdog child RSS for PID ${pid}`)
|
||||
}
|
||||
return rssKiB * 1024
|
||||
}
|
||||
|
||||
export function parsePhysicalFootprintBytes(output, processCount) {
|
||||
const match =
|
||||
processCount > 1
|
||||
? output.match(/^Summary Footprint:\s+(\d+) B$/m)
|
||||
: output.match(/^[^\s].*\sFootprint:\s+(\d+) B/m)
|
||||
const bytes = Number(match?.[1])
|
||||
return Number.isFinite(bytes) && bytes > 0 ? bytes : null
|
||||
}
|
||||
|
||||
export function physicalFootprintBytes(pids) {
|
||||
const pidArgs = pids.flatMap((pid) => ['--pid', String(pid)])
|
||||
const output = execFileSync(
|
||||
'/usr/bin/footprint',
|
||||
[...pidArgs, '--format', 'bytes', '--noCategories'],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
const bytes = parsePhysicalFootprintBytes(output, pids.length)
|
||||
if (bytes === null) {
|
||||
throw new Error(`Could not read physical footprint for PIDs ${pids.join(', ')}`)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
export function parseProcessCpuTimeMs(raw) {
|
||||
if (!raw.trim()) {
|
||||
return null
|
||||
}
|
||||
const parts = raw.split(':').map(Number)
|
||||
if (!parts.length || parts.some((part) => !Number.isFinite(part))) {
|
||||
return null
|
||||
}
|
||||
const seconds = parts.reduce((total, part) => total * 60 + part, 0)
|
||||
const milliseconds = seconds * 1_000
|
||||
return milliseconds >= 0 ? milliseconds : null
|
||||
}
|
||||
|
||||
function processCpuTimeMs(pid) {
|
||||
const raw = execFileSync('ps', ['-o', 'time=', '-p', String(pid)], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const milliseconds = parseProcessCpuTimeMs(raw)
|
||||
if (milliseconds === null) {
|
||||
throw new Error(`Could not read CPU time for PID ${pid}`)
|
||||
}
|
||||
return milliseconds
|
||||
}
|
||||
|
||||
function combinedCpuTimeMs(pids) {
|
||||
return pids.reduce((total, pid) => total + processCpuTimeMs(pid), 0)
|
||||
}
|
||||
|
||||
export async function sampleProductionPerformance(boundary, options) {
|
||||
const loopDelay = monitorEventLoopDelay({ resolution: 10 })
|
||||
let heartbeatCount = 0
|
||||
const heartbeat = setInterval(() => {
|
||||
heartbeatCount += 1
|
||||
boundary.sendHeartbeat()
|
||||
}, options.heartbeatIntervalMs)
|
||||
const cpuBefore = combinedCpuTimeMs(boundary.pids)
|
||||
loopDelay.enable()
|
||||
try {
|
||||
await options.sleep(options.sampleMs)
|
||||
} finally {
|
||||
loopDelay.disable()
|
||||
clearInterval(heartbeat)
|
||||
}
|
||||
return {
|
||||
cpuMs: Math.max(0, combinedCpuTimeMs(boundary.pids) - cpuBefore),
|
||||
heartbeatCount,
|
||||
eventLoopDelayP95Ms: loopDelay.percentile(95) / 1e6,
|
||||
eventLoopDelayP99Ms: loopDelay.percentile(99) / 1e6,
|
||||
eventLoopDelayMaxMs: loopDelay.max / 1e6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parsePhysicalFootprintBytes,
|
||||
parseProcessCpuTimeMs
|
||||
} from './hang-watchdog-process-metrics.mjs'
|
||||
|
||||
describe('hang watchdog process metrics', () => {
|
||||
it('uses the de-duplicated summary for multiple processes', () => {
|
||||
const output = `
|
||||
Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page)
|
||||
phys_footprint: 5100000 B
|
||||
Electron Helper [102]: 64-bit Footprint: 2000000 B (16384 bytes per page)
|
||||
phys_footprint: 2100000 B
|
||||
Summary Footprint: 6259264 B
|
||||
`
|
||||
expect(parsePhysicalFootprintBytes(output, 2)).toBe(6_259_264)
|
||||
})
|
||||
|
||||
it('uses the process footprint rather than auxiliary accounting for one process', () => {
|
||||
const output = `
|
||||
Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page)
|
||||
phys_footprint: 5100000 B
|
||||
`
|
||||
expect(parsePhysicalFootprintBytes(output, 1)).toBe(5_000_000)
|
||||
})
|
||||
|
||||
it('rejects missing or zero footprint summaries', () => {
|
||||
expect(parsePhysicalFootprintBytes('phys_footprint: 100 B', 2)).toBeNull()
|
||||
expect(parsePhysicalFootprintBytes('Summary Footprint: 0 B', 2)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['0:00.04', 40],
|
||||
['1:02.50', 62_500],
|
||||
['2:01:02.50', 7_262_500]
|
||||
])('parses ps CPU time %s', (value, expected) => {
|
||||
expect(parseProcessCpuTimeMs(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it('rejects invalid CPU times', () => {
|
||||
expect(parseProcessCpuTimeMs('')).toBeNull()
|
||||
expect(parseProcessCpuTimeMs('not-a-time')).toBeNull()
|
||||
expect(parseProcessCpuTimeMs('-1:00')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,614 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync, fork } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, openSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import {
|
||||
median,
|
||||
percentile,
|
||||
processSnapshot,
|
||||
sampleProcess
|
||||
} from './macos-computer-helper-owner-loss-metrics.mjs'
|
||||
import {
|
||||
benchmarkTrialNeedsCleanup,
|
||||
parseBenchmarkTrialResult,
|
||||
processIdentityIsCurrent,
|
||||
signalProcessIdentity,
|
||||
spawnBenchmarkProcess,
|
||||
throwBenchmarkTrialFailures,
|
||||
writeProcessRecord
|
||||
} from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
|
||||
const INTERNAL_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_INTERNAL'
|
||||
const EXPECTATION_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_EXPECTATION'
|
||||
const HELPER_RECORD_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_HELPER_RECORD_PATH'
|
||||
const RESULT_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_RESULT_PATH'
|
||||
const ACTIVE_REQUEST_COUNT = 100_000
|
||||
const DEFAULT_TRIALS = 3
|
||||
const OWNER_HOLD_MS = 31_000
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 5_000
|
||||
const RETAIN_PROOF_MS = 3_000
|
||||
const TRIAL_TIMEOUT_MS = OWNER_HOLD_MS + 4 * PROCESS_EXIT_TIMEOUT_MS + 120_000
|
||||
const MIB = 1024 * 1024
|
||||
const scriptPath = import.meta.filename
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
|
||||
const metricsPath = path.join(import.meta.dirname, 'macos-computer-helper-owner-loss-metrics.mjs')
|
||||
const processCleanupPath = path.join(
|
||||
import.meta.dirname,
|
||||
'macos-computer-helper-owner-loss-processes.mjs'
|
||||
)
|
||||
const trialCleanupPath = path.join(
|
||||
import.meta.dirname,
|
||||
'macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
)
|
||||
const sidecarPath = path.join(repoRoot, 'out', 'main', 'computer-sidecar.js')
|
||||
const helperAppPath = path.join(
|
||||
repoRoot,
|
||||
'native',
|
||||
'computer-use-macos',
|
||||
'.build',
|
||||
'release',
|
||||
'Orca Computer Use.app'
|
||||
)
|
||||
const helperPath = path.join(helperAppPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(identity, timeoutMs) {
|
||||
const startedAt = performance.now()
|
||||
while (performance.now() - startedAt < timeoutMs) {
|
||||
if (!processIdentityIsCurrent(identity)) {
|
||||
return performance.now() - startedAt
|
||||
}
|
||||
await sleep(50)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function stopProcess(identity) {
|
||||
if (!identity || !processIdentityIsCurrent(identity)) {
|
||||
return
|
||||
}
|
||||
signalProcessIdentity(identity, helperPath, 'SIGTERM')
|
||||
if ((await waitForProcessExit(identity, 2_000)) !== null) {
|
||||
return
|
||||
}
|
||||
signalProcessIdentity(identity, helperPath, 'SIGKILL')
|
||||
await waitForProcessExit(identity, 2_000)
|
||||
}
|
||||
|
||||
function startSidecar() {
|
||||
const errors = []
|
||||
const child = fork(sidecarPath, [], {
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ORCA_COMPUTER_SIDECAR: '1',
|
||||
ORCA_COMPUTER_MACOS_HELPER_APP_PATH: helperAppPath
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => errors.push(error.stack ?? error.message))
|
||||
child.stderr?.on('data', (chunk) => errors.push(String(chunk)))
|
||||
return { child, errors }
|
||||
}
|
||||
|
||||
function requestSidecar(sidecar, id, method) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Sidecar ${method} request timed out: ${sidecar.errors.join('')}`))
|
||||
}, 10_000)
|
||||
const onMessage = (message) => {
|
||||
if (message?.id !== id) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
if (message.ok) {
|
||||
resolve(message.result)
|
||||
} else {
|
||||
reject(new Error(`Sidecar ${method} failed: ${JSON.stringify(message.error)}`))
|
||||
}
|
||||
}
|
||||
const onExit = (code, signal) => {
|
||||
cleanup()
|
||||
reject(
|
||||
new Error(
|
||||
`Sidecar exited during ${method}: ${JSON.stringify({ code, signal, stderr: sidecar.errors.join('') })}`
|
||||
)
|
||||
)
|
||||
}
|
||||
const onError = (error) => {
|
||||
cleanup()
|
||||
reject(new Error(`Sidecar ${method} process error: ${error.message}`))
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
sidecar.child.off('message', onMessage)
|
||||
sidecar.child.off('exit', onExit)
|
||||
sidecar.child.off('error', onError)
|
||||
}
|
||||
sidecar.child.on('message', onMessage)
|
||||
sidecar.child.once('exit', onExit)
|
||||
sidecar.child.once('error', onError)
|
||||
try {
|
||||
sidecar.child.send({ id, method, params: {} })
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHelper(sidecarPid) {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (Date.now() < deadline) {
|
||||
const output = execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,command='], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
for (const line of output.split('\n')) {
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)
|
||||
if (
|
||||
match &&
|
||||
Number(match[2]) === sidecarPid &&
|
||||
Number(match[3]) === Number(match[1]) &&
|
||||
match[4].includes(helperPath) &&
|
||||
match[4].includes(' --agent ')
|
||||
) {
|
||||
return { pid: Number(match[1]), pgid: Number(match[3]), command: match[4] }
|
||||
}
|
||||
}
|
||||
await sleep(50)
|
||||
}
|
||||
throw new Error(`Could not find helper owned by sidecar ${sidecarPid}`)
|
||||
}
|
||||
|
||||
function socketPathFromCommand(command) {
|
||||
const match = command.match(/ --agent (.+?) --token-file /)
|
||||
if (!match) {
|
||||
throw new Error(`Could not read helper socket path from command: ${command}`)
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function connectInvalidPeer(socketPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection(socketPath)
|
||||
let accepted = false
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy()
|
||||
reject(new Error('Invalid-peer connection timed out'))
|
||||
}, 5_000)
|
||||
let buffer = ''
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.off('error', onError)
|
||||
socket.off('data', onData)
|
||||
}
|
||||
const onError = (error) => {
|
||||
if (accepted) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk
|
||||
const newline = buffer.indexOf('\n')
|
||||
if (newline < 0) {
|
||||
return
|
||||
}
|
||||
let response
|
||||
try {
|
||||
response = JSON.parse(buffer.slice(0, newline))
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
if (response.ok !== false || response.error?.code !== 'permission_denied') {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
reject(new Error(`Invalid peer was not rejected: ${JSON.stringify(response)}`))
|
||||
return
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
socket.off('data', onData)
|
||||
accepted = true
|
||||
resolve(socket)
|
||||
}
|
||||
socket.setEncoding('utf8')
|
||||
socket.on('error', onError)
|
||||
socket.on('data', onData)
|
||||
socket.once('connect', () => {
|
||||
socket.write(
|
||||
`${JSON.stringify({ id: 991, method: 'handshake', params: {}, token: 'invalid' })}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForChildExit(child, timeoutMs) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return true
|
||||
}
|
||||
return await Promise.race([
|
||||
new Promise((resolve) => child.once('exit', () => resolve(true))),
|
||||
sleep(timeoutMs).then(() => false)
|
||||
])
|
||||
}
|
||||
|
||||
async function startAuthenticatedSession() {
|
||||
const sidecar = startSidecar()
|
||||
let helper
|
||||
try {
|
||||
const capabilitiesResult = requestSidecar(sidecar, 1, 'capabilities').then(
|
||||
(capabilities) => ({ capabilities }),
|
||||
(error) => ({ error })
|
||||
)
|
||||
helper = await waitForHelper(sidecar.child.pid)
|
||||
const helperRecordPath = process.env[HELPER_RECORD_PATH_ENV]
|
||||
if (!helperRecordPath) {
|
||||
throw new Error('Missing helper process record path')
|
||||
}
|
||||
writeProcessRecord(helperRecordPath, helper)
|
||||
const { capabilities, error } = await capabilitiesResult
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
if (capabilities?.protocolVersion !== 1) {
|
||||
throw new Error(`Unexpected helper handshake: ${JSON.stringify(capabilities)}`)
|
||||
}
|
||||
return { authenticated: capabilities.protocolVersion === 1, sidecar, helper }
|
||||
} catch (error) {
|
||||
sidecar.child.kill('SIGKILL')
|
||||
await stopProcess(helper)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function exerciseActiveRequests(sidecar) {
|
||||
const latencies = []
|
||||
const startedAt = performance.now()
|
||||
for (let index = 0; index < ACTIVE_REQUEST_COUNT; index += 1) {
|
||||
const requestStartedAt = performance.now()
|
||||
const result = await requestSidecar(sidecar, 10_000 + index, 'listApps')
|
||||
if (!Array.isArray(result?.apps)) {
|
||||
throw new Error(`Unexpected listApps response: ${JSON.stringify(result)}`)
|
||||
}
|
||||
latencies.push(performance.now() - requestStartedAt)
|
||||
}
|
||||
const totalMs = performance.now() - startedAt
|
||||
return {
|
||||
totalMs,
|
||||
requestsPerSecond: (ACTIVE_REQUEST_COUNT * 1_000) / totalMs,
|
||||
medianLatencyMs: median(latencies),
|
||||
p95LatencyMs: percentile(latencies, 0.95),
|
||||
maxLatencyMs: Math.max(...latencies)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyGracefulClose() {
|
||||
const { sidecar, helper } = await startAuthenticatedSession()
|
||||
try {
|
||||
const startedAt = performance.now()
|
||||
sidecar.child.disconnect()
|
||||
if (!(await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS))) {
|
||||
throw new Error('Sidecar did not exit after graceful IPC close')
|
||||
}
|
||||
const helperExitMs = await waitForProcessExit(helper, PROCESS_EXIT_TIMEOUT_MS)
|
||||
if (helperExitMs === null) {
|
||||
throw new Error('Helper did not exit after graceful owner close')
|
||||
}
|
||||
return Math.round(performance.now() - startedAt)
|
||||
} finally {
|
||||
sidecar.child.kill('SIGKILL')
|
||||
await stopProcess(helper)
|
||||
}
|
||||
}
|
||||
|
||||
async function runInternalTrial(expectation) {
|
||||
let sidecar
|
||||
let helper
|
||||
let invalidPeer
|
||||
let invalidPeerRejected = false
|
||||
try {
|
||||
const session = await startAuthenticatedSession()
|
||||
sidecar = session.sidecar
|
||||
helper = session.helper
|
||||
const authenticatedAt = performance.now()
|
||||
const initial = await sampleProcess(helper.pid)
|
||||
const activeRequests = await exerciseActiveRequests(sidecar)
|
||||
const remainingHoldMs = Math.max(0, OWNER_HOLD_MS - (performance.now() - authenticatedAt))
|
||||
await sleep(remainingHoldMs)
|
||||
const connected = await sampleProcess(helper.pid)
|
||||
const invalidSocketPath = socketPathFromCommand(helper.command)
|
||||
invalidPeer = await connectInvalidPeer(invalidSocketPath)
|
||||
invalidPeerRejected = true
|
||||
const survivedClaimDeadline =
|
||||
performance.now() - authenticatedAt >= OWNER_HOLD_MS && isProcessAlive(helper.pid)
|
||||
|
||||
sidecar.child.kill('SIGKILL')
|
||||
await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS)
|
||||
const abruptExitMs = await waitForProcessExit(
|
||||
helper,
|
||||
expectation === 'reaped' ? PROCESS_EXIT_TIMEOUT_MS : RETAIN_PROOF_MS
|
||||
)
|
||||
const helperExitedAfterAbruptLoss = abruptExitMs !== null
|
||||
if (expectation === 'reaped' && !helperExitedAfterAbruptLoss) {
|
||||
throw new Error('Expected helper to exit after abrupt authenticated owner loss')
|
||||
}
|
||||
if (expectation === 'retained' && helperExitedAfterAbruptLoss) {
|
||||
throw new Error('Expected baseline helper to remain after abrupt owner loss')
|
||||
}
|
||||
const postLossRssBytes = helperExitedAfterAbruptLoss ? 0 : processSnapshot(helper.pid).rssBytes
|
||||
await stopProcess(helper)
|
||||
helper = null
|
||||
invalidPeer.destroy()
|
||||
invalidPeer = null
|
||||
|
||||
const gracefulExitMs = await verifyGracefulClose()
|
||||
return {
|
||||
authenticated: session.authenticated,
|
||||
survivedClaimDeadline,
|
||||
invalidPeerRejectedAndDidNotRetain: invalidPeerRejected && helperExitedAfterAbruptLoss,
|
||||
connectedRssBytes: connected.rssBytes,
|
||||
connectedCpuMilliseconds: Math.max(
|
||||
0,
|
||||
Math.round((connected.cpuTimeSeconds - initial.cpuTimeSeconds) * 1_000)
|
||||
),
|
||||
activeRequests,
|
||||
cpuSampleMs: Math.round(performance.now() - authenticatedAt),
|
||||
helperExitedAfterAbruptLoss,
|
||||
abruptExitMs: abruptExitMs === null ? null : Math.round(abruptExitMs),
|
||||
postLossRssBytes,
|
||||
gracefulExitMs
|
||||
}
|
||||
} finally {
|
||||
invalidPeer?.destroy()
|
||||
sidecar?.child.kill('SIGKILL')
|
||||
await stopProcess(helper)
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { expect: '', trials: DEFAULT_TRIALS, output: '' }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (arg === '--expect' || arg === '--trials' || arg === '--output') {
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${arg}`)
|
||||
}
|
||||
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
|
||||
index += 1
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
if (!['retained', 'reaped'].includes(options.expect)) {
|
||||
throw new Error('--expect must be retained or reaped')
|
||||
}
|
||||
if (!Number.isInteger(options.trials) || options.trials < 1) {
|
||||
throw new Error('--trials must be a positive integer')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function electronPath() {
|
||||
const electronModulePath = fileURLToPath(import.meta.resolve('electron'))
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
}
|
||||
|
||||
function buildArtifacts() {
|
||||
execFileSync('pnpm', ['exec', 'electron-vite', 'build'], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
execFileSync('pnpm', ['build:computer-macos'], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
}
|
||||
|
||||
function artifactSha256(artifactPath) {
|
||||
return createHash('sha256').update(readFileSync(artifactPath)).digest('hex')
|
||||
}
|
||||
|
||||
function runTrial(executable, expectation) {
|
||||
let launcherDir
|
||||
let trialTempDir
|
||||
let helperRecordPath
|
||||
let resultPath
|
||||
let stderrPath
|
||||
let stdoutPath
|
||||
let result
|
||||
let serializedResult
|
||||
let parsedResult
|
||||
let parsedResultAvailable = false
|
||||
let trialError
|
||||
let cleanupError
|
||||
let stderrDescriptor
|
||||
let stdoutDescriptor
|
||||
let trialOutput = ''
|
||||
try {
|
||||
launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-helper-owner-bench-launcher-'))
|
||||
trialTempDir = mkdtempSync(path.join(path.sep, 'tmp', 'orca-owner-bench-'))
|
||||
helperRecordPath = path.join(launcherDir, 'helper.json')
|
||||
resultPath = path.join(launcherDir, 'result.json')
|
||||
stderrPath = path.join(launcherDir, 'stderr.log')
|
||||
stdoutPath = path.join(launcherDir, 'stdout.log')
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'package.json'),
|
||||
JSON.stringify({ name: 'orca-helper-owner-benchmark', main: 'main.cjs' })
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(launcherDir, 'main.cjs'),
|
||||
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})\n`
|
||||
)
|
||||
const env = {
|
||||
...process.env,
|
||||
TMPDIR: trialTempDir,
|
||||
[INTERNAL_ENV]: '1',
|
||||
[EXPECTATION_ENV]: expectation,
|
||||
[HELPER_RECORD_PATH_ENV]: helperRecordPath,
|
||||
[RESULT_PATH_ENV]: resultPath
|
||||
}
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
stderrDescriptor = openSync(stderrPath, 'w')
|
||||
stdoutDescriptor = openSync(stdoutPath, 'w')
|
||||
result = spawnBenchmarkProcess(executable, [launcherDir], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: ['ignore', stdoutDescriptor, stderrDescriptor],
|
||||
timeout: TRIAL_TIMEOUT_MS
|
||||
})
|
||||
if (result.status === 0 && existsSync(resultPath)) {
|
||||
serializedResult = readFileSync(resultPath, 'utf8')
|
||||
parsedResult = parseBenchmarkTrialResult(serializedResult)
|
||||
parsedResultAvailable = true
|
||||
}
|
||||
} catch (error) {
|
||||
trialError = error
|
||||
} finally {
|
||||
const failedTrial = benchmarkTrialNeedsCleanup(result, parsedResultAvailable)
|
||||
const trialMarker = trialTempDir ? `TMPDIR=${trialTempDir}` : undefined
|
||||
const cleanup = cleanupOwnerLossTrial({
|
||||
failed: failedTrial,
|
||||
pid: result?.pid,
|
||||
marker: trialMarker,
|
||||
recordPath: helperRecordPath,
|
||||
helperPath,
|
||||
tempDir: trialTempDir,
|
||||
stderrDescriptor,
|
||||
stdoutDescriptor,
|
||||
outputPaths: [stderrPath, stdoutPath],
|
||||
launcherDir
|
||||
})
|
||||
cleanupError = cleanup.error
|
||||
trialOutput = cleanup.output
|
||||
}
|
||||
if (!trialError && result?.status !== 0) {
|
||||
trialError = new Error(
|
||||
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n${trialOutput}`
|
||||
)
|
||||
}
|
||||
if (!trialError && !serializedResult) {
|
||||
trialError = new Error(`Electron trial did not write a result:\n${trialOutput}`)
|
||||
}
|
||||
throwBenchmarkTrialFailures(trialError, cleanupError)
|
||||
return parsedResult
|
||||
}
|
||||
|
||||
function runBenchmark() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('The computer-use helper owner benchmark is macOS-only')
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const dirty = execFileSync('git', ['status', '--porcelain'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
if (dirty) {
|
||||
throw new Error('Commit or stash changes before running the provenance-bound benchmark')
|
||||
}
|
||||
buildArtifacts()
|
||||
if (!existsSync(sidecarPath) || !existsSync(helperPath)) {
|
||||
throw new Error('Fresh production sidecar/helper build did not produce the expected artifacts')
|
||||
}
|
||||
const executable = electronPath()
|
||||
const results = Array.from({ length: options.trials }, () => runTrial(executable, options.expect))
|
||||
const rssBytes = results.map((result) => result.connectedRssBytes)
|
||||
const cpuMilliseconds = results.map((result) => result.connectedCpuMilliseconds)
|
||||
const activeRequestTotals = results.map((result) => result.activeRequests.totalMs)
|
||||
const activeRequestRates = results.map((result) => result.activeRequests.requestsPerSecond)
|
||||
const activeRequestMedians = results.map((result) => result.activeRequests.medianLatencyMs)
|
||||
const activeRequestP95s = results.map((result) => result.activeRequests.p95LatencyMs)
|
||||
const activeRequestMaxes = results.map((result) => result.activeRequests.maxLatencyMs)
|
||||
const postLossRssBytes = results.map((result) => result.postLossRssBytes)
|
||||
const report = {
|
||||
benchmark: 'macos-computer-helper-authenticated-owner-loss',
|
||||
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8'
|
||||
}).trim(),
|
||||
artifacts: {
|
||||
sidecarSha256: artifactSha256(sidecarPath),
|
||||
helperSha256: artifactSha256(helperPath)
|
||||
},
|
||||
sources: {
|
||||
benchmarkSha256: artifactSha256(scriptPath),
|
||||
metricsSha256: artifactSha256(metricsPath),
|
||||
processCleanupSha256: artifactSha256(processCleanupPath),
|
||||
trialCleanupSha256: artifactSha256(trialCleanupPath)
|
||||
},
|
||||
expectation: options.expect,
|
||||
trials: options.trials,
|
||||
ownerHoldMs: OWNER_HOLD_MS,
|
||||
activeRequestCount: ACTIVE_REQUEST_COUNT,
|
||||
authenticated: results.every((result) => result.authenticated),
|
||||
survivedClaimDeadline: results.every((result) => result.survivedClaimDeadline),
|
||||
invalidPeerRejectedAndDidNotRetain: results.every(
|
||||
(result) => result.invalidPeerRejectedAndDidNotRetain
|
||||
),
|
||||
connectedRssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
|
||||
medianConnectedRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
|
||||
connectedCpuMilliseconds: cpuMilliseconds,
|
||||
medianConnectedCpuMilliseconds: median(cpuMilliseconds),
|
||||
activeRequestTotalMs: activeRequestTotals.map((value) => Number(value.toFixed(3))),
|
||||
medianActiveRequestTotalMs: Number(median(activeRequestTotals).toFixed(3)),
|
||||
activeRequestsPerSecond: activeRequestRates.map((value) => Number(value.toFixed(2))),
|
||||
medianActiveRequestsPerSecond: Number(median(activeRequestRates).toFixed(2)),
|
||||
activeRequestMedianLatencyMs: activeRequestMedians.map((value) => Number(value.toFixed(3))),
|
||||
medianActiveRequestMedianLatencyMs: Number(median(activeRequestMedians).toFixed(3)),
|
||||
activeRequestP95LatencyMs: activeRequestP95s.map((value) => Number(value.toFixed(3))),
|
||||
medianActiveRequestP95LatencyMs: Number(median(activeRequestP95s).toFixed(3)),
|
||||
activeRequestMaxLatencyMs: activeRequestMaxes.map((value) => Number(value.toFixed(3))),
|
||||
helperExitedAfterAbruptLoss: results.map((result) => result.helperExitedAfterAbruptLoss),
|
||||
abruptExitMs: results.map((result) => result.abruptExitMs),
|
||||
postLossRssMiB: postLossRssBytes.map((value) => Number((value / MIB).toFixed(2))),
|
||||
medianPostLossRssMiB: Number((median(postLossRssBytes) / MIB).toFixed(2)),
|
||||
gracefulExitMs: results.map((result) => result.gracefulExitMs)
|
||||
}
|
||||
const serialized = `${JSON.stringify(report, null, 2)}\n`
|
||||
process.stdout.write(serialized)
|
||||
if (options.output) {
|
||||
writeFileSync(path.resolve(options.output), serialized)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env[INTERNAL_ENV] === '1') {
|
||||
const { app } = await import('electron')
|
||||
await app.whenReady()
|
||||
try {
|
||||
const result = await runInternalTrial(process.env[EXPECTATION_ENV])
|
||||
writeFileSync(process.env[RESULT_PATH_ENV], JSON.stringify(result))
|
||||
} finally {
|
||||
app.quit()
|
||||
}
|
||||
} else {
|
||||
runBenchmark()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { signalValidatedProcessGroup } from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
|
||||
describe('macOS helper owner-loss benchmark group recovery', () => {
|
||||
it('retains uncertain stop state across cleanup stage failures', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
|
||||
const groupState = { stopped: false }
|
||||
let scanCount = 0
|
||||
let continueAttempts = 0
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount >= 3) {
|
||||
throw new Error(
|
||||
scanCount === 3 ? 'post-stop inspection failed' : 'final inspection failed'
|
||||
)
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
if (pid === -41 && signal === 'SIGCONT') {
|
||||
continueAttempts += 1
|
||||
if (continueAttempts === 1) {
|
||||
throw new Error('first compensation failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
|
||||
).toThrow('Benchmark process group signal recovery failed')
|
||||
expect(groupState.stopped).toBe(true)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('final inspection failed')
|
||||
expect(continueAttempts).toBe(2)
|
||||
expect(groupState.stopped).toBe(false)
|
||||
})
|
||||
|
||||
it('retains an uncertain anchor stop across cleanup stage failures', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
|
||||
const groupState = { stopped: false, anchorPid: null }
|
||||
let scanCount = 0
|
||||
let continueAttempts = 0
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount >= 2) {
|
||||
throw new Error(
|
||||
scanCount === 2 ? 'post-anchor inspection failed' : 'final inspection failed'
|
||||
)
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
if (pid === 41 && signal === 'SIGCONT') {
|
||||
continueAttempts += 1
|
||||
if (continueAttempts === 1) {
|
||||
throw new Error('first anchor compensation failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
|
||||
).toThrow('Benchmark process group signal recovery failed')
|
||||
expect(groupState.anchorPid).toBe(41)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('final inspection failed')
|
||||
expect(continueAttempts).toBe(2)
|
||||
expect(groupState.anchorPid).toBeNull()
|
||||
})
|
||||
|
||||
it('recovers a retained anchor before selecting a new one', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const firstAnchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
|
||||
const finalAnchor = { pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
const groupState = { stopped: false, anchorPid: null }
|
||||
let scanCount = 0
|
||||
let firstAnchorContinues = 0
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount === 2) {
|
||||
throw new Error('post-anchor inspection failed')
|
||||
}
|
||||
return scanCount === 1 ? [firstAnchor] : [finalAnchor]
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
if (pid === 41 && signal === 'SIGCONT') {
|
||||
firstAnchorContinues += 1
|
||||
if (firstAnchorContinues === 1) {
|
||||
throw new Error('first anchor compensation failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
|
||||
).toThrow('Benchmark process group signal recovery failed')
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(true)
|
||||
expect(firstAnchorContinues).toBe(2)
|
||||
expect(groupState).toEqual({ stopped: false, anchorPid: null })
|
||||
})
|
||||
|
||||
it('preserves group authority errors when compensation fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const ownershipError = 'Benchmark process group no longer belongs to this trial'
|
||||
let thrown
|
||||
|
||||
try {
|
||||
signalValidatedProcessGroup(
|
||||
41,
|
||||
marker,
|
||||
'SIGKILL',
|
||||
{ stopped: true },
|
||||
{
|
||||
processIdentities: () => [{ pid: 41, pgid: 41, command: '/unrelated' }],
|
||||
signalProcess: () => {
|
||||
throw new Error('resume denied')
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors.map((error) => error.message)).toEqual([ownershipError, 'resume denied'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
const SAMPLE_COUNT = 5
|
||||
const SAMPLE_INTERVAL_MS = 200
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function median(values) {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
|
||||
export function percentile(values, fraction) {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]
|
||||
}
|
||||
|
||||
function parseCpuTimeSeconds(value) {
|
||||
const [dayOrTime, clock] = value.includes('-') ? value.split('-', 2) : [null, value]
|
||||
const days = dayOrTime === null ? 0 : Number(dayOrTime)
|
||||
const parts = clock.split(':').map(Number)
|
||||
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
|
||||
throw new Error(`Invalid process CPU time: ${value}`)
|
||||
}
|
||||
const seconds = parts.pop() ?? 0
|
||||
const minutes = parts.pop() ?? 0
|
||||
const hours = parts.pop() ?? 0
|
||||
return days * 86_400 + hours * 3_600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
export function processSnapshot(pid) {
|
||||
const raw = execFileSync(
|
||||
'ps',
|
||||
['-o', 'rss=', '-o', 'time=', '-o', 'command=', '-p', String(pid)],
|
||||
{ encoding: 'utf8' }
|
||||
).trim()
|
||||
const match = raw.match(/^(\d+)\s+(\S+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
throw new Error(`Could not inspect process ${pid}: ${raw}`)
|
||||
}
|
||||
return {
|
||||
rssBytes: Number(match[1]) * 1024,
|
||||
cpuTimeSeconds: parseCpuTimeSeconds(match[2]),
|
||||
command: match[3]
|
||||
}
|
||||
}
|
||||
|
||||
export async function sampleProcess(pid) {
|
||||
const samples = []
|
||||
for (let index = 0; index < SAMPLE_COUNT; index += 1) {
|
||||
samples.push(processSnapshot(pid))
|
||||
await sleep(SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
return {
|
||||
rssBytes: median(samples.map((sample) => sample.rssBytes)),
|
||||
cpuTimeSeconds: samples.at(-1).cpuTimeSeconds,
|
||||
command: samples.at(-1).command
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 2_000
|
||||
const PROCESS_POLL_MS = 25
|
||||
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
|
||||
|
||||
const processIdentityOperations = {
|
||||
executePs: execFileSync,
|
||||
signalProcess: process.kill.bind(process)
|
||||
}
|
||||
|
||||
export function processIdentity(pid, operations = processIdentityOperations) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const output = operations
|
||||
.executePs('ps', ['-p', String(pid), '-o', 'pid=,pgid=,command='], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
.trim()
|
||||
const match = output.match(/^(\d+)\s+(\d+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse process identity for ${pid}`)
|
||||
}
|
||||
return { pid: Number(match[1]), pgid: Number(match[2]), command: match[3] }
|
||||
} catch (error) {
|
||||
try {
|
||||
operations.signalProcess(pid, 0)
|
||||
} catch (lookupError) {
|
||||
if (lookupError?.code === 'ESRCH') {
|
||||
return null
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function matchingDetachedProcesses(identities, expectedCommandFragments) {
|
||||
return identities.filter(
|
||||
(identity) =>
|
||||
identity.pgid === identity.pid &&
|
||||
expectedCommandFragments.every((fragment) => identity.command.includes(fragment))
|
||||
)
|
||||
}
|
||||
|
||||
const matchingProcessOperations = {
|
||||
processIdentities,
|
||||
signalProcessIdentity,
|
||||
waitForIdentityExit
|
||||
}
|
||||
|
||||
export function killProcessMatchingCommand(
|
||||
expectedCommandFragments,
|
||||
operations = matchingProcessOperations
|
||||
) {
|
||||
const matches = matchingDetachedProcesses(
|
||||
operations.processIdentities(),
|
||||
expectedCommandFragments
|
||||
)
|
||||
if (matches.length === 0) {
|
||||
return false
|
||||
}
|
||||
const errors = []
|
||||
for (const match of matches) {
|
||||
try {
|
||||
if (operations.signalProcessIdentity(match, expectedCommandFragments[0], 'SIGKILL')) {
|
||||
operations.waitForIdentityExit(match)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const remaining = matchingDetachedProcesses(
|
||||
operations.processIdentities(),
|
||||
expectedCommandFragments
|
||||
)
|
||||
if (remaining.length > 0) {
|
||||
errors.push(
|
||||
new Error(
|
||||
`Benchmark helper cleanup left matching processes: ${remaining
|
||||
.map((identity) => identity.pid)
|
||||
.join(', ')}`
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Benchmark exact-command cleanup failed')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function sleepSync(milliseconds) {
|
||||
Atomics.wait(sleepBuffer, 0, 0, milliseconds)
|
||||
}
|
||||
|
||||
function validateDetachedIdentity(identity, expectedCommandFragment) {
|
||||
if (
|
||||
!Number.isInteger(identity?.pid) ||
|
||||
identity.pid <= 0 ||
|
||||
identity.pgid !== identity.pid ||
|
||||
typeof identity.command !== 'string' ||
|
||||
!identity.command.includes(expectedCommandFragment)
|
||||
) {
|
||||
throw new Error('Recorded benchmark helper identity is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
function sameIdentity(left, right) {
|
||||
return left?.pid === right?.pid && left?.pgid === right?.pgid && left?.command === right?.command
|
||||
}
|
||||
|
||||
function processIdentities(includeEnvironment = false) {
|
||||
const args = includeEnvironment
|
||||
? ['eww', '-axo', 'pid=,pgid=,command=']
|
||||
: ['-axo', 'pid=,pgid=,command=']
|
||||
const output = execFileSync('ps', args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 20 * 1024 * 1024
|
||||
})
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/))
|
||||
.filter(Boolean)
|
||||
.map((match) => ({
|
||||
pid: Number(match[1]),
|
||||
pgid: Number(match[2]),
|
||||
command: match[3]
|
||||
}))
|
||||
}
|
||||
|
||||
function waitForIdentityExit(identity) {
|
||||
const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS
|
||||
while (Date.now() < deadline) {
|
||||
if (!processIdentityIsCurrent(identity)) {
|
||||
return true
|
||||
}
|
||||
sleepSync(PROCESS_POLL_MS)
|
||||
}
|
||||
throw new Error(`Recorded benchmark helper ${identity.pid} did not exit`)
|
||||
}
|
||||
|
||||
export function spawnBenchmarkProcess(executable, args, options) {
|
||||
return spawnSync(executable, args, {
|
||||
...options,
|
||||
detached: true,
|
||||
killSignal: 'SIGKILL'
|
||||
})
|
||||
}
|
||||
|
||||
export function runBenchmarkCleanupStages(stages) {
|
||||
const errors = []
|
||||
for (const stage of stages) {
|
||||
try {
|
||||
stage()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Benchmark trial cleanup failed')
|
||||
}
|
||||
}
|
||||
|
||||
export function throwBenchmarkTrialFailures(trialError, cleanupError) {
|
||||
if (trialError && cleanupError) {
|
||||
throw new AggregateError([trialError, cleanupError], 'Electron trial and cleanup failed')
|
||||
}
|
||||
if (trialError) {
|
||||
throw trialError
|
||||
}
|
||||
if (cleanupError) {
|
||||
throw cleanupError
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBenchmarkTrialResult(serializedResult) {
|
||||
return JSON.parse(serializedResult)
|
||||
}
|
||||
|
||||
export function benchmarkTrialNeedsCleanup(spawnResult, parsedResultAvailable) {
|
||||
return spawnResult?.status !== 0 || !parsedResultAvailable
|
||||
}
|
||||
|
||||
const processGroupSignalOperations = {
|
||||
processIdentities,
|
||||
signalProcess: process.kill.bind(process)
|
||||
}
|
||||
|
||||
function compensateStoppedGroup(pgid, groupState, operations) {
|
||||
const errors = []
|
||||
const targets = [
|
||||
groupState.stopped ? [-pgid, 'stopped'] : null,
|
||||
groupState.anchorPid ? [groupState.anchorPid, 'anchorPid'] : null
|
||||
].filter(Boolean)
|
||||
for (const [pid, stateKey] of targets) {
|
||||
try {
|
||||
operations.signalProcess(pid, 'SIGCONT')
|
||||
groupState[stateKey] = stateKey === 'stopped' ? false : null
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
groupState[stateKey] = stateKey === 'stopped' ? false : null
|
||||
} else {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export function signalValidatedProcessGroup(
|
||||
pgid,
|
||||
environmentFragment,
|
||||
signal,
|
||||
groupState = { stopped: false, anchorPid: null },
|
||||
operations = processGroupSignalOperations
|
||||
) {
|
||||
if (!Number.isInteger(pgid) || pgid <= 0) {
|
||||
return false
|
||||
}
|
||||
let members
|
||||
try {
|
||||
members = operations.processIdentities(true).filter((identity) => identity.pgid === pgid)
|
||||
} catch (error) {
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...recoveryErrors],
|
||||
'Benchmark process group recovery failed before validation'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (members.length === 0) {
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(recoveryErrors, 'Benchmark missing process group recovery failed')
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (members.some((identity) => !identity.command.includes(environmentFragment))) {
|
||||
const ownershipError = new Error('Benchmark process group no longer belongs to this trial')
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[ownershipError, ...recoveryErrors],
|
||||
'Benchmark process group authority recovery failed'
|
||||
)
|
||||
}
|
||||
throw ownershipError
|
||||
}
|
||||
if (groupState.anchorPid) {
|
||||
try {
|
||||
operations.signalProcess(groupState.anchorPid, 'SIGCONT')
|
||||
groupState.anchorPid = null
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
groupState.anchorPid = null
|
||||
} else {
|
||||
throw new AggregateError([error], 'Benchmark pending anchor recovery failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
const anchor = members[0]
|
||||
try {
|
||||
operations.signalProcess(anchor.pid, 'SIGSTOP')
|
||||
groupState.anchorPid = anchor.pid
|
||||
const stoppedAnchor = operations
|
||||
.processIdentities(true)
|
||||
.find((identity) => identity.pid === anchor.pid)
|
||||
if (!sameIdentity(stoppedAnchor, anchor)) {
|
||||
throw new Error('Benchmark process group anchor changed before signaling')
|
||||
}
|
||||
operations.signalProcess(-pgid, 'SIGSTOP')
|
||||
groupState.stopped = true
|
||||
groupState.anchorPid = null
|
||||
const stoppedMembers = operations
|
||||
.processIdentities(true)
|
||||
.filter((identity) => identity.pgid === pgid)
|
||||
if (
|
||||
stoppedMembers.length === 0 ||
|
||||
stoppedMembers.some((identity) => !identity.command.includes(environmentFragment))
|
||||
) {
|
||||
throw new Error('Benchmark process group changed before signaling')
|
||||
}
|
||||
if (signal !== 'SIGSTOP') {
|
||||
operations.signalProcess(-pgid, signal)
|
||||
if (signal !== 'SIGKILL') {
|
||||
operations.signalProcess(-pgid, 'SIGCONT')
|
||||
}
|
||||
groupState.stopped = false
|
||||
groupState.anchorPid = null
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
|
||||
if (recoveryErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...recoveryErrors],
|
||||
'Benchmark process group signal recovery failed'
|
||||
)
|
||||
}
|
||||
if (error.code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function writeProcessRecord(recordPath, processIdentity) {
|
||||
const temporaryPath = `${recordPath}.${process.pid}.tmp`
|
||||
writeFileSync(temporaryPath, JSON.stringify(processIdentity))
|
||||
renameSync(temporaryPath, recordPath)
|
||||
}
|
||||
|
||||
export function processIdentityIsCurrent(identity) {
|
||||
return sameIdentity(processIdentity(identity?.pid), identity)
|
||||
}
|
||||
|
||||
const processSignalOperations = {
|
||||
processIdentity,
|
||||
signalProcess: process.kill.bind(process)
|
||||
}
|
||||
|
||||
export function signalProcessIdentity(
|
||||
identity,
|
||||
expectedCommandFragment,
|
||||
signal,
|
||||
operations = processSignalOperations
|
||||
) {
|
||||
validateDetachedIdentity(identity, expectedCommandFragment)
|
||||
const currentIdentity = operations.processIdentity(identity.pid)
|
||||
if (!currentIdentity) {
|
||||
return false
|
||||
}
|
||||
if (!sameIdentity(currentIdentity, identity)) {
|
||||
throw new Error('Recorded benchmark helper PID now belongs to another process')
|
||||
}
|
||||
let stopped = false
|
||||
try {
|
||||
operations.signalProcess(identity.pid, 'SIGSTOP')
|
||||
stopped = true
|
||||
const stoppedIdentity = operations.processIdentity(identity.pid)
|
||||
if (!sameIdentity(stoppedIdentity, identity)) {
|
||||
throw new Error('Recorded benchmark helper PID changed before signaling')
|
||||
}
|
||||
operations.signalProcess(-identity.pgid, signal)
|
||||
if (signal !== 'SIGKILL') {
|
||||
operations.signalProcess(-identity.pgid, 'SIGCONT')
|
||||
}
|
||||
stopped = false
|
||||
return true
|
||||
} catch (error) {
|
||||
let resumeError
|
||||
if (stopped) {
|
||||
try {
|
||||
operations.signalProcess(identity.pid, 'SIGCONT')
|
||||
} catch (caught) {
|
||||
if (caught?.code !== 'ESRCH') {
|
||||
resumeError = caught
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resumeError) {
|
||||
throw new AggregateError([error, resumeError], 'Benchmark helper signal recovery failed')
|
||||
}
|
||||
if (error.code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function killRecordedProcess(recordPath, expectedCommandFragment) {
|
||||
if (!existsSync(recordPath)) {
|
||||
return false
|
||||
}
|
||||
const record = JSON.parse(readFileSync(recordPath, 'utf8'))
|
||||
if (!signalProcessIdentity(record, expectedCommandFragment, 'SIGKILL')) {
|
||||
return false
|
||||
}
|
||||
return waitForIdentityExit(record)
|
||||
}
|
||||
|
||||
export function killRecordedAndMatchingProcesses(
|
||||
recordPath,
|
||||
recordedCommandFragment,
|
||||
matchingCommandFragments
|
||||
) {
|
||||
const errors = []
|
||||
try {
|
||||
killRecordedProcess(recordPath, recordedCommandFragment)
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
try {
|
||||
killProcessMatchingCommand(matchingCommandFragments)
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'Benchmark helper cleanup failed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
benchmarkTrialNeedsCleanup,
|
||||
killProcessMatchingCommand,
|
||||
killRecordedAndMatchingProcesses,
|
||||
killRecordedProcess,
|
||||
parseBenchmarkTrialResult,
|
||||
processIdentity,
|
||||
runBenchmarkCleanupStages,
|
||||
signalProcessIdentity,
|
||||
signalValidatedProcessGroup,
|
||||
spawnBenchmarkProcess,
|
||||
throwBenchmarkTrialFailures,
|
||||
writeProcessRecord
|
||||
} from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
|
||||
|
||||
const describeMacOS = process.platform === 'darwin' ? describe : describe.skip
|
||||
const spawnedPids = new Set()
|
||||
const temporaryDirectories = new Set()
|
||||
|
||||
afterEach(() => {
|
||||
for (const pid of spawnedPids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {}
|
||||
}
|
||||
spawnedPids.clear()
|
||||
for (const temporaryDirectory of temporaryDirectories) {
|
||||
rmSync(temporaryDirectory, { recursive: true, force: true })
|
||||
}
|
||||
temporaryDirectories.clear()
|
||||
})
|
||||
|
||||
describeMacOS('macOS helper owner-loss benchmark process cleanup', () => {
|
||||
it('enforces a hard timeout when the trial ignores SIGTERM', () => {
|
||||
const startedAt = Date.now()
|
||||
const result = spawnBenchmarkProcess(
|
||||
process.execPath,
|
||||
['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)"],
|
||||
{ stdio: 'ignore', timeout: 100 }
|
||||
)
|
||||
expect(result.error?.code).toBe('ETIMEDOUT')
|
||||
expect(result.signal).toBe('SIGKILL')
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000)
|
||||
expect(() => process.kill(result.pid, 0)).toThrow()
|
||||
})
|
||||
|
||||
it('runs every cleanup stage before aggregating errors', () => {
|
||||
const completed = []
|
||||
let thrown
|
||||
|
||||
try {
|
||||
runBenchmarkCleanupStages([
|
||||
() => {
|
||||
completed.push(1)
|
||||
throw new Error('first failure')
|
||||
},
|
||||
() => {
|
||||
completed.push(2)
|
||||
},
|
||||
() => {
|
||||
completed.push(3)
|
||||
throw new Error('last failure')
|
||||
}
|
||||
])
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(completed).toEqual([1, 2, 3])
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors.map((error) => error.message)).toEqual(['first failure', 'last failure'])
|
||||
})
|
||||
|
||||
it('preserves malformed-result and cleanup failures', () => {
|
||||
let trialError
|
||||
try {
|
||||
parseBenchmarkTrialResult('{malformed')
|
||||
} catch (error) {
|
||||
trialError = error
|
||||
}
|
||||
const cleanupError = new Error('cleanup failed')
|
||||
let thrown
|
||||
|
||||
try {
|
||||
throwBenchmarkTrialFailures(trialError, cleanupError)
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(trialError).toBeInstanceOf(SyntaxError)
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors).toEqual([trialError, cleanupError])
|
||||
})
|
||||
|
||||
it('cleans up a status-zero trial whose result could not be parsed', () => {
|
||||
expect(benchmarkTrialNeedsCleanup(undefined, false)).toBe(true)
|
||||
expect(benchmarkTrialNeedsCleanup({ status: 0 }, false)).toBe(true)
|
||||
expect(benchmarkTrialNeedsCleanup({ status: 0 }, true)).toBe(false)
|
||||
expect(benchmarkTrialNeedsCleanup({ status: 1 }, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes a launcher directory after partial trial setup', () => {
|
||||
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-owner-partial-setup-test-'))
|
||||
temporaryDirectories.add(launcherDir)
|
||||
|
||||
const cleanup = cleanupOwnerLossTrial({
|
||||
failed: true,
|
||||
launcherDir,
|
||||
outputPaths: []
|
||||
})
|
||||
|
||||
expect(cleanup.error).toBeUndefined()
|
||||
expect(existsSync(launcherDir)).toBe(false)
|
||||
temporaryDirectories.delete(launcherDir)
|
||||
})
|
||||
|
||||
it('kills a timed-out trial group only after validating its environment', async () => {
|
||||
const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'orca-owner-benchmark-group-test-'))
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const childPidPath = path.join(temporaryDirectory, 'child.pid')
|
||||
const environmentName = `ORCA_OWNER_GROUP_${process.pid}`
|
||||
const environmentValue = `${Date.now()}`
|
||||
const fixture = `
|
||||
const { spawn } = require('node:child_process')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid))
|
||||
setInterval(() => {}, 1000)
|
||||
`
|
||||
const result = spawnBenchmarkProcess(process.execPath, ['-e', fixture], {
|
||||
env: { ...process.env, [environmentName]: environmentValue },
|
||||
stdio: 'ignore',
|
||||
timeout: 100
|
||||
})
|
||||
const childPid = Number(readFileSync(childPidPath, 'utf8'))
|
||||
spawnedPids.add(childPid)
|
||||
const environmentFragment = `${environmentName}=${environmentValue}`
|
||||
const groupState = { stopped: false }
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(result.pid, `${environmentName}=wrong`, 'SIGSTOP')
|
||||
).toThrow('Benchmark process group no longer belongs to this trial')
|
||||
expect(() => process.kill(childPid, 0)).not.toThrow()
|
||||
expect(
|
||||
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGSTOP', groupState)
|
||||
).toBe(true)
|
||||
expect(
|
||||
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGKILL', groupState)
|
||||
).toBe(true)
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
process.kill(childPid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.toBe(false)
|
||||
|
||||
spawnedPids.delete(childPid)
|
||||
})
|
||||
|
||||
it('resumes the group after post-stop revalidation fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [
|
||||
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
|
||||
{ pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
]
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(
|
||||
41,
|
||||
marker,
|
||||
'SIGKILL',
|
||||
{ stopped: false },
|
||||
{
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount === 3) {
|
||||
throw new Error('transient group inspection failure')
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
)
|
||||
).toThrow('transient group inspection failure')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[-41, 'SIGSTOP'],
|
||||
[-41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('compensates a possible stop after group anchor replacement', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
|
||||
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(
|
||||
41,
|
||||
marker,
|
||||
'SIGKILL',
|
||||
{ stopped: false },
|
||||
{
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
return scanCount === 1 ? [anchor] : [replacement]
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
)
|
||||
).toThrow('Benchmark process group anchor changed before signaling')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('resumes a previously frozen group when final inspection fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
const groupState = { stopped: false }
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
if (scanCount === 4) {
|
||||
throw new Error('transient final inspection failure')
|
||||
}
|
||||
return members
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('transient final inspection failure')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[-41, 'SIGSTOP'],
|
||||
[-41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('resumes a previously frozen group when final anchor stop fails', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const members = [
|
||||
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
|
||||
{ pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
]
|
||||
const signals = []
|
||||
let finalCall = false
|
||||
const groupState = { stopped: false }
|
||||
const missingProcessError = Object.assign(new Error('anchor exited'), { code: 'ESRCH' })
|
||||
const operations = {
|
||||
processIdentities: () => members,
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
if (finalCall && pid === 41 && signal === 'SIGSTOP') {
|
||||
throw missingProcessError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
|
||||
finalCall = true
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(false)
|
||||
expect(signals.at(-1)).toEqual([-41, 'SIGCONT'])
|
||||
})
|
||||
|
||||
it('resumes a previously frozen group after final anchor replacement', () => {
|
||||
const marker = 'ORCA_OWNER_GROUP=trial'
|
||||
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
|
||||
const child = { pid: 42, pgid: 41, command: `/child ${marker}` }
|
||||
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
|
||||
const signals = []
|
||||
let scanCount = 0
|
||||
const groupState = { stopped: false }
|
||||
const operations = {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
return scanCount === 5 ? [replacement, child] : [anchor, child]
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
}
|
||||
|
||||
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
|
||||
expect(() =>
|
||||
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
|
||||
).toThrow('Benchmark process group anchor changed before signaling')
|
||||
expect(signals.slice(-2)).toEqual([
|
||||
[-41, 'SIGCONT'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('kills a recorded helper in a separate process group', async () => {
|
||||
const temporaryDirectory = mkdtempSync(
|
||||
path.join(tmpdir(), 'orca-owner-benchmark-cleanup-test-')
|
||||
)
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const recordPath = path.join(temporaryDirectory, 'helper.json')
|
||||
const marker = `orca-owner-cleanup-${process.pid}-${Date.now()}`
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
const exited = new Promise((resolve) => helper.once('exit', resolve))
|
||||
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const processGroup = Number(
|
||||
execFileSync('ps', ['-p', String(helper.pid), '-o', 'pgid='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
)
|
||||
writeProcessRecord(recordPath, { pid: helper.pid, pgid: processGroup, command })
|
||||
|
||||
expect(processGroup).toBe(helper.pid)
|
||||
expect(killRecordedProcess(recordPath, marker)).toBe(true)
|
||||
await exited
|
||||
expect(() => process.kill(helper.pid, 0)).toThrow()
|
||||
|
||||
spawnedPids.delete(helper.pid)
|
||||
})
|
||||
|
||||
it('kills every unrecorded helper using its unique trial command', async () => {
|
||||
const marker = `orca-owner-unrecorded-${process.pid}-${Date.now()}`
|
||||
const helpers = Array.from({ length: 2 }, () =>
|
||||
spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
)
|
||||
for (const helper of helpers) {
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
}
|
||||
const exited = Promise.all(
|
||||
helpers.map((helper) => new Promise((resolve) => helper.once('exit', resolve)))
|
||||
)
|
||||
|
||||
expect(killProcessMatchingCommand([process.execPath, marker])).toBe(true)
|
||||
await exited
|
||||
for (const helper of helpers) {
|
||||
expect(() => process.kill(helper.pid, 0)).toThrow()
|
||||
spawnedPids.delete(helper.pid)
|
||||
}
|
||||
})
|
||||
|
||||
it('continues exact-match cleanup after an earlier match fails', () => {
|
||||
const marker = `orca-owner-multiple-${process.pid}-${Date.now()}`
|
||||
const matches = [
|
||||
{ pid: 41, pgid: 41, command: `/helper ${marker}` },
|
||||
{ pid: 42, pgid: 42, command: `/helper ${marker}` }
|
||||
]
|
||||
const attempted = []
|
||||
let scanCount = 0
|
||||
|
||||
expect(() =>
|
||||
killProcessMatchingCommand(['/helper', marker], {
|
||||
processIdentities: () => {
|
||||
scanCount += 1
|
||||
return scanCount === 1 ? matches : [matches[0]]
|
||||
},
|
||||
signalProcessIdentity: (identity) => {
|
||||
attempted.push(identity.pid)
|
||||
if (identity.pid === matches[0].pid) {
|
||||
throw new Error('identity changed')
|
||||
}
|
||||
return true
|
||||
},
|
||||
waitForIdentityExit: () => {}
|
||||
})
|
||||
).toThrow('Benchmark exact-command cleanup failed')
|
||||
expect(attempted).toEqual([41, 42])
|
||||
})
|
||||
|
||||
it('does not treat an identity query failure as process exit', () => {
|
||||
const queryError = new Error('transient ps failure')
|
||||
|
||||
expect(() =>
|
||||
processIdentity(41, {
|
||||
executePs: () => {
|
||||
throw queryError
|
||||
},
|
||||
signalProcess: () => {}
|
||||
})
|
||||
).toThrow(queryError)
|
||||
})
|
||||
|
||||
it('resumes a helper when post-stop identity inspection fails', () => {
|
||||
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
|
||||
const signals = []
|
||||
let inspectionCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
|
||||
processIdentity: () => {
|
||||
inspectionCount += 1
|
||||
if (inspectionCount === 2) {
|
||||
throw new Error('transient ps failure after stop')
|
||||
}
|
||||
return identity
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
})
|
||||
).toThrow('transient ps failure after stop')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('compensates a possible stop after helper PID replacement', () => {
|
||||
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
|
||||
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
|
||||
const signals = []
|
||||
let inspectionCount = 0
|
||||
|
||||
expect(() =>
|
||||
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
|
||||
processIdentity: () => {
|
||||
inspectionCount += 1
|
||||
return inspectionCount === 1 ? identity : replacement
|
||||
},
|
||||
signalProcess: (pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
}
|
||||
})
|
||||
).toThrow('Recorded benchmark helper PID changed before signaling')
|
||||
expect(signals).toEqual([
|
||||
[41, 'SIGSTOP'],
|
||||
[41, 'SIGCONT']
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves helper identity errors when compensation fails', () => {
|
||||
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
|
||||
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
|
||||
let inspectionCount = 0
|
||||
let thrown
|
||||
|
||||
try {
|
||||
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
|
||||
processIdentity: () => {
|
||||
inspectionCount += 1
|
||||
return inspectionCount === 1 ? identity : replacement
|
||||
},
|
||||
signalProcess: (_pid, signal) => {
|
||||
if (signal === 'SIGCONT') {
|
||||
throw new Error('resume denied')
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AggregateError)
|
||||
expect(thrown.errors.map((error) => error.message)).toEqual([
|
||||
'Recorded benchmark helper PID changed before signaling',
|
||||
'resume denied'
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a missing PID as process exit after an identity query failure', () => {
|
||||
const missingProcessError = Object.assign(new Error('missing process'), { code: 'ESRCH' })
|
||||
|
||||
expect(
|
||||
processIdentity(41, {
|
||||
executePs: () => {
|
||||
throw new Error('ps found no process')
|
||||
},
|
||||
signalProcess: () => {
|
||||
throw missingProcessError
|
||||
}
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('runs unique-command cleanup after an invalid process record', async () => {
|
||||
const temporaryDirectory = mkdtempSync(
|
||||
path.join(tmpdir(), 'orca-owner-benchmark-fallback-test-')
|
||||
)
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const recordPath = path.join(temporaryDirectory, 'helper.json')
|
||||
const marker = `orca-owner-invalid-record-${process.pid}-${Date.now()}`
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
const exited = new Promise((resolve) => helper.once('exit', resolve))
|
||||
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
|
||||
|
||||
expect(() =>
|
||||
killRecordedAndMatchingProcesses(recordPath, marker, [process.execPath, marker])
|
||||
).toThrow('Recorded benchmark helper identity is invalid')
|
||||
await exited
|
||||
expect(() => process.kill(helper.pid, 0)).toThrow()
|
||||
|
||||
spawnedPids.delete(helper.pid)
|
||||
})
|
||||
|
||||
it('rejects a record that is not a detached process-group identity', () => {
|
||||
const temporaryDirectory = mkdtempSync(
|
||||
path.join(tmpdir(), 'orca-owner-benchmark-identity-test-')
|
||||
)
|
||||
temporaryDirectories.add(temporaryDirectory)
|
||||
const recordPath = path.join(temporaryDirectory, 'helper.json')
|
||||
const marker = `orca-owner-invalid-identity-${process.pid}-${Date.now()}`
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
spawnedPids.add(helper.pid)
|
||||
helper.unref()
|
||||
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
|
||||
|
||||
expect(() => killRecordedProcess(recordPath, marker)).toThrow(
|
||||
'Recorded benchmark helper identity is invalid'
|
||||
)
|
||||
expect(() => process.kill(helper.pid, 0)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { closeSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import {
|
||||
killRecordedAndMatchingProcesses,
|
||||
runBenchmarkCleanupStages,
|
||||
signalValidatedProcessGroup
|
||||
} from './macos-computer-helper-owner-loss-processes.mjs'
|
||||
|
||||
export function cleanupOwnerLossTrial(options) {
|
||||
const groupState = { stopped: false, anchorPid: null }
|
||||
let error
|
||||
let output = ''
|
||||
try {
|
||||
runBenchmarkCleanupStages([
|
||||
() => {
|
||||
if (options.failed && Number.isInteger(options.pid) && options.marker) {
|
||||
signalValidatedProcessGroup(options.pid, options.marker, 'SIGSTOP', groupState)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.failed && options.recordPath && options.tempDir) {
|
||||
killRecordedAndMatchingProcesses(options.recordPath, options.helperPath, [
|
||||
options.helperPath,
|
||||
options.tempDir
|
||||
])
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.failed && Number.isInteger(options.pid) && options.marker) {
|
||||
signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL', groupState)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.stderrDescriptor !== undefined) {
|
||||
closeSync(options.stderrDescriptor)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.stdoutDescriptor !== undefined) {
|
||||
closeSync(options.stdoutDescriptor)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
output = (options.outputPaths ?? [])
|
||||
.filter((outputPath) => outputPath && existsSync(outputPath))
|
||||
.map((outputPath) => readFileSync(outputPath, 'utf8'))
|
||||
.join('')
|
||||
},
|
||||
() => {
|
||||
if (options.launcherDir) {
|
||||
rmSync(options.launcherDir, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (options.tempDir) {
|
||||
rmSync(options.tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
])
|
||||
} catch (caught) {
|
||||
error = caught
|
||||
}
|
||||
return { error, output }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('packaged hang watchdog worker contract', () => {
|
||||
it('boots the worker from app.asar in PR checks', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const smokeSource = readFileSync(
|
||||
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
|
||||
'utf8'
|
||||
)
|
||||
const smokeStep = workflow.jobs.package.steps.find(
|
||||
(step) => step.name === 'Smoke packaged hang watchdog worker'
|
||||
)
|
||||
|
||||
expect(smokeStep.run).toBe(
|
||||
'xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked'
|
||||
)
|
||||
expect(smokeSource).toContain(
|
||||
"process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]"
|
||||
)
|
||||
})
|
||||
|
||||
// Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion.
|
||||
it('fails the smoke when the packaged worker never reports success', () => {
|
||||
const smokeSource = readFileSync(
|
||||
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(smokeSource).toContain('app.exit(1)')
|
||||
expect(smokeSource).not.toContain('app.quit()')
|
||||
expect(smokeSource).toContain('if (!result.stdout.includes(SUCCESS_LINE))')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
|
||||
const INTERNAL_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_INTERNAL'
|
||||
const ASAR_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_ASAR'
|
||||
const TIMEOUT_MS = 100
|
||||
const CHECK_INTERVAL_MS = 20
|
||||
const POLL_TIMEOUT_MS = 5_000
|
||||
const SUCCESS_LINE = '[packaged-watchdog-smoke] app.asar worker detected and recovered a stall'
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
|
||||
}
|
||||
|
||||
function readAppDirArg(argv) {
|
||||
const explicit = argv.find((arg) => arg.startsWith('--app-dir='))
|
||||
if (explicit) {
|
||||
return explicit.slice('--app-dir='.length)
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return 'dist/mac-arm64/Orca.app'
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return 'dist/win-unpacked'
|
||||
}
|
||||
return 'dist/linux-unpacked'
|
||||
}
|
||||
|
||||
function getResourcesDir(appDir) {
|
||||
return process.platform === 'darwin' || appDir.endsWith('.app')
|
||||
? join(appDir, 'Contents', 'Resources')
|
||||
: join(appDir, 'resources')
|
||||
}
|
||||
|
||||
function readMarker(markerPath) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(markerPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMarker(markerPath, predicate, workerError) {
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS
|
||||
while (Date.now() < deadline) {
|
||||
if (workerError.current) {
|
||||
throw workerError.current
|
||||
}
|
||||
const marker = readMarker(markerPath)
|
||||
if (predicate(marker)) {
|
||||
return marker
|
||||
}
|
||||
await sleep(CHECK_INTERVAL_MS)
|
||||
}
|
||||
throw new Error(`Timed out waiting for packaged watchdog marker at ${markerPath}`)
|
||||
}
|
||||
|
||||
async function waitForExit(worker, workerError) {
|
||||
const exitCode = await Promise.race([
|
||||
new Promise((resolveExit) => worker.once('exit', resolveExit)),
|
||||
sleep(POLL_TIMEOUT_MS).then(() => {
|
||||
throw new Error('Timed out waiting for packaged watchdog worker to exit')
|
||||
})
|
||||
])
|
||||
if (workerError.current) {
|
||||
throw workerError.current
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Packaged watchdog worker exited with code ${exitCode}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function runInternal() {
|
||||
const appAsar = process.env[ASAR_ENV]
|
||||
if (!appAsar) {
|
||||
throw new Error(`Missing ${ASAR_ENV}`)
|
||||
}
|
||||
const { app } = await import('electron')
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-smoke-'))
|
||||
const markerPath = join(tempRoot, 'main-thread-hang.json')
|
||||
const entryPath = join(appAsar, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
let worker
|
||||
try {
|
||||
await app.whenReady()
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`Packaged watchdog entry is missing from app.asar: ${entryPath}`)
|
||||
}
|
||||
const workerError = { current: null }
|
||||
worker = new Worker(entryPath, {
|
||||
workerData: {
|
||||
parentPid: process.pid,
|
||||
markerPath,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
checkIntervalMs: CHECK_INTERVAL_MS
|
||||
}
|
||||
})
|
||||
worker.once('error', (error) => {
|
||||
workerError.current = error
|
||||
})
|
||||
await waitForMarker(markerPath, (marker) => marker?.selfRecovered === false, workerError)
|
||||
worker.postMessage({ type: 'heartbeat' })
|
||||
await waitForMarker(markerPath, (marker) => marker?.selfRecovered === true, workerError)
|
||||
worker.postMessage({ type: 'shutdown' })
|
||||
await waitForExit(worker, workerError)
|
||||
worker = undefined
|
||||
console.log(SUCCESS_LINE)
|
||||
} finally {
|
||||
await worker?.terminate()
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function runSmoke() {
|
||||
const appDir = resolve(readAppDirArg(process.argv.slice(2)))
|
||||
const appAsar = join(getResourcesDir(appDir), 'app.asar')
|
||||
if (!existsSync(appAsar)) {
|
||||
throw new Error(`Packaged app archive is missing: ${appAsar}`)
|
||||
}
|
||||
const require = createRequire(import.meta.url)
|
||||
const executable = require('electron')
|
||||
const launcherDir = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-launcher-'))
|
||||
const launcherPath = join(launcherDir, 'main.cjs')
|
||||
writeFileSync(
|
||||
join(launcherDir, 'package.json'),
|
||||
JSON.stringify({ name: 'orca-packaged-watchdog-smoke', main: 'main.cjs' })
|
||||
)
|
||||
writeFileSync(
|
||||
launcherPath,
|
||||
`import(${JSON.stringify(pathToFileURL(import.meta.filename).href)}).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})\n`
|
||||
)
|
||||
const env = { ...process.env, [INTERNAL_ENV]: '1', [ASAR_ENV]: appAsar, NODE_PATH: '' }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
try {
|
||||
const electronArgs =
|
||||
process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]
|
||||
const result = spawnSync(executable, electronArgs, {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Packaged watchdog smoke failed (${result.error?.message ?? result.signal ?? result.status}):\n` +
|
||||
`${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
// Why: Electron discards process.exitCode, so status 0 alone can't prove the worker ran.
|
||||
if (!result.stdout.includes(SUCCESS_LINE)) {
|
||||
throw new Error(
|
||||
`Packaged watchdog smoke did not report success:\n${result.stderr || result.stdout}`
|
||||
)
|
||||
}
|
||||
process.stdout.write(result.stdout)
|
||||
} finally {
|
||||
rmSync(launcherDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env[INTERNAL_ENV] === '1') {
|
||||
// Why: a graceful quit exits 0 regardless of process.exitCode; only app.exit propagates failure.
|
||||
const { app } = await import('electron')
|
||||
try {
|
||||
await runInternal()
|
||||
app.exit(0)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
}
|
||||
} else {
|
||||
runSmoke()
|
||||
}
|
||||
@@ -243,9 +243,8 @@ export const electronViteConfig: UserConfig = {
|
||||
// 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'),
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
|
||||
// main thread (macOS 26 AppKit scene-update deadlock) and can record
|
||||
// the stall for the next launch to report.
|
||||
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
|
||||
// without paying for another Electron process.
|
||||
'main-thread-hang-watchdog-entry': resolve(
|
||||
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
|
||||
),
|
||||
|
||||
@@ -2579,9 +2579,12 @@ private enum KeyMap {
|
||||
}
|
||||
|
||||
private final class AgentRuntime: NSObject, NSApplicationDelegate {
|
||||
private static let unclaimedSessionDeadline: TimeInterval = 30
|
||||
|
||||
private let socketPath: String
|
||||
private let token: String?
|
||||
private var listener: SocketListener?
|
||||
private var unclaimedSessionTimeout: DispatchWorkItem?
|
||||
|
||||
init(socketPath: String, token: String?) {
|
||||
self.socketPath = socketPath
|
||||
@@ -2590,9 +2593,29 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate {
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
do {
|
||||
let listener = try SocketListener(socketPath: socketPath, token: token)
|
||||
let timeout = DispatchWorkItem {
|
||||
fputs("computer-use agent received no authenticated session before its deadline\n", stderr)
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
unclaimedSessionTimeout = timeout
|
||||
let listener = try SocketListener(
|
||||
socketPath: socketPath,
|
||||
token: token,
|
||||
onSessionClaimed: {
|
||||
timeout.cancel()
|
||||
},
|
||||
onSessionClosed: {
|
||||
DispatchQueue.main.async {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
)
|
||||
self.listener = listener
|
||||
listener.start()
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + Self.unclaimedSessionDeadline,
|
||||
execute: timeout
|
||||
)
|
||||
} catch {
|
||||
fputs("failed to start computer-use socket: \(error)\n", stderr)
|
||||
NSApp.terminate(nil)
|
||||
@@ -2600,6 +2623,8 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
unclaimedSessionTimeout?.cancel()
|
||||
unclaimedSessionTimeout = nil
|
||||
listener?.stop()
|
||||
}
|
||||
}
|
||||
@@ -3505,14 +3530,26 @@ private final class ButtonTarget: NSObject {
|
||||
private final class SocketListener: @unchecked Sendable {
|
||||
private let socketPath: String
|
||||
private let token: String?
|
||||
private let onSessionClaimed: () -> Void
|
||||
private let onSessionClosed: () -> Void
|
||||
private let provider = Provider()
|
||||
private let providerLock = NSLock()
|
||||
private let sessionLock = NSLock()
|
||||
private var sessionOwnership = AgentSessionOwnership()
|
||||
private var lastConnectionID: UInt64 = 0
|
||||
private var socketFd: Int32 = -1
|
||||
private var isStopped = false
|
||||
|
||||
init(socketPath: String, token: String?) throws {
|
||||
init(
|
||||
socketPath: String,
|
||||
token: String?,
|
||||
onSessionClaimed: @escaping () -> Void,
|
||||
onSessionClosed: @escaping () -> Void
|
||||
) throws {
|
||||
self.socketPath = socketPath
|
||||
self.token = token
|
||||
self.onSessionClaimed = onSessionClaimed
|
||||
self.onSessionClosed = onSessionClosed
|
||||
try bindSocket()
|
||||
}
|
||||
|
||||
@@ -3587,14 +3624,35 @@ private final class SocketListener: @unchecked Sendable {
|
||||
}
|
||||
continue
|
||||
}
|
||||
guard let connectionID = allocateConnectionID() else {
|
||||
fputs("computer-use socket exhausted connection identities\n", stderr)
|
||||
close(fd)
|
||||
continue
|
||||
}
|
||||
Thread.detachNewThread { [weak self] in
|
||||
self?.handleConnection(fd)
|
||||
self?.handleConnection(fd, connectionID: connectionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnection(_ fd: Int32) {
|
||||
defer { close(fd) }
|
||||
private func allocateConnectionID() -> AgentSessionConnectionID? {
|
||||
sessionLock.lock()
|
||||
defer { sessionLock.unlock() }
|
||||
guard lastConnectionID < UInt64.max else { return nil }
|
||||
lastConnectionID += 1
|
||||
return AgentSessionConnectionID(rawValue: lastConnectionID)
|
||||
}
|
||||
|
||||
private func handleConnection(_ fd: Int32, connectionID: AgentSessionConnectionID) {
|
||||
var registeredSession = false
|
||||
var hangupMonitor: AuthenticatedConnectionHangupMonitor?
|
||||
defer {
|
||||
hangupMonitor?.cancel()
|
||||
if registeredSession {
|
||||
disconnectSession(connectionID)
|
||||
}
|
||||
close(fd)
|
||||
}
|
||||
let authorizedPeer = peerProcessId(fd).map(isAuthorizedAgentPeer) == true
|
||||
let decoder = JSONDecoder()
|
||||
while let line = readLine(from: fd) {
|
||||
@@ -3603,6 +3661,40 @@ private final class SocketListener: @unchecked Sendable {
|
||||
else {
|
||||
continue
|
||||
}
|
||||
if !registeredSession && isAuthenticatedAgentSession(
|
||||
expectedToken: token,
|
||||
requestToken: request.token,
|
||||
authorizedPeer: authorizedPeer
|
||||
) {
|
||||
let monitor: AuthenticatedConnectionHangupMonitor
|
||||
do {
|
||||
monitor = try AuthenticatedConnectionHangupMonitor(
|
||||
fileDescriptor: fd,
|
||||
onHangup: { [weak self] in
|
||||
self?.disconnectSession(connectionID)
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
fputs("computer-use owner monitor failed: \(error)\n", stderr)
|
||||
return
|
||||
}
|
||||
sessionLock.lock()
|
||||
let registration = sessionOwnership.registerConnection(
|
||||
connectionID,
|
||||
authenticated: true
|
||||
)
|
||||
sessionLock.unlock()
|
||||
guard registration != .rejected else {
|
||||
monitor.cancel()
|
||||
return
|
||||
}
|
||||
registeredSession = true
|
||||
hangupMonitor = monitor
|
||||
monitor.start()
|
||||
if registration == .claimed {
|
||||
onSessionClaimed()
|
||||
}
|
||||
}
|
||||
let response = handleRequest(
|
||||
provider: provider,
|
||||
lock: providerLock,
|
||||
@@ -3613,6 +3705,15 @@ private final class SocketListener: @unchecked Sendable {
|
||||
writeJSON(response, to: fd)
|
||||
}
|
||||
}
|
||||
|
||||
private func disconnectSession(_ connectionID: AgentSessionConnectionID) {
|
||||
sessionLock.lock()
|
||||
let shouldTerminate = sessionOwnership.disconnect(connectionID)
|
||||
sessionLock.unlock()
|
||||
if shouldTerminate {
|
||||
onSessionClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func existingPathMode(_ path: String) -> mode_t? {
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
public struct AgentSessionConnectionID: Hashable, Sendable {
|
||||
public let rawValue: UInt64
|
||||
|
||||
public init(rawValue: UInt64) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
}
|
||||
|
||||
public enum AgentSessionRegistration: Sendable {
|
||||
case rejected
|
||||
case claimed
|
||||
case retained
|
||||
}
|
||||
|
||||
public struct AgentSessionOwnership: Sendable {
|
||||
private var authenticatedConnections: Set<AgentSessionConnectionID> = []
|
||||
private var wasClaimed = false
|
||||
private var sessionClosed = false
|
||||
|
||||
public init() {}
|
||||
|
||||
public mutating func registerConnection(
|
||||
_ connection: AgentSessionConnectionID,
|
||||
authenticated: Bool
|
||||
) -> AgentSessionRegistration {
|
||||
guard authenticated, !sessionClosed else { return .rejected }
|
||||
let inserted = authenticatedConnections.insert(connection).inserted
|
||||
guard inserted else { return .rejected }
|
||||
guard !wasClaimed else { return .retained }
|
||||
wasClaimed = true
|
||||
return .claimed
|
||||
}
|
||||
|
||||
public mutating func disconnect(_ connection: AgentSessionConnectionID) -> Bool {
|
||||
guard authenticatedConnections.remove(connection) != nil else { return false }
|
||||
guard wasClaimed, authenticatedConnections.isEmpty else { return false }
|
||||
sessionClosed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public func isAuthenticatedAgentSession(
|
||||
expectedToken: String?,
|
||||
requestToken: String?,
|
||||
authorizedPeer: Bool
|
||||
) -> Bool {
|
||||
guard authorizedPeer else { return false }
|
||||
guard let expectedToken else { return true }
|
||||
return requestToken == expectedToken
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import Darwin
|
||||
import Dispatch
|
||||
import Foundation
|
||||
|
||||
public final class AuthenticatedConnectionHangupMonitor: @unchecked Sendable {
|
||||
typealias RegisterEvents = @Sendable (
|
||||
Int32,
|
||||
UnsafePointer<kevent>?,
|
||||
Int32
|
||||
) -> POSIXErrorCode?
|
||||
typealias CloseDescriptor = @Sendable (Int32) -> Void
|
||||
|
||||
private static let cancelEventIdentifier: UInt = 1
|
||||
|
||||
private let closeDescriptor: CloseDescriptor
|
||||
private let eventQueue: DispatchQueue
|
||||
private let eventQueueDescriptor: Int32
|
||||
private let onEvent: @Sendable () -> Void
|
||||
private let stateLock = NSLock()
|
||||
private let onHangup: @Sendable () -> Void
|
||||
private var isCancelled = false
|
||||
private var didReportHangup = false
|
||||
private var isFinished = false
|
||||
private var isStarted = false
|
||||
|
||||
public convenience init(
|
||||
fileDescriptor: Int32,
|
||||
queue: DispatchQueue = DispatchQueue(
|
||||
label: "com.stablyai.orca.computer-use-owner-hangup"
|
||||
),
|
||||
onEvent: @escaping @Sendable () -> Void = {},
|
||||
onHangup: @escaping @Sendable () -> Void
|
||||
) throws {
|
||||
try self.init(
|
||||
fileDescriptor: fileDescriptor,
|
||||
queue: queue,
|
||||
registerEvents: { descriptor, events, count in
|
||||
guard kevent(descriptor, events, count, nil, 0, nil) == 0 else {
|
||||
return POSIXErrorCode(rawValue: errno) ?? .EIO
|
||||
}
|
||||
return nil
|
||||
},
|
||||
closeDescriptor: { descriptor in
|
||||
close(descriptor)
|
||||
},
|
||||
onEvent: onEvent,
|
||||
onHangup: onHangup
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
fileDescriptor: Int32,
|
||||
queue: DispatchQueue = DispatchQueue(
|
||||
label: "com.stablyai.orca.computer-use-owner-hangup"
|
||||
),
|
||||
registerEvents: RegisterEvents,
|
||||
closeDescriptor: @escaping CloseDescriptor,
|
||||
onEvent: @escaping @Sendable () -> Void = {},
|
||||
onHangup: @escaping @Sendable () -> Void
|
||||
) throws {
|
||||
self.closeDescriptor = closeDescriptor
|
||||
eventQueue = queue
|
||||
self.onEvent = onEvent
|
||||
self.onHangup = onHangup
|
||||
let descriptor = kqueue()
|
||||
guard descriptor >= 0 else {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
guard fileDescriptor >= 0 else {
|
||||
closeDescriptor(descriptor)
|
||||
throw POSIXError(.EBADF)
|
||||
}
|
||||
eventQueueDescriptor = descriptor
|
||||
var registrations = [
|
||||
kevent(
|
||||
ident: UInt(fileDescriptor),
|
||||
filter: Int16(EVFILT_READ),
|
||||
flags: UInt16(EV_ADD | EV_CLEAR),
|
||||
fflags: UInt32(NOTE_LOWAT),
|
||||
data: Int.max,
|
||||
udata: nil
|
||||
),
|
||||
kevent(
|
||||
ident: Self.cancelEventIdentifier,
|
||||
filter: Int16(EVFILT_USER),
|
||||
flags: UInt16(EV_ADD | EV_CLEAR),
|
||||
fflags: 0,
|
||||
data: 0,
|
||||
udata: nil
|
||||
)
|
||||
]
|
||||
let registrationError = registrations.withUnsafeMutableBufferPointer { buffer in
|
||||
registerEvents(descriptor, buffer.baseAddress, Int32(buffer.count))
|
||||
}
|
||||
if let registrationError {
|
||||
isFinished = true
|
||||
closeDescriptor(descriptor)
|
||||
throw POSIXError(registrationError)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancel()
|
||||
}
|
||||
|
||||
public func start() {
|
||||
stateLock.lock()
|
||||
guard !isStarted, !isCancelled, !isFinished else {
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
isStarted = true
|
||||
stateLock.unlock()
|
||||
eventQueue.async { [self] in
|
||||
waitForHangup()
|
||||
}
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
stateLock.lock()
|
||||
guard !isFinished else {
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
isCancelled = true
|
||||
guard isStarted else {
|
||||
isFinished = true
|
||||
closeDescriptor(eventQueueDescriptor)
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
var event = kevent(
|
||||
ident: Self.cancelEventIdentifier,
|
||||
filter: Int16(EVFILT_USER),
|
||||
flags: 0,
|
||||
fflags: UInt32(NOTE_TRIGGER),
|
||||
data: 0,
|
||||
udata: nil
|
||||
)
|
||||
_ = kevent(eventQueueDescriptor, &event, 1, nil, 0, nil)
|
||||
stateLock.unlock()
|
||||
}
|
||||
|
||||
private func waitForHangup() {
|
||||
defer { finish() }
|
||||
while true {
|
||||
var event = kevent()
|
||||
let result = kevent(eventQueueDescriptor, nil, 0, &event, 1, nil)
|
||||
if result < 0 && errno == EINTR {
|
||||
continue
|
||||
}
|
||||
guard result > 0 else { return }
|
||||
onEvent()
|
||||
if event.filter == Int16(EVFILT_USER) {
|
||||
return
|
||||
}
|
||||
if event.flags & UInt16(EV_EOF | EV_ERROR) != 0 {
|
||||
reportHangup()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
stateLock.lock()
|
||||
isFinished = true
|
||||
closeDescriptor(eventQueueDescriptor)
|
||||
stateLock.unlock()
|
||||
}
|
||||
|
||||
private func reportHangup() {
|
||||
stateLock.lock()
|
||||
guard !isCancelled, !didReportHangup else {
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
didReportHangup = true
|
||||
stateLock.unlock()
|
||||
|
||||
onHangup()
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import OrcaComputerUseMacOSCore
|
||||
import XCTest
|
||||
|
||||
final class AgentSessionOwnershipTests: XCTestCase {
|
||||
func testUnclaimedDisconnectDoesNotTerminateAgent() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertFalse(ownership.disconnect(connection(12)))
|
||||
}
|
||||
|
||||
func testUnauthenticatedConnectionCannotClaimOrRetainAgent() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: false), .rejected)
|
||||
XCTAssertFalse(ownership.disconnect(connection(12)))
|
||||
}
|
||||
|
||||
func testLastAuthenticatedDisconnectTerminatesAgent() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
|
||||
XCTAssertTrue(ownership.disconnect(connection(12)))
|
||||
}
|
||||
|
||||
func testAgentWaitsForEveryAuthenticatedConnectionToClose() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
|
||||
XCTAssertEqual(ownership.registerConnection(connection(13), authenticated: true), .retained)
|
||||
XCTAssertFalse(ownership.disconnect(connection(12)))
|
||||
XCTAssertTrue(ownership.disconnect(connection(13)))
|
||||
}
|
||||
|
||||
func testDuplicateRegistrationDoesNotRetainAgent() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
|
||||
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .rejected)
|
||||
XCTAssertTrue(ownership.disconnect(connection(12)))
|
||||
}
|
||||
|
||||
func testClosedSessionRejectsNewConnections() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
|
||||
XCTAssertTrue(ownership.disconnect(connection(12)))
|
||||
XCTAssertEqual(ownership.registerConnection(connection(13), authenticated: true), .rejected)
|
||||
XCTAssertFalse(ownership.disconnect(connection(13)))
|
||||
}
|
||||
|
||||
func testStaleDisconnectCannotRemoveReusedFileDescriptorOwner() {
|
||||
var ownership = AgentSessionOwnership()
|
||||
let staleConnection = connection(12)
|
||||
let otherConnection = connection(13)
|
||||
let reusedDescriptorConnection = connection(14)
|
||||
|
||||
XCTAssertEqual(ownership.registerConnection(staleConnection, authenticated: true), .claimed)
|
||||
XCTAssertEqual(ownership.registerConnection(otherConnection, authenticated: true), .retained)
|
||||
XCTAssertFalse(ownership.disconnect(staleConnection))
|
||||
XCTAssertEqual(
|
||||
ownership.registerConnection(reusedDescriptorConnection, authenticated: true),
|
||||
.retained
|
||||
)
|
||||
|
||||
XCTAssertFalse(ownership.disconnect(staleConnection))
|
||||
XCTAssertFalse(ownership.disconnect(otherConnection))
|
||||
XCTAssertTrue(ownership.disconnect(reusedDescriptorConnection))
|
||||
}
|
||||
|
||||
func testTokenlessSessionStillRequiresAuthorizedPeer() {
|
||||
XCTAssertFalse(isAuthenticatedAgentSession(
|
||||
expectedToken: nil,
|
||||
requestToken: nil,
|
||||
authorizedPeer: false
|
||||
))
|
||||
XCTAssertTrue(isAuthenticatedAgentSession(
|
||||
expectedToken: nil,
|
||||
requestToken: nil,
|
||||
authorizedPeer: true
|
||||
))
|
||||
}
|
||||
|
||||
func testTokenSessionRequiresAuthorizedPeerAndMatchingToken() {
|
||||
XCTAssertFalse(isAuthenticatedAgentSession(
|
||||
expectedToken: "expected",
|
||||
requestToken: "expected",
|
||||
authorizedPeer: false
|
||||
))
|
||||
XCTAssertFalse(isAuthenticatedAgentSession(
|
||||
expectedToken: "expected",
|
||||
requestToken: "wrong",
|
||||
authorizedPeer: true
|
||||
))
|
||||
XCTAssertTrue(isAuthenticatedAgentSession(
|
||||
expectedToken: "expected",
|
||||
requestToken: "expected",
|
||||
authorizedPeer: true
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func connection(_ rawValue: UInt64) -> AgentSessionConnectionID {
|
||||
AgentSessionConnectionID(rawValue: rawValue)
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import Darwin
|
||||
import Dispatch
|
||||
@testable import OrcaComputerUseMacOSCore
|
||||
import XCTest
|
||||
|
||||
final class AuthenticatedConnectionHangupMonitorTests: XCTestCase {
|
||||
func testRejectsInvalidDescriptorDuringSetup() {
|
||||
XCTAssertThrowsError(
|
||||
try AuthenticatedConnectionHangupMonitor(
|
||||
fileDescriptor: -1,
|
||||
onHangup: {}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testRegistrationFailureClosesEventQueueOnce() {
|
||||
let closes = DescriptorCloseRecorder()
|
||||
|
||||
XCTAssertThrowsError(
|
||||
try AuthenticatedConnectionHangupMonitor(
|
||||
fileDescriptor: 42,
|
||||
registerEvents: { _, _, _ in .EBADF },
|
||||
closeDescriptor: { descriptor in
|
||||
closes.recordAndClose(descriptor)
|
||||
},
|
||||
onHangup: {}
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(closes.count, 1)
|
||||
}
|
||||
|
||||
func testReportsPeerCloseWhileRequestHandlingIsBlocked() throws {
|
||||
let descriptors = try makeSocketPair()
|
||||
let hangup = expectation(description: "peer hangup")
|
||||
let processingStarted = expectation(description: "processing started")
|
||||
let releaseProcessing = DispatchSemaphore(value: 0)
|
||||
let processingFinished = DispatchSemaphore(value: 0)
|
||||
let monitor = try AuthenticatedConnectionHangupMonitor(
|
||||
fileDescriptor: descriptors.local,
|
||||
onHangup: {
|
||||
hangup.fulfill()
|
||||
}
|
||||
)
|
||||
monitor.start()
|
||||
|
||||
DispatchQueue.global().async {
|
||||
processingStarted.fulfill()
|
||||
releaseProcessing.wait()
|
||||
processingFinished.signal()
|
||||
}
|
||||
wait(for: [processingStarted], timeout: 1)
|
||||
close(descriptors.peer)
|
||||
|
||||
wait(for: [hangup], timeout: 1)
|
||||
XCTAssertEqual(processingFinished.wait(timeout: .now() + 0.05), .timedOut)
|
||||
|
||||
releaseProcessing.signal()
|
||||
XCTAssertEqual(processingFinished.wait(timeout: .now() + 1), .success)
|
||||
monitor.cancel()
|
||||
close(descriptors.local)
|
||||
}
|
||||
|
||||
func testReadableDataDoesNotLookLikeHangup() throws {
|
||||
let descriptors = try makeSocketPair()
|
||||
let hangup = expectation(description: "peer hangup")
|
||||
let callbacks = CallbackRecorder()
|
||||
let events = CallbackRecorder()
|
||||
let monitor = try AuthenticatedConnectionHangupMonitor(
|
||||
fileDescriptor: descriptors.local,
|
||||
onEvent: {
|
||||
events.record()
|
||||
},
|
||||
onHangup: {
|
||||
callbacks.record()
|
||||
hangup.fulfill()
|
||||
}
|
||||
)
|
||||
monitor.start()
|
||||
var byte: UInt8 = 7
|
||||
|
||||
XCTAssertEqual(write(descriptors.peer, &byte, 1), 1)
|
||||
usleep(50_000)
|
||||
XCTAssertEqual(callbacks.count, 0)
|
||||
XCTAssertEqual(events.count, 0)
|
||||
close(descriptors.peer)
|
||||
wait(for: [hangup], timeout: 1)
|
||||
XCTAssertEqual(events.count, 1)
|
||||
|
||||
monitor.cancel()
|
||||
close(descriptors.local)
|
||||
}
|
||||
|
||||
func testCancelPreventsLaterHangupCallback() throws {
|
||||
let descriptors = try makeSocketPair()
|
||||
let hangup = expectation(description: "peer hangup")
|
||||
hangup.isInverted = true
|
||||
let monitor = try AuthenticatedConnectionHangupMonitor(
|
||||
fileDescriptor: descriptors.local,
|
||||
onHangup: {
|
||||
hangup.fulfill()
|
||||
}
|
||||
)
|
||||
monitor.start()
|
||||
|
||||
monitor.cancel()
|
||||
close(descriptors.peer)
|
||||
|
||||
wait(for: [hangup], timeout: 0.1)
|
||||
close(descriptors.local)
|
||||
}
|
||||
|
||||
func testCancelReleasesMonitorWhileSocketStaysOpen() throws {
|
||||
let descriptors = try makeSocketPair()
|
||||
var monitor: AuthenticatedConnectionHangupMonitor? = try AuthenticatedConnectionHangupMonitor(
|
||||
fileDescriptor: descriptors.local,
|
||||
onHangup: {}
|
||||
)
|
||||
weak var retainedMonitor = monitor
|
||||
|
||||
monitor?.cancel()
|
||||
monitor = nil
|
||||
|
||||
let deadline = Date().addingTimeInterval(1)
|
||||
while retainedMonitor != nil, Date() < deadline {
|
||||
usleep(10_000)
|
||||
}
|
||||
XCTAssertNil(retainedMonitor)
|
||||
close(descriptors.peer)
|
||||
close(descriptors.local)
|
||||
}
|
||||
|
||||
func testStartReportsHangupQueuedAfterSuccessfulSetup() throws {
|
||||
let descriptors = try makeSocketPair()
|
||||
let hangup = expectation(description: "queued peer hangup")
|
||||
let monitor = try AuthenticatedConnectionHangupMonitor(
|
||||
fileDescriptor: descriptors.local,
|
||||
onHangup: {
|
||||
hangup.fulfill()
|
||||
}
|
||||
)
|
||||
|
||||
close(descriptors.peer)
|
||||
monitor.start()
|
||||
|
||||
wait(for: [hangup], timeout: 1)
|
||||
monitor.cancel()
|
||||
close(descriptors.local)
|
||||
}
|
||||
}
|
||||
|
||||
private final class CallbackRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var recordedCount = 0
|
||||
|
||||
var count: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return recordedCount
|
||||
}
|
||||
|
||||
func record() {
|
||||
lock.lock()
|
||||
recordedCount += 1
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private final class DescriptorCloseRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var closeCount = 0
|
||||
|
||||
var count: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return closeCount
|
||||
}
|
||||
|
||||
func recordAndClose(_ descriptor: Int32) {
|
||||
lock.lock()
|
||||
closeCount += 1
|
||||
let shouldClose = closeCount == 1
|
||||
lock.unlock()
|
||||
if shouldClose {
|
||||
close(descriptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSocketPair() throws -> (local: Int32, peer: Int32) {
|
||||
var descriptors: [Int32] = [0, 0]
|
||||
guard socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0 else {
|
||||
throw POSIXError(.EIO)
|
||||
}
|
||||
return (descriptors[0], descriptors[1])
|
||||
}
|
||||
@@ -105,8 +105,10 @@
|
||||
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
|
||||
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
|
||||
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
|
||||
"bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs",
|
||||
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
|
||||
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
|
||||
"bench:hang-watchdog-memory": "pnpm run ensure:electron-runtime && node config/scripts/hang-watchdog-memory-benchmark.mjs",
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:worktree-deletion": "node tools/benchmarks/worktree-deletion-dev-bench.mjs",
|
||||
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: written by the plain-Node watchdog child when main-thread heartbeats stop, and rewritten if
|
||||
// Why: written by the watchdog worker when main-thread heartbeats stop, and rewritten if
|
||||
// they resume; consumed on the next launch to report how long the stall lasted and whether it ever
|
||||
// cleared. `selfRecovered` separates a real deadlock from a long-but-survivable stall — the two are
|
||||
// indistinguishable at detection time, and only the latter would have been a destructive kill.
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop'
|
||||
import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop'
|
||||
|
||||
const TIMEOUT_MS = 45_000
|
||||
const CHECK_INTERVAL_MS = 5_000
|
||||
@@ -8,7 +8,7 @@ function loopWithClock(startAt = 0) {
|
||||
let now = startAt
|
||||
const onHangDetected = vi.fn()
|
||||
const onHangResolved = vi.fn()
|
||||
const loop = createHangWatchdogChildLoop({
|
||||
const loop = createHangWatchdogDetectionLoop({
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
checkIntervalMs: CHECK_INTERVAL_MS,
|
||||
now: () => now,
|
||||
@@ -18,7 +18,7 @@ function loopWithClock(startAt = 0) {
|
||||
return { loop, onHangDetected, onHangResolved, advance: (ms: number) => (now += ms) }
|
||||
}
|
||||
|
||||
describe('createHangWatchdogChildLoop', () => {
|
||||
describe('createHangWatchdogDetectionLoop', () => {
|
||||
it('does not fire while heartbeats keep arriving', () => {
|
||||
const { loop, onHangDetected, advance } = loopWithClock()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
export type HangWatchdogChildLoopConfig = {
|
||||
export type HangWatchdogDetectionLoopConfig = {
|
||||
timeoutMs: number
|
||||
checkIntervalMs: number
|
||||
now: () => number
|
||||
@@ -7,14 +7,14 @@ export type HangWatchdogChildLoopConfig = {
|
||||
onHangResolved: (unresponsiveMs: number) => void
|
||||
}
|
||||
|
||||
export type HangWatchdogChildLoop = {
|
||||
export type HangWatchdogDetectionLoop = {
|
||||
recordHeartbeat: () => void
|
||||
tick: () => void
|
||||
}
|
||||
|
||||
export function createHangWatchdogChildLoop(
|
||||
config: HangWatchdogChildLoopConfig
|
||||
): HangWatchdogChildLoop {
|
||||
export function createHangWatchdogDetectionLoop(
|
||||
config: HangWatchdogDetectionLoopConfig
|
||||
): HangWatchdogDetectionLoop {
|
||||
let lastHeartbeatAt = config.now()
|
||||
let lastTickAt = config.now()
|
||||
let detected = false
|
||||
@@ -1,17 +0,0 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export function resolveHangWatchdogEntryPath(
|
||||
appPath: string,
|
||||
isPackaged: boolean,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
// Why: ELECTRON_RUN_AS_NODE bypasses asar integration, so the packaged entry must be forked from app.asar.unpacked.
|
||||
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
|
||||
const adjacentBuildEntry = join(basePath, 'main-thread-hang-watchdog-entry.js')
|
||||
// Why: electron-vite's unpackaged appPath is already out/main; appending out/main again would silently disable the watchdog in dev and E2E builds.
|
||||
if (!isPackaged && pathExists(adjacentBuildEntry)) {
|
||||
return adjacentBuildEntry
|
||||
}
|
||||
return join(basePath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path'
|
||||
|
||||
describe('resolveHangWatchdogWorkerPath', () => {
|
||||
it('resolves packaged workers inside app.asar', () => {
|
||||
const appPath = join('/apps', 'orca', 'app.asar')
|
||||
expect(resolveHangWatchdogWorkerPath(appPath, true)).toBe(
|
||||
join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
)
|
||||
})
|
||||
|
||||
it('uses an adjacent entry when the dev app path is out/main', () => {
|
||||
const appPath = join('/repo', 'out', 'main')
|
||||
const adjacent = join(appPath, 'main-thread-hang-watchdog-entry.js')
|
||||
const pathExists = vi.fn((candidate: string) => candidate === adjacent)
|
||||
expect(resolveHangWatchdogWorkerPath(appPath, false, pathExists)).toBe(adjacent)
|
||||
})
|
||||
|
||||
it('resolves through out/main from a dev project root', () => {
|
||||
const appPath = join('/repo', 'orca')
|
||||
expect(resolveHangWatchdogWorkerPath(appPath, false, () => false)).toBe(
|
||||
join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export function resolveHangWatchdogWorkerPath(
|
||||
appPath: string,
|
||||
isPackaged: boolean,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
const adjacentBuildEntry = join(appPath, 'main-thread-hang-watchdog-entry.js')
|
||||
if (!isPackaged && pathExists(adjacentBuildEntry)) {
|
||||
return adjacentBuildEntry
|
||||
}
|
||||
return join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS = 2_000
|
||||
export const HANG_WATCHDOG_TIMEOUT_MS = 45_000
|
||||
export const HANG_WATCHDOG_CHECK_INTERVAL_MS = 5_000
|
||||
|
||||
export type HangWatchdogWorkerData = {
|
||||
parentPid: number
|
||||
markerPath: string
|
||||
timeoutMs: number
|
||||
checkIntervalMs: number
|
||||
}
|
||||
|
||||
export type MainToHangWatchdogWorkerMessage = { type: 'heartbeat' } | { type: 'shutdown' }
|
||||
@@ -12,7 +12,11 @@ vi.mock('node:child_process', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
import { recordHangObservation } from './main-thread-hang-watchdog-entry'
|
||||
import {
|
||||
isHangWatchdogWorkerData,
|
||||
recordHangObservation,
|
||||
runWatchdog
|
||||
} from './main-thread-hang-watchdog-entry'
|
||||
|
||||
describe('recordHangObservation', () => {
|
||||
let dir: string
|
||||
@@ -67,10 +71,8 @@ describe('recordHangObservation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Why: this is the safety contract of the whole PR. Observing a hang must never kill, relaunch,
|
||||
// or exit — a false positive would SIGKILL a live main thread mid-write. If a future change
|
||||
// reintroduces recovery, this test must fail loudly rather than ship silently.
|
||||
it('never spawns, signals, or exits', () => {
|
||||
// Why: a false positive must never kill a live main thread while writes may be in flight.
|
||||
recordHangObservation({
|
||||
parentPid: 4242,
|
||||
markerPath: join(dir, 'marker.json'),
|
||||
@@ -104,3 +106,75 @@ describe('recordHangObservation', () => {
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('watchdog worker entry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('accepts only complete positive worker data', () => {
|
||||
expect(
|
||||
isHangWatchdogWorkerData({
|
||||
parentPid: 42,
|
||||
markerPath: '/tmp/marker',
|
||||
timeoutMs: 100,
|
||||
checkIntervalMs: 25
|
||||
})
|
||||
).toBe(true)
|
||||
for (const value of [
|
||||
null,
|
||||
{},
|
||||
{ parentPid: 0, markerPath: '/tmp/marker', timeoutMs: 100, checkIntervalMs: 25 },
|
||||
{ parentPid: 42, markerPath: 7, timeoutMs: 100, checkIntervalMs: 25 },
|
||||
{ parentPid: 42, markerPath: '/tmp/marker', timeoutMs: 0, checkIntervalMs: 25 },
|
||||
{ parentPid: 42, markerPath: '/tmp/marker', timeoutMs: 100, checkIntervalMs: Infinity }
|
||||
]) {
|
||||
expect(isHangWatchdogWorkerData(value)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('routes heartbeats and shuts down its timer and port', () => {
|
||||
const markerPath = join(tmpdir(), `hang-watchdog-entry-${process.pid}.json`)
|
||||
let onMessage: ((message: { type: 'heartbeat' | 'shutdown' }) => void) | undefined
|
||||
const port = {
|
||||
on: vi.fn(
|
||||
(_event: 'message', listener: (message: { type: 'heartbeat' | 'shutdown' }) => void) => {
|
||||
onMessage = listener
|
||||
}
|
||||
),
|
||||
close: vi.fn()
|
||||
}
|
||||
try {
|
||||
runWatchdog(
|
||||
{
|
||||
parentPid: process.pid,
|
||||
markerPath,
|
||||
timeoutMs: 100,
|
||||
checkIntervalMs: 25
|
||||
},
|
||||
port
|
||||
)
|
||||
vi.advanceTimersByTime(75)
|
||||
onMessage?.({ type: 'heartbeat' })
|
||||
vi.advanceTimersByTime(75)
|
||||
expect(consumeHangDetectionMarker(markerPath)).toBeNull()
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(consumeHangDetectionMarker(markerPath)).toMatchObject({ selfRecovered: false })
|
||||
|
||||
onMessage?.({ type: 'heartbeat' })
|
||||
expect(consumeHangDetectionMarker(markerPath)).toMatchObject({ selfRecovered: true })
|
||||
onMessage?.({ type: 'shutdown' })
|
||||
expect(port.close).toHaveBeenCalledOnce()
|
||||
vi.advanceTimersByTime(1_000)
|
||||
expect(port.close).toHaveBeenCalledOnce()
|
||||
expect(consumeHangDetectionMarker(markerPath)).toBeNull()
|
||||
} finally {
|
||||
rmSync(markerPath, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
// Forked with ELECTRON_RUN_AS_NODE from the main process. Watches heartbeats from the main
|
||||
// thread; if they stop (e.g. the macOS 26 AppKit scene-update deadlock) it records a marker so
|
||||
// the next launch can report the stall, and rewrites it if the heartbeats come back.
|
||||
//
|
||||
// Why no kill: a deadlocked main thread has already lost whatever sat in the persistence debounce
|
||||
// window, so killing it recovers nothing the user could not recover by force-quitting. A false
|
||||
// positive, though, would SIGKILL a live main thread that was merely blocked — most plausibly on
|
||||
// I/O, i.e. exactly when writes are in flight. All of the downside sits in the misfire, so this
|
||||
// measures first: `selfRecovered` counts the stalls a killer would have gotten wrong.
|
||||
//
|
||||
// Must never import electron.
|
||||
import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop'
|
||||
import { isMainThread, parentPort, workerData } from 'node:worker_threads'
|
||||
import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop'
|
||||
import { writeHangDetectionMarker } from './hang-detection-marker'
|
||||
import type {
|
||||
HangWatchdogWorkerData,
|
||||
MainToHangWatchdogWorkerMessage
|
||||
} from './hang-watchdog-worker-protocol'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 45_000
|
||||
const DEFAULT_CHECK_INTERVAL_MS = 5_000
|
||||
type HangWatchdogPort = {
|
||||
on: (event: 'message', listener: (message: MainToHangWatchdogWorkerMessage) => void) => unknown
|
||||
close: () => void
|
||||
}
|
||||
|
||||
// Observation only: a false positive must never kill a live main thread mid-write.
|
||||
export function recordHangObservation(options: {
|
||||
parentPid: number
|
||||
markerPath: string
|
||||
@@ -36,37 +33,65 @@ export function recordHangObservation(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function runWatchdog(parentPid: number): void {
|
||||
const markerPath = process.env.ORCA_HANG_WATCHDOG_MARKER_PATH ?? ''
|
||||
const timeoutMs = Number(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS
|
||||
const checkIntervalMs =
|
||||
Number(process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS) || DEFAULT_CHECK_INTERVAL_MS
|
||||
|
||||
const loop = createHangWatchdogChildLoop({
|
||||
timeoutMs,
|
||||
checkIntervalMs,
|
||||
export function runWatchdog(
|
||||
config: HangWatchdogWorkerData,
|
||||
port: HangWatchdogPort | null = parentPort
|
||||
): void {
|
||||
if (!port) {
|
||||
return
|
||||
}
|
||||
const loop = createHangWatchdogDetectionLoop({
|
||||
timeoutMs: config.timeoutMs,
|
||||
checkIntervalMs: config.checkIntervalMs,
|
||||
now: () => Date.now(),
|
||||
onHangDetected: (unresponsiveMs) =>
|
||||
recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: false }),
|
||||
recordHangObservation({
|
||||
parentPid: config.parentPid,
|
||||
markerPath: config.markerPath,
|
||||
unresponsiveMs,
|
||||
selfRecovered: false
|
||||
}),
|
||||
// Why: rewriting the marker keeps one observation per stall rather than two rows to reconcile.
|
||||
onHangResolved: (unresponsiveMs) =>
|
||||
recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: true })
|
||||
recordHangObservation({
|
||||
parentPid: config.parentPid,
|
||||
markerPath: config.markerPath,
|
||||
unresponsiveMs,
|
||||
selfRecovered: true
|
||||
})
|
||||
})
|
||||
|
||||
process.on('message', (message) => {
|
||||
const type = (message as { type?: string } | null)?.type
|
||||
if (type === 'heartbeat') {
|
||||
let checkTimer: ReturnType<typeof setInterval> | null = setInterval(
|
||||
() => loop.tick(),
|
||||
config.checkIntervalMs
|
||||
)
|
||||
port.on('message', (message: MainToHangWatchdogWorkerMessage) => {
|
||||
if (message.type === 'heartbeat') {
|
||||
loop.recordHeartbeat()
|
||||
} else if (type === 'shutdown') {
|
||||
process.exit(0)
|
||||
} else if (message.type === 'shutdown') {
|
||||
if (checkTimer) {
|
||||
clearInterval(checkTimer)
|
||||
checkTimer = null
|
||||
}
|
||||
port.close()
|
||||
}
|
||||
})
|
||||
// Why: a normal parent exit closes the IPC channel; the watchdog must not outlive it and misfire.
|
||||
process.on('disconnect', () => process.exit(0))
|
||||
setInterval(() => loop.tick(), checkIntervalMs)
|
||||
}
|
||||
|
||||
const configuredParentPid = Number(process.env.ORCA_HANG_WATCHDOG_PARENT_PID)
|
||||
if (Number.isInteger(configuredParentPid) && configuredParentPid > 0) {
|
||||
runWatchdog(configuredParentPid)
|
||||
export function isHangWatchdogWorkerData(value: unknown): value is HangWatchdogWorkerData {
|
||||
const data = value as Partial<HangWatchdogWorkerData> | null
|
||||
return (
|
||||
!!data &&
|
||||
Number.isInteger(data.parentPid) &&
|
||||
(data.parentPid ?? 0) > 0 &&
|
||||
typeof data.markerPath === 'string' &&
|
||||
Number.isFinite(data.timeoutMs) &&
|
||||
(data.timeoutMs ?? 0) > 0 &&
|
||||
Number.isFinite(data.checkIntervalMs) &&
|
||||
(data.checkIntervalMs ?? 0) > 0
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMainThread && isHangWatchdogWorkerData(workerData)) {
|
||||
runWatchdog(workerData)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const { forkMock, appMock } = vi.hoisted(() => ({
|
||||
forkMock: vi.fn(),
|
||||
const { workerState, appMock } = vi.hoisted(() => ({
|
||||
workerState: {
|
||||
calls: [] as unknown[][],
|
||||
instance: null as object | null,
|
||||
error: null as Error | null
|
||||
},
|
||||
appMock: {
|
||||
isPackaged: true,
|
||||
getAppPath: vi.fn(() => '/apps/orca/app.asar'),
|
||||
@@ -9,8 +14,16 @@ const { forkMock, appMock } = vi.hoisted(() => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
fork: forkMock
|
||||
vi.mock('node:worker_threads', () => ({
|
||||
Worker: class WorkerMock {
|
||||
constructor(...args: unknown[]) {
|
||||
workerState.calls.push(args)
|
||||
if (workerState.error) {
|
||||
throw workerState.error
|
||||
}
|
||||
return workerState.instance as WorkerMock
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -29,28 +42,33 @@ function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
|
||||
}
|
||||
}
|
||||
|
||||
function fakeChild() {
|
||||
function fakeWorker() {
|
||||
return {
|
||||
connected: true,
|
||||
stderr: { on: vi.fn() },
|
||||
postMessage: vi.fn(),
|
||||
unref: vi.fn(),
|
||||
on: vi.fn(),
|
||||
send: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
kill: vi.fn()
|
||||
once: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('installMainThreadHangWatchdog', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
forkMock.mockReset()
|
||||
workerState.calls = []
|
||||
workerState.instance = null
|
||||
workerState.error = null
|
||||
appMock.on.mockReset()
|
||||
appMock.isPackaged = true
|
||||
delete process.env.ORCA_HANG_WATCHDOG_FORCE
|
||||
delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS
|
||||
delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
delete process.env.ORCA_HANG_WATCHDOG_FORCE
|
||||
delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS
|
||||
delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS
|
||||
})
|
||||
|
||||
it('is a no-op off macOS', () => {
|
||||
@@ -60,7 +78,7 @@ describe('installMainThreadHangWatchdog', () => {
|
||||
expect(
|
||||
withPlatform('linux', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
).toBeNull()
|
||||
expect(forkMock).not.toHaveBeenCalled()
|
||||
expect(workerState.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is a no-op in unpackaged builds unless forced', () => {
|
||||
@@ -69,62 +87,98 @@ describe('installMainThreadHangWatchdog', () => {
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
).toBeNull()
|
||||
process.env.ORCA_HANG_WATCHDOG_FORCE = '1'
|
||||
const child = fakeChild()
|
||||
forkMock.mockReturnValue(child)
|
||||
const worker = fakeWorker()
|
||||
workerState.instance = worker
|
||||
expect(
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
).not.toBeNull()
|
||||
delete process.env.ORCA_HANG_WATCHDOG_FORCE
|
||||
})
|
||||
|
||||
it('forks the watchdog as plain Node with pid, bundle, and marker config', () => {
|
||||
const child = fakeChild()
|
||||
forkMock.mockReturnValue(child)
|
||||
it('starts a worker with pid, marker, and timing config', () => {
|
||||
const worker = fakeWorker()
|
||||
workerState.instance = worker
|
||||
const handle = withPlatform('darwin', () =>
|
||||
installMainThreadHangWatchdog({ userDataPath: '/ud' })
|
||||
)
|
||||
expect(handle).not.toBeNull()
|
||||
const [, , options] = forkMock.mock.calls[0]
|
||||
expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1')
|
||||
expect(options.env.ORCA_HANG_WATCHDOG_PARENT_PID).toBe(String(process.pid))
|
||||
expect(options.env.ORCA_HANG_WATCHDOG_MARKER_PATH).toContain('/ud')
|
||||
const [workerPath, rawOptions] = workerState.calls[0]
|
||||
const options = rawOptions as {
|
||||
name: string
|
||||
workerData: {
|
||||
parentPid: number
|
||||
markerPath: string
|
||||
timeoutMs: number
|
||||
checkIntervalMs: number
|
||||
}
|
||||
}
|
||||
expect(workerPath).toBe(
|
||||
join('/apps/orca/app.asar', 'out', 'main', 'main-thread-hang-watchdog-entry.js')
|
||||
)
|
||||
expect(options.name).toBe('orca-main-thread-hang-watchdog')
|
||||
expect(options.workerData).toMatchObject({
|
||||
parentPid: process.pid,
|
||||
markerPath: join('/ud', 'main-thread-hang.json'),
|
||||
timeoutMs: 45_000,
|
||||
checkIntervalMs: 5_000
|
||||
})
|
||||
expect(worker.unref).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends heartbeats on an interval and shutdown+disconnect on stop', () => {
|
||||
const child = fakeChild()
|
||||
forkMock.mockReturnValue(child)
|
||||
it('sends heartbeats on an interval and shutdown on stop', () => {
|
||||
const worker = fakeWorker()
|
||||
workerState.instance = worker
|
||||
const handle = withPlatform('darwin', () =>
|
||||
installMainThreadHangWatchdog({ userDataPath: '/ud' })
|
||||
)
|
||||
vi.advanceTimersByTime(6_000)
|
||||
const heartbeats = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat')
|
||||
const heartbeats = worker.postMessage.mock.calls.filter(([m]) => m.type === 'heartbeat')
|
||||
expect(heartbeats.length).toBe(3)
|
||||
|
||||
handle?.stop()
|
||||
expect(child.send.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true)
|
||||
expect(child.disconnect).toHaveBeenCalled()
|
||||
expect(worker.postMessage.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true)
|
||||
|
||||
// Why: quit fires will-quit twice; a second stop must not resend or throw.
|
||||
handle?.stop()
|
||||
const shutdowns = child.send.mock.calls.filter(([m]) => m.type === 'shutdown')
|
||||
const shutdowns = worker.postMessage.mock.calls.filter(([m]) => m.type === 'shutdown')
|
||||
expect(shutdowns.length).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(10_000)
|
||||
const heartbeatsAfterStop = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat')
|
||||
const heartbeatsAfterStop = worker.postMessage.mock.calls.filter(
|
||||
([m]) => m.type === 'heartbeat'
|
||||
)
|
||||
expect(heartbeatsAfterStop.length).toBe(3)
|
||||
})
|
||||
|
||||
it('passes test timing overrides to the worker', () => {
|
||||
process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS = '900'
|
||||
process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS = '100'
|
||||
workerState.instance = fakeWorker()
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
const options = workerState.calls[0][1] as {
|
||||
workerData: { timeoutMs: number; checkIntervalMs: number }
|
||||
}
|
||||
expect(options.workerData).toMatchObject({ timeoutMs: 900, checkIntervalMs: 100 })
|
||||
})
|
||||
|
||||
it('registers stop on will-quit', () => {
|
||||
const child = fakeChild()
|
||||
forkMock.mockReturnValue(child)
|
||||
workerState.instance = fakeWorker()
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
expect(appMock.on).toHaveBeenCalledWith('will-quit', expect.any(Function))
|
||||
})
|
||||
|
||||
it('returns null and stays inert when the fork itself fails', () => {
|
||||
forkMock.mockImplementation(() => {
|
||||
throw new Error('spawn failure')
|
||||
})
|
||||
it('stops heartbeat work when the worker exits', () => {
|
||||
const worker = fakeWorker()
|
||||
workerState.instance = worker
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
const exitListener = worker.once.mock.calls.find(([event]) => event === 'exit')?.[1]
|
||||
expect(exitListener).toEqual(expect.any(Function))
|
||||
exitListener()
|
||||
vi.advanceTimersByTime(6_000)
|
||||
expect(worker.postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null and stays inert when worker construction fails', () => {
|
||||
workerState.error = new Error('worker failure')
|
||||
expect(
|
||||
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
|
||||
).toBeNull()
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { app } from 'electron'
|
||||
import { resolveHangWatchdogEntryPath } from './hang-watchdog-entry-path'
|
||||
import { hangDetectionMarkerPath } from './hang-detection-marker'
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 2_000
|
||||
import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path'
|
||||
import {
|
||||
HANG_WATCHDOG_CHECK_INTERVAL_MS,
|
||||
HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS,
|
||||
HANG_WATCHDOG_TIMEOUT_MS,
|
||||
type HangWatchdogWorkerData,
|
||||
type MainToHangWatchdogWorkerMessage
|
||||
} from './hang-watchdog-worker-protocol'
|
||||
|
||||
export type MainThreadHangWatchdogHandle = {
|
||||
stop: () => void
|
||||
child: ChildProcess
|
||||
worker: Worker
|
||||
}
|
||||
|
||||
function positiveTiming(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
// Why: macOS 26 scene-backed AppKit windows can deadlock the main thread inside
|
||||
// FrontBoardServices with no crash and no event loop left to self-report. A plain-Node sibling
|
||||
// process watches heartbeats and records the stall so the next launch can report it. It observes
|
||||
// only — see main-thread-hang-watchdog-entry.ts for why it does not kill the parent.
|
||||
export function installMainThreadHangWatchdog(options: {
|
||||
userDataPath: string
|
||||
}): MainThreadHangWatchdogHandle | null {
|
||||
@@ -24,58 +30,57 @@ export function installMainThreadHangWatchdog(options: {
|
||||
if (!app.isPackaged && process.env.ORCA_HANG_WATCHDOG_FORCE !== '1') {
|
||||
return null
|
||||
}
|
||||
const entryPath = resolveHangWatchdogEntryPath(app.getAppPath(), app.isPackaged)
|
||||
let child: ChildProcess
|
||||
const workerPath = resolveHangWatchdogWorkerPath(app.getAppPath(), app.isPackaged)
|
||||
const workerData: HangWatchdogWorkerData = {
|
||||
parentPid: process.pid,
|
||||
markerPath: hangDetectionMarkerPath(options.userDataPath),
|
||||
timeoutMs: positiveTiming(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS, HANG_WATCHDOG_TIMEOUT_MS),
|
||||
checkIntervalMs: positiveTiming(
|
||||
process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS,
|
||||
HANG_WATCHDOG_CHECK_INTERVAL_MS
|
||||
)
|
||||
}
|
||||
let worker: Worker
|
||||
try {
|
||||
child = fork(entryPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid),
|
||||
ORCA_HANG_WATCHDOG_MARKER_PATH: hangDetectionMarkerPath(options.userDataPath)
|
||||
}
|
||||
// Why: the worker survives an AppKit main-thread deadlock without another Electron process.
|
||||
worker = new Worker(workerPath, {
|
||||
name: 'orca-main-thread-hang-watchdog',
|
||||
workerData
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[hang-watchdog] failed to fork watchdog process:', error)
|
||||
console.error('[hang-watchdog] failed to start watchdog worker:', error)
|
||||
return null
|
||||
}
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
console.error('[hang-watchdog]', String(chunk).trimEnd())
|
||||
worker.on('error', (error) => {
|
||||
console.error('[hang-watchdog] watchdog worker failed:', error)
|
||||
})
|
||||
// Why: the watchdog is a safety net — if it dies, run without it; a restart loop here must never affect the app.
|
||||
child.on('error', () => {})
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
if (child.connected) {
|
||||
try {
|
||||
child.send({ type: 'heartbeat' }, () => {})
|
||||
} catch {
|
||||
// Channel raced closed between the check and the send.
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
let stopped = false
|
||||
const postMessage = (message: MainToHangWatchdogWorkerMessage): void => {
|
||||
if (stopped && message.type === 'heartbeat') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
worker.postMessage(message)
|
||||
} catch {
|
||||
// The worker already exited.
|
||||
}
|
||||
}
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
postMessage({ type: 'heartbeat' })
|
||||
}, HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS)
|
||||
const stop = (): void => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
stopped = true
|
||||
clearInterval(heartbeatTimer)
|
||||
if (child.connected) {
|
||||
try {
|
||||
child.send({ type: 'shutdown' }, () => {})
|
||||
} catch {
|
||||
// Already disconnecting.
|
||||
}
|
||||
}
|
||||
// Why: disconnect guarantees the child observes parent shutdown and exits instead of misreading quit as a hang.
|
||||
try {
|
||||
child.disconnect()
|
||||
} catch {
|
||||
// Channel already closed.
|
||||
}
|
||||
postMessage({ type: 'shutdown' })
|
||||
}
|
||||
// Why: will-quit fires twice during quit; stop is idempotent.
|
||||
worker.once('exit', () => {
|
||||
stopped = true
|
||||
clearInterval(heartbeatTimer)
|
||||
})
|
||||
worker.unref()
|
||||
app.on('will-quit', stop)
|
||||
return { stop, child }
|
||||
return { stop, worker }
|
||||
}
|
||||
|
||||
@@ -25026,6 +25026,117 @@ describe('OrcaRuntimeService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a live headed runtime-owned tab until its explicit close', async () => {
|
||||
const ptyId = 'local-runtime-owned-pty'
|
||||
const splitPtyId = 'local-runtime-owned-split-pty'
|
||||
const tabId = 'runtime-session-tab'
|
||||
const leafId = HEADLESS_LEAF_ID
|
||||
const kill = vi.fn(() => true)
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: TEST_REPO_ID,
|
||||
activeWorktreeId: TEST_WORKTREE_ID,
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: tabId,
|
||||
ptyId: null,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Codex',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
launchAgent: 'codex'
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: makeHeadlessTerminalLayout({ [leafId]: undefined })
|
||||
}
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValueOnce({ id: ptyId }).mockResolvedValueOnce({ id: splitPtyId }),
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{
|
||||
id: ptyId,
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
title: 'Codex',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
},
|
||||
{
|
||||
id: splitPtyId,
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
title: 'Codex',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
const publishRendererOmission = (snapshotVersion: number): void => {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'headed-runtime',
|
||||
snapshotVersion,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
runtime.attachWindow(1)
|
||||
publishRendererOmission(1)
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
})
|
||||
|
||||
const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
presentation: 'background',
|
||||
persistHostSessionBinding: true,
|
||||
tabId,
|
||||
leafId,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
const split = await runtime.splitTerminal(created.handle, { direction: 'vertical' })
|
||||
publishRendererOmission(2)
|
||||
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: `${tabId}::${leafId}`,
|
||||
parentTabId: tabId,
|
||||
ptyId,
|
||||
status: 'ready',
|
||||
terminal: created.handle
|
||||
}),
|
||||
expect.objectContaining({
|
||||
parentTabId: tabId,
|
||||
ptyId: splitPtyId,
|
||||
status: 'ready',
|
||||
terminal: split.handle
|
||||
})
|
||||
])
|
||||
)
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toHaveLength(2)
|
||||
|
||||
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, tabId, { reason: 'user' })
|
||||
publishRendererOmission(3)
|
||||
|
||||
expect(kill).toHaveBeenCalledWith(ptyId)
|
||||
expect(kill).toHaveBeenCalledWith(splitPtyId)
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('publishes laptop-created remote runtime terminals to phone session tabs', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
@@ -29289,7 +29400,7 @@ describe('OrcaRuntimeService', () => {
|
||||
{
|
||||
tabId: 'tab-renderer',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:1',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-renderer',
|
||||
paneTitle: null
|
||||
@@ -29306,10 +29417,12 @@ describe('OrcaRuntimeService', () => {
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-renderer::pane:1',
|
||||
id: `tab-renderer::${HEADLESS_LEAF_ID}`,
|
||||
parentTabId: 'tab-renderer',
|
||||
leafId: 'pane:1',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
ptyId: 'pty-renderer',
|
||||
title: 'Terminal',
|
||||
viewMode: 'chat',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
@@ -29350,6 +29463,86 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
expect(focusTerminal).not.toHaveBeenCalled()
|
||||
expect(result.tab).toMatchObject({ parentTabId: 'tab-renderer', isActive: false })
|
||||
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-renderer',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-renderer',
|
||||
paneTitle: null
|
||||
}
|
||||
],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([
|
||||
expect.objectContaining({ parentTabId: 'tab-renderer', ptyId: 'pty-renderer' })
|
||||
])
|
||||
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-renderer',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: HEADLESS_SECOND_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-renderer',
|
||||
paneTitle: null
|
||||
}
|
||||
],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 3,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-renderer',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: HEADLESS_SECOND_LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-renderer',
|
||||
paneTitle: null
|
||||
}
|
||||
],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 4,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes concurrent mobile terminal creates that share a clientMutationId', async () => {
|
||||
|
||||
@@ -1216,6 +1216,7 @@ type RuntimePtyWorktreeRecord = {
|
||||
incarnationId: PtyIncarnationId | null
|
||||
worktreeId: string
|
||||
connectionId: string | null
|
||||
runtimeSessionOwned: boolean
|
||||
// Why: a Windows host can own both native and WSL panes; preamble command
|
||||
// selection must follow the pane that executes it, not process.platform.
|
||||
isWsl: boolean | null
|
||||
@@ -5982,6 +5983,35 @@ export class OrcaRuntimeService {
|
||||
return boundPtyIds.some((ptyId) => persistedPtyIds.has(ptyId))
|
||||
}
|
||||
|
||||
private hasLiveRuntimeSessionOwnedPtyBinding(
|
||||
worktreeId: string,
|
||||
tab: RuntimeMobileSessionTerminalTab
|
||||
): boolean {
|
||||
const pty = this.findPtyForMobileTerminalTab(worktreeId, tab)
|
||||
return pty?.connected === true && pty.runtimeSessionOwned
|
||||
}
|
||||
|
||||
private clearRuntimeSessionOwnershipForMobileTab(
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
parentTabId: string
|
||||
): void {
|
||||
for (const tab of snapshot.tabs) {
|
||||
if (tab.type !== 'terminal' || tab.parentTabId !== parentTabId) {
|
||||
continue
|
||||
}
|
||||
const ptyIds = [tab.ptyId, ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {})].filter(
|
||||
(ptyId): ptyId is string => typeof ptyId === 'string'
|
||||
)
|
||||
for (const ptyId of ptyIds) {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty?.worktreeId === worktreeId && pty.tabId === parentTabId) {
|
||||
pty.runtimeSessionOwned = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a tab needs authoritative runtime teardown (kill + de-persist + prune)
|
||||
// only when the renderer can't durably tear it down: either it's serve/SSH
|
||||
// (preserved + re-hydrated, would resurrect) or the renderer graph never
|
||||
@@ -7440,6 +7470,7 @@ export class OrcaRuntimeService {
|
||||
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
|
||||
this.store?.flushOrThrow?.()
|
||||
}
|
||||
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot!, tab.parentTabId)
|
||||
return { closed: true }
|
||||
}
|
||||
// Why: notifier implementations without the acknowledged relay may expose
|
||||
@@ -7467,6 +7498,7 @@ export class OrcaRuntimeService {
|
||||
// parent tab id. Closing that parent should close the desktop tab, not
|
||||
// just whichever leaf happened to be first in the session snapshot.
|
||||
this.notifier?.closeTerminal(tab.parentTabId)
|
||||
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot!, tab.parentTabId)
|
||||
}
|
||||
} else if (tab.type === 'browser' && this.offscreenBrowserBackend) {
|
||||
// Why: headless browser tabs are offscreen WebContents with no renderer to
|
||||
@@ -7611,6 +7643,7 @@ export class OrcaRuntimeService {
|
||||
options: { killPtys?: boolean } = {}
|
||||
): void {
|
||||
const closedParentTabId = tab.parentTabId
|
||||
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId)
|
||||
const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId)
|
||||
// Why: local provider ids can be reused after restart, so a dormant
|
||||
// persisted id is not kill authority. SSH relay ids remain durable exact
|
||||
@@ -8782,6 +8815,9 @@ export class OrcaRuntimeService {
|
||||
this.recordPtyWorktree(ptyId, worktreeId, {
|
||||
connected: true,
|
||||
connectionId,
|
||||
...(binding && this.pendingMobileTerminalCreatesByKey.has(`${worktreeId}::${binding.tabId}`)
|
||||
? { runtimeSessionOwned: true }
|
||||
: {}),
|
||||
...(isWsl !== undefined ? { isWsl } : {}),
|
||||
...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}),
|
||||
...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {})
|
||||
@@ -12806,6 +12842,7 @@ export class OrcaRuntimeService {
|
||||
this.agentDetector?.onExit(ptyId)
|
||||
if (pty) {
|
||||
pty.connected = false
|
||||
pty.runtimeSessionOwned = false
|
||||
pty.disconnectedAt = Date.now()
|
||||
pty.lastExitCode = exitCode
|
||||
this.resolvePtyExitWaiters(pty, ptyId)
|
||||
@@ -24193,6 +24230,9 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||||
if (pty) {
|
||||
if (launchOpts.persistHostSessionBinding) {
|
||||
pty.runtimeSessionOwned = true
|
||||
}
|
||||
if (launchOpts.title) {
|
||||
const observedAt = this.nextTitleObservationSequence()
|
||||
pty.title = launchOpts.title
|
||||
@@ -25101,6 +25141,10 @@ export class OrcaRuntimeService {
|
||||
return null
|
||||
}
|
||||
const existing = this.findMobileTerminalSurface(worktreeId, tabId)
|
||||
const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId)
|
||||
if (pty) {
|
||||
pty.runtimeSessionOwned = true
|
||||
}
|
||||
if (
|
||||
existing &&
|
||||
this.isReadyMobileTerminalSurface(existing) &&
|
||||
@@ -25109,7 +25153,6 @@ export class OrcaRuntimeService {
|
||||
// Why: the renderer's ready publication already landed with the intended mode; only a pending shell needs the main-side rescue.
|
||||
return existing
|
||||
}
|
||||
const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId)
|
||||
const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined
|
||||
if (!pty || !leafId) {
|
||||
return existing
|
||||
@@ -25554,6 +25597,7 @@ export class OrcaRuntimeService {
|
||||
if (createdPty) {
|
||||
createdPty.tabId = parentTabId
|
||||
createdPty.paneKey = paneKey
|
||||
createdPty.runtimeSessionOwned = pty.runtimeSessionOwned
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -27428,6 +27472,7 @@ export class OrcaRuntimeService {
|
||||
| 'paneKey'
|
||||
| 'title'
|
||||
| 'connectionId'
|
||||
| 'runtimeSessionOwned'
|
||||
| 'isWsl'
|
||||
| 'wslDistro'
|
||||
| 'incarnationId'
|
||||
@@ -27452,6 +27497,7 @@ export class OrcaRuntimeService {
|
||||
incarnationId: state.incarnationId ?? null,
|
||||
worktreeId,
|
||||
connectionId,
|
||||
runtimeSessionOwned: state.runtimeSessionOwned ?? false,
|
||||
isWsl: state.isWsl ?? null,
|
||||
wslDistro,
|
||||
tabId: state.tabId ?? null,
|
||||
@@ -27509,6 +27555,9 @@ export class OrcaRuntimeService {
|
||||
this.wslDistroByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
if (state.runtimeSessionOwned !== undefined) {
|
||||
pty.runtimeSessionOwned = state.runtimeSessionOwned
|
||||
}
|
||||
if (state.isWsl !== undefined) {
|
||||
pty.isWsl = state.isWsl
|
||||
}
|
||||
@@ -28303,6 +28352,7 @@ export class OrcaRuntimeService {
|
||||
// are runtime-owned and preservable.
|
||||
return (
|
||||
this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) ||
|
||||
this.hasLiveRuntimeSessionOwnedPtyBinding(snapshot.worktree, tab) ||
|
||||
this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(snapshot.worktree, tab)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -445,6 +445,7 @@ function App(): React.JSX.Element {
|
||||
toggleSidebar: s.toggleSidebar,
|
||||
fetchRepos: s.fetchRepos,
|
||||
fetchReposForAllHosts: s.fetchReposForAllHosts,
|
||||
awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement,
|
||||
fetchProjectGroups: s.fetchProjectGroups,
|
||||
fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts,
|
||||
fetchFolderWorkspaces: s.fetchFolderWorkspaces,
|
||||
@@ -899,6 +900,9 @@ function App(): React.JSX.Element {
|
||||
await timeRendererStartupStep('fetch-repos-local', () =>
|
||||
actions.fetchReposForAllHosts({ remoteHosts: 'skip' })
|
||||
)
|
||||
await timeRendererStartupStep('repo-catalog-settlement', () =>
|
||||
actions.awaitLocalRepoCatalogSettlement()
|
||||
)
|
||||
// Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts),
|
||||
// so keep this chain ordered while overlapping it with session-scoped hydration.
|
||||
const localCatalogChain = (async () => {
|
||||
@@ -951,6 +955,9 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
const sessionRead = sessionOutcome.value
|
||||
await keybindingsPromise
|
||||
await timeRendererStartupStep('repo-catalog-final-settlement', () =>
|
||||
actions.awaitLocalRepoCatalogSettlement()
|
||||
)
|
||||
if (!cancelled) {
|
||||
const sessionHydrationOptions = {
|
||||
additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session)
|
||||
|
||||
@@ -20,6 +20,12 @@ describe('renderer startup runtime routing', () => {
|
||||
const localReposIndex = indexInStartupBlock(
|
||||
"actions.fetchReposForAllHosts({ remoteHosts: 'skip' })"
|
||||
)
|
||||
const repoCatalogSettlementIndex = indexInStartupBlock(
|
||||
"timeRendererStartupStep('repo-catalog-settlement'"
|
||||
)
|
||||
const finalRepoCatalogSettlementIndex = indexInStartupBlock(
|
||||
"timeRendererStartupStep('repo-catalog-final-settlement'"
|
||||
)
|
||||
const localGroupsIndex = indexInStartupBlock(
|
||||
"actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })"
|
||||
)
|
||||
@@ -39,6 +45,10 @@ describe('renderer startup runtime routing', () => {
|
||||
expect(settingsIndex).toBeLessThan(uiGetIndex)
|
||||
expect(uiGetIndex).toBeLessThan(hydrateUiIndex)
|
||||
expect(hydrateUiIndex).toBeLessThan(localReposIndex)
|
||||
expect(localReposIndex).toBeLessThan(repoCatalogSettlementIndex)
|
||||
expect(repoCatalogSettlementIndex).toBeLessThan(sessionIndex)
|
||||
expect(sessionIndex).toBeLessThan(finalRepoCatalogSettlementIndex)
|
||||
expect(finalRepoCatalogSettlementIndex).toBeLessThan(startupBlockEnd)
|
||||
// The local catalog chain stays internally ordered (folders merge against project groups).
|
||||
expect(localReposIndex).toBeLessThan(localGroupsIndex)
|
||||
expect(localGroupsIndex).toBeLessThan(localFoldersIndex)
|
||||
@@ -75,6 +85,7 @@ describe('renderer startup runtime routing', () => {
|
||||
// The catalog and selective hydration chains overlap, but both settle before recovery or hydration.
|
||||
const joinStart = indexInStartupBlock('await Promise.allSettled([')
|
||||
expect(joinStart).toBeGreaterThan(hydrateUiIndex)
|
||||
expect(joinStart).toBeLessThan(finalRepoCatalogSettlementIndex)
|
||||
const joinBlock = source.slice(joinStart, startupBlockEnd)
|
||||
expect(joinBlock).toContain('hydrationSessionChain')
|
||||
expect(joinBlock).toContain('localCatalogChain')
|
||||
|
||||
@@ -42,6 +42,10 @@ vi.mock('@/store', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
useAllWorktrees: () => mocks.state.allWorktrees()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAllWorktrees } from '@/store/selectors'
|
||||
import { toast } from 'sonner'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getRuntimeGitStatus } from '@/runtime/runtime-git-client'
|
||||
@@ -36,7 +37,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const removeWorktree = useAppStore((s) => s.removeWorktree)
|
||||
const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
|
||||
const allWorktrees = useAppStore((s) => s.allWorktrees)
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
@@ -62,7 +63,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
? (modalData.onDeleted as (worktreeIds: string[]) => void)
|
||||
: null
|
||||
const worktree = useMemo(
|
||||
() => (worktreeId ? (allWorktrees().find((item) => item.id === worktreeId) ?? null) : null),
|
||||
() => (worktreeId ? (allWorktrees.find((item) => item.id === worktreeId) ?? null) : null),
|
||||
[allWorktrees, worktreeId]
|
||||
)
|
||||
const worktrees = useMemo(() => {
|
||||
@@ -70,7 +71,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
return []
|
||||
}
|
||||
const selected = new Set(worktreeIds)
|
||||
return allWorktrees().filter((item) => selected.has(item.id))
|
||||
return allWorktrees.filter((item) => selected.has(item.id))
|
||||
}, [allWorktrees, worktreeIds])
|
||||
const repoMap = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
|
||||
const isBatchDelete = worktreeIds.length > 1
|
||||
@@ -90,7 +91,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
|
||||
const lineageDelete = useMemo(
|
||||
() =>
|
||||
!isBatchDelete && worktree
|
||||
? getWorkspaceDeleteLineage(worktree, allWorktrees(), worktreeLineageById)
|
||||
? getWorkspaceDeleteLineage(worktree, allWorktrees, worktreeLineageById)
|
||||
: { descendants: [], deleteAllTargets: [] },
|
||||
[allWorktrees, isBatchDelete, worktree, worktreeLineageById]
|
||||
)
|
||||
|
||||
@@ -7,6 +7,9 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
type WorkspaceKanbanCardProps = {
|
||||
worktree: Worktree
|
||||
// Why: the lane virtualizes, so DOM order no longer implies list position.
|
||||
// Drop-index math reads this instead of counting rendered siblings.
|
||||
laneIndex: number
|
||||
repo: Repo | undefined
|
||||
isActive: boolean
|
||||
isSelected: boolean
|
||||
@@ -23,6 +26,7 @@ type WorkspaceKanbanCardProps = {
|
||||
|
||||
function WorkspaceKanbanCard({
|
||||
worktree,
|
||||
laneIndex,
|
||||
repo,
|
||||
isActive,
|
||||
isSelected,
|
||||
@@ -40,6 +44,7 @@ function WorkspaceKanbanCard({
|
||||
<div
|
||||
className="relative rounded-lg data-[workspace-board-card-area-selected=true]:ring-1 data-[workspace-board-card-area-selected=true]:ring-worktree-sidebar-ring/40"
|
||||
data-workspace-board-card-id={worktree.id}
|
||||
data-workspace-board-card-index={laneIndex}
|
||||
data-workspace-board-card-mode="detailed"
|
||||
data-workspace-board-card-selected={isSelected ? 'true' : 'false'}
|
||||
data-workspace-board-pointer-draggable={nativeDragEnabled ? undefined : 'true'}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/* eslint-disable max-lines -- Why: the board drawer owns shared board state, drag/drop, and settings callbacks that need one coordinated surface. */
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, {
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAllWorktrees, useRepoMap } from '@/store/selectors'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
@@ -46,6 +54,7 @@ import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
|
||||
import { STATUS_BAR_RESERVE_HEIGHT, WORKSPACE_TOP_CHROME_HEIGHT } from './workspace-chrome-metrics'
|
||||
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { registerWorkspaceKanbanSidebarDropGroups } from './workspace-kanban-sidebar-drop'
|
||||
|
||||
type WorkspaceKanbanDrawerProps = {
|
||||
leftSidebarStyle?: React.CSSProperties
|
||||
@@ -162,6 +171,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
const areaSelectionOverlayRef = useRef<HTMLDivElement>(null)
|
||||
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
|
||||
const [pinDragOver, setPinDragOver] = useState(false)
|
||||
const [renderCards, setRenderCards] = useState(false)
|
||||
const { canCreateWorktree, createWorktreeForStatus } = useWorkspaceKanbanCreateWorktree()
|
||||
const visibleWorktreeIdSet = useVisibleWorkspaceKanbanWorktreeIds({
|
||||
allWorktrees,
|
||||
@@ -191,6 +201,12 @@ export default function WorkspaceKanbanDrawer({
|
||||
})),
|
||||
[worktreesByStatus, workspaceStatuses]
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
return registerWorkspaceKanbanSidebarDropGroups(boardDragGroups)
|
||||
}, [boardDragGroups, open])
|
||||
const laneFullWorktreeIds = useMemo(
|
||||
() => new Map(boardDragGroups.map((group) => [group.key, group.worktreeIds])),
|
||||
[boardDragGroups]
|
||||
@@ -692,6 +708,28 @@ export default function WorkspaceKanbanDrawer({
|
||||
}
|
||||
)
|
||||
|
||||
// Why: mounting every lane's cards in the same commit that opens the sheet
|
||||
// blocks the slide-in for the whole card render. Paint the board chrome
|
||||
// first, then fill the lanes in a non-blocking transition.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setRenderCards(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
startTransition(() => {
|
||||
if (!cancelled) {
|
||||
setRenderCards(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useWorkspaceKanbanShiftWheelScroll(boardRef, laneScrollerRef, open, isPointerDragActiveRef)
|
||||
useWorkspaceKanbanOutsideDismiss({ open, boardRef, preserveOpenForMenu, onOpenChange })
|
||||
useContextualTour('workspace-board', open && !dragPreview, 'workspace_board_visible')
|
||||
@@ -871,6 +909,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
isResizingColumn={isResizingColumn}
|
||||
dragOverStatus={dragOverStatus}
|
||||
canCreateWorktree={canCreateWorktree}
|
||||
renderCards={renderCards}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={renderedSelectedWorktrees}
|
||||
onDragOver={handleDragOver}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* Why a render test: the board's open cost was mounting every card in every
|
||||
* lane at once. These assert the lane renders only the virtual window, and that
|
||||
* each rendered card still advertises its true lane index — drop-index math
|
||||
* reads that attribute now that DOM order no longer implies list order.
|
||||
*
|
||||
* The virtualizer itself is mocked (as the sidebar list tests do): it needs real
|
||||
* layout, which the test DOM has none of, and the contract under test is ours.
|
||||
*/
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
|
||||
const WINDOW_START = 20
|
||||
const WINDOW_END = 25
|
||||
const TOTAL_SIZE = 21_992
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => {
|
||||
const start = count === 0 ? 0 : Math.min(WINDOW_START, count - 1)
|
||||
const end = count === 0 ? -1 : Math.min(WINDOW_END, count - 1)
|
||||
return {
|
||||
getTotalSize: () => (count === 0 ? 0 : count * 44 - 8),
|
||||
measurementsCache: Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
start: index * 44,
|
||||
end: index * 44 + 36
|
||||
})),
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: Math.max(0, end - start + 1) }, (_, offset) => {
|
||||
const index = start + offset
|
||||
return { index, key: `w${index}`, start: index * 44 }
|
||||
}),
|
||||
measureElement: () => {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceKanbanCard', () => ({
|
||||
default: ({ worktree, laneIndex }: { worktree: Worktree; laneIndex: number }) => (
|
||||
<div data-workspace-board-card-id={worktree.id} data-workspace-board-card-index={laneIndex} />
|
||||
)
|
||||
}))
|
||||
|
||||
const { default: WorkspaceKanbanLaneCardList } = await import('./WorkspaceKanbanLaneCardList')
|
||||
|
||||
const REPO_MAP = new Map<string, Repo>()
|
||||
|
||||
function renderLane(count: number): HTMLElement {
|
||||
const scrollRef = createRef<HTMLDivElement>()
|
||||
const { container } = render(
|
||||
<div ref={scrollRef}>
|
||||
<WorkspaceKanbanLaneCardList
|
||||
items={Array.from({ length: count }, (_, index) => ({ id: `w${index}` }) as Worktree)}
|
||||
repoMap={REPO_MAP}
|
||||
activeWorktreeId={null}
|
||||
scrollRef={scrollRef}
|
||||
selectedWorktreeIds={new Set()}
|
||||
selectedWorktrees={[]}
|
||||
nativeDragEnabled={false}
|
||||
onActivate={() => {}}
|
||||
onSelectionGesture={() => false}
|
||||
onContextMenuSelect={() => []}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return container
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('WorkspaceKanbanLaneCardList', () => {
|
||||
it('mounts only the virtual window, not the whole lane', () => {
|
||||
const container = renderLane(500)
|
||||
|
||||
const ids = Array.from(container.querySelectorAll('[data-workspace-board-card-id]')).map(
|
||||
(card) => card.getAttribute('data-workspace-board-card-id')
|
||||
)
|
||||
expect(ids).toEqual(['w20', 'w21', 'w22', 'w23', 'w24', 'w25'])
|
||||
})
|
||||
|
||||
it('gives every rendered card its lane index, not its rendered position', () => {
|
||||
const container = renderLane(500)
|
||||
|
||||
const indexes = Array.from(container.querySelectorAll('[data-workspace-board-card-index]')).map(
|
||||
(card) => Number(card.getAttribute('data-workspace-board-card-index'))
|
||||
)
|
||||
expect(indexes).toEqual([20, 21, 22, 23, 24, 25])
|
||||
})
|
||||
|
||||
it('reserves the full lane height so the scrollbar spans the whole list', () => {
|
||||
const container = renderLane(500)
|
||||
|
||||
const spacer = container.querySelector<HTMLElement>('[data-workspace-board-card-id]')
|
||||
?.parentElement?.parentElement
|
||||
expect(spacer?.style.height).toBe(`${TOTAL_SIZE}px`)
|
||||
})
|
||||
|
||||
it('positions each card at its virtual offset', () => {
|
||||
const container = renderLane(500)
|
||||
|
||||
const first = container.querySelector<HTMLElement>(
|
||||
'[data-workspace-board-card-id]'
|
||||
)?.parentElement
|
||||
expect(first?.style.transform).toBe(`translateY(${WINDOW_START * 44}px)`)
|
||||
})
|
||||
|
||||
it('renders nothing for an empty lane and a single card at index 0', () => {
|
||||
const empty = renderLane(0)
|
||||
expect(empty.querySelectorAll('[data-workspace-board-card-id]')).toHaveLength(0)
|
||||
|
||||
const single = renderLane(1)
|
||||
const card = single.querySelector('[data-workspace-board-card-id]')
|
||||
expect(card?.getAttribute('data-workspace-board-card-id')).toBe('w0')
|
||||
expect(card?.getAttribute('data-workspace-board-card-index')).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types'
|
||||
import WorkspaceKanbanCard from './WorkspaceKanbanCard'
|
||||
import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout'
|
||||
|
||||
// Why: board cards are uniform one-line rows; a close estimate keeps the first
|
||||
// virtual window right so the lane does not reflow once cards measure.
|
||||
const WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT = 36
|
||||
// Matches the `space-y-2` rhythm the lane used before virtualization.
|
||||
const WORKSPACE_BOARD_CARD_GAP = 8
|
||||
const WORKSPACE_BOARD_CARD_OVERSCAN = 6
|
||||
|
||||
function estimateWorkspaceBoardCardSize(): number {
|
||||
return WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT
|
||||
}
|
||||
|
||||
type WorkspaceKanbanLaneCardListProps = {
|
||||
items: readonly Worktree[]
|
||||
repoMap: Map<string, Repo>
|
||||
activeWorktreeId: string | null
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
selectedWorktreeIds: ReadonlySet<string>
|
||||
selectedWorktrees: readonly Worktree[]
|
||||
nativeDragEnabled: boolean
|
||||
onActivate: () => void
|
||||
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
|
||||
onContextMenuSelect: (
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
worktree: Worktree
|
||||
) => readonly Worktree[]
|
||||
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
|
||||
}
|
||||
|
||||
function WorkspaceKanbanLaneCardList({
|
||||
items,
|
||||
repoMap,
|
||||
activeWorktreeId,
|
||||
scrollRef,
|
||||
selectedWorktreeIds,
|
||||
selectedWorktrees,
|
||||
nativeDragEnabled,
|
||||
onActivate,
|
||||
onSelectionGesture,
|
||||
onContextMenuSelect,
|
||||
onAssignWorkspaceStatus
|
||||
}: WorkspaceKanbanLaneCardListProps): React.JSX.Element {
|
||||
const spacerRef = useRef<HTMLDivElement | null>(null)
|
||||
const itemIds = useMemo(() => items.map((item) => item.id), [items])
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: estimateWorkspaceBoardCardSize,
|
||||
getItemKey: useCallback((index: number) => items[index]?.id ?? index, [items]),
|
||||
overscan: WORKSPACE_BOARD_CARD_OVERSCAN,
|
||||
gap: WORKSPACE_BOARD_CARD_GAP,
|
||||
// Why: sync-flushing rich card renders inside the scroll listener stalls the
|
||||
// wheel; async + overscan keeps the lane filled without blocking input.
|
||||
useFlushSync: false
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scrollElement = scrollRef.current
|
||||
const spacerElement = spacerRef.current
|
||||
if (!scrollElement || !spacerElement) {
|
||||
return
|
||||
}
|
||||
return registerWorkspaceKanbanVirtualLaneLayout({
|
||||
scrollElement,
|
||||
spacerElement,
|
||||
getItemIds: () => itemIds,
|
||||
getMeasurements: () => virtualizer.measurementsCache
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- virtualizer is a stable instance (useVirtualizer holds it in useState), and getMeasurements reads measurementsCache off it live.
|
||||
}, [itemIds, scrollRef])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={spacerRef}
|
||||
className="relative w-full"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const worktree = items[virtualItem.index]
|
||||
if (!worktree) {
|
||||
return null
|
||||
}
|
||||
const isSelected = selectedWorktreeIds.has(worktree.id)
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
||||
>
|
||||
<WorkspaceKanbanCard
|
||||
worktree={worktree}
|
||||
laneIndex={virtualItem.index}
|
||||
repo={repoMap.get(worktree.repoId)}
|
||||
isActive={activeWorktreeId === worktree.id}
|
||||
isSelected={isSelected}
|
||||
nativeDragEnabled={nativeDragEnabled}
|
||||
selectedWorktrees={
|
||||
isSelected && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
|
||||
}
|
||||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(WorkspaceKanbanLaneCardList)
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
import type { WorkspaceKanbanLaneView } from './workspace-kanban-search'
|
||||
import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane'
|
||||
|
||||
// Why: a fresh [] per render would defeat the memoized lane on empty lanes.
|
||||
const EMPTY_LANE_ITEMS: readonly Worktree[] = []
|
||||
|
||||
type WorkspaceKanbanLaneGridProps = {
|
||||
statuses: readonly WorkspaceStatusDefinition[]
|
||||
laneViews: ReadonlyMap<WorkspaceStatus, WorkspaceKanbanLaneView>
|
||||
@@ -19,6 +22,7 @@ type WorkspaceKanbanLaneGridProps = {
|
||||
isResizingColumn: boolean
|
||||
dragOverStatus: WorkspaceStatus | null
|
||||
canCreateWorktree: boolean
|
||||
renderCards: boolean
|
||||
selectedWorktreeIds: ReadonlySet<string>
|
||||
selectedWorktrees: readonly Worktree[]
|
||||
onDragOver: (event: React.DragEvent, statusId: string) => void
|
||||
@@ -47,6 +51,7 @@ export default function WorkspaceKanbanLaneGrid({
|
||||
isResizingColumn,
|
||||
dragOverStatus,
|
||||
canCreateWorktree,
|
||||
renderCards,
|
||||
selectedWorktreeIds,
|
||||
selectedWorktrees,
|
||||
onDragOver,
|
||||
@@ -72,7 +77,7 @@ export default function WorkspaceKanbanLaneGrid({
|
||||
<WorkspaceKanbanStatusLane
|
||||
key={status.id}
|
||||
status={status}
|
||||
items={laneViews.get(status.id)?.items ?? []}
|
||||
items={laneViews.get(status.id)?.items ?? EMPTY_LANE_ITEMS}
|
||||
totalCount={laneViews.get(status.id)?.totalCount ?? 0}
|
||||
hasQuery={hasQuery}
|
||||
fullWorktreeIds={laneFullWorktreeIds.get(status.id) ?? []}
|
||||
@@ -82,6 +87,7 @@ export default function WorkspaceKanbanLaneGrid({
|
||||
isResizingColumn={isResizingColumn}
|
||||
isDragTarget={dragOverStatus === status.id}
|
||||
canCreateWorktree={canCreateWorktree}
|
||||
renderCards={renderCards}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
nativeDragEnabled={false}
|
||||
|
||||
@@ -9,9 +9,13 @@ import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
vi.mock('./WorkspaceKanbanCard', () => ({
|
||||
default: ({ worktree }: { worktree: Worktree }) => (
|
||||
<div data-workspace-board-card-id={worktree.id} />
|
||||
vi.mock('./WorkspaceKanbanLaneCardList', () => ({
|
||||
default: ({ items }: { items: readonly Worktree[] }) => (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} data-workspace-board-card-id={item.id} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}))
|
||||
|
||||
@@ -51,6 +55,7 @@ function renderLane(props: {
|
||||
isResizingColumn={false}
|
||||
isDragTarget={false}
|
||||
canCreateWorktree={true}
|
||||
renderCards={true}
|
||||
selectedWorktreeIds={new Set()}
|
||||
selectedWorktrees={[]}
|
||||
onDragOver={vi.fn()}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type {
|
||||
Repo,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import WorkspaceKanbanCard from './WorkspaceKanbanCard'
|
||||
import WorkspaceKanbanLaneCardList from './WorkspaceKanbanLaneCardList'
|
||||
import { serializeWorkspaceLaneFullIds } from './workspace-kanban-filtered-drop-index'
|
||||
import { getWorkspaceStatusVisualMeta } from './workspace-status'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -32,6 +32,7 @@ type WorkspaceKanbanStatusLaneProps = {
|
||||
isDragTarget: boolean
|
||||
canCreateWorktree: boolean
|
||||
nativeDragEnabled?: boolean
|
||||
renderCards: boolean
|
||||
selectedWorktreeIds: ReadonlySet<string>
|
||||
selectedWorktrees: readonly Worktree[]
|
||||
onDragOver: (event: React.DragEvent, statusId: string) => void
|
||||
@@ -49,7 +50,7 @@ type WorkspaceKanbanStatusLaneProps = {
|
||||
onColumnResizeKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
export default function WorkspaceKanbanStatusLane({
|
||||
function WorkspaceKanbanStatusLane({
|
||||
status,
|
||||
items,
|
||||
totalCount,
|
||||
@@ -62,6 +63,7 @@ export default function WorkspaceKanbanStatusLane({
|
||||
isDragTarget,
|
||||
canCreateWorktree,
|
||||
nativeDragEnabled = true,
|
||||
renderCards,
|
||||
selectedWorktreeIds,
|
||||
selectedWorktrees,
|
||||
onDragOver,
|
||||
@@ -75,6 +77,7 @@ export default function WorkspaceKanbanStatusLane({
|
||||
onColumnResizeStart,
|
||||
onColumnResizeKeyDown
|
||||
}: WorkspaceKanbanStatusLaneProps): React.JSX.Element {
|
||||
const laneScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const meta = getWorkspaceStatusVisualMeta(status)
|
||||
// Why: a lane that is empty on its own merits is still "Empty" under a query —
|
||||
// only a lane whose cards were filtered away has anything to say about matches.
|
||||
@@ -180,32 +183,26 @@ export default function WorkspaceKanbanStatusLane({
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={laneScrollRef}
|
||||
data-workspace-board-lane-scroll=""
|
||||
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1.5 py-2 scrollbar-sleek"
|
||||
>
|
||||
{items.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{items.map((worktree) => {
|
||||
const isSelected = selectedWorktreeIds.has(worktree.id)
|
||||
return (
|
||||
<WorkspaceKanbanCard
|
||||
key={worktree.id}
|
||||
worktree={worktree}
|
||||
repo={repoMap.get(worktree.repoId)}
|
||||
isActive={activeWorktreeId === worktree.id}
|
||||
isSelected={isSelected}
|
||||
nativeDragEnabled={nativeDragEnabled}
|
||||
selectedWorktrees={
|
||||
isSelected && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
|
||||
}
|
||||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
renderCards ? (
|
||||
<WorkspaceKanbanLaneCardList
|
||||
items={items}
|
||||
repoMap={repoMap}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
scrollRef={laneScrollRef}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
nativeDragEnabled={nativeDragEnabled}
|
||||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<div className="flex h-20 items-center justify-center rounded-md border border-dashed border-border/70 text-[11px] text-muted-foreground">
|
||||
{isFiltered
|
||||
@@ -241,3 +238,5 @@ export default function WorkspaceKanbanStatusLane({
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(WorkspaceKanbanStatusLane)
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('@/store', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
getAllWorktreesFromState: () => Array.from(mocks.state.worktreeMap.values()),
|
||||
getWorktreeMapFromState: () => mocks.state.worktreeMap
|
||||
}))
|
||||
|
||||
@@ -141,6 +142,16 @@ describe('runWorktreeBatchDelete', () => {
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' })
|
||||
})
|
||||
|
||||
it('treats duplicate selected ids as one delete target', () => {
|
||||
setWorktrees([{ id: 'wt-1' }])
|
||||
|
||||
const started = runWorktreeBatchDelete(['wt-1', 'wt-1'])
|
||||
|
||||
expect(started).toBe(true)
|
||||
expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' })
|
||||
})
|
||||
|
||||
it('keeps batch deletes behind confirmation when confirmation is skipped', () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
setWorktrees([
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { getAllWorktreesFromState, getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { findRepoForHost } from '@/store/slices/repo-host-identity'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete'
|
||||
@@ -46,16 +46,18 @@ export async function runWorktreeDeletesInParallel(
|
||||
targets: readonly Pick<Worktree, 'id' | 'displayName' | 'repoId' | 'path'>[],
|
||||
options: WorktreeDeleteWithToastOptions = {}
|
||||
): Promise<string[]> {
|
||||
// Why: refresh races can leave duplicate rows, but a destructive command must run once per identity.
|
||||
const uniqueTargets = Array.from(new Map(targets.map((target) => [target.id, target])).values())
|
||||
// Why: capture the viewed workspace before any delete so we can focus one survivor after the batch settles, not per delete.
|
||||
const activeWorktreeIdBefore = useAppStore.getState().activeWorktreeId
|
||||
const commitBatchFocus = activeWorktreeIdBefore
|
||||
? prepareActiveWorktreeFocusAfterDelete(activeWorktreeIdBefore)
|
||||
: null
|
||||
// Why: mark every target deleting up front for immediate in-flight feedback, even though deletes serialize per repo.
|
||||
useAppStore.getState().markWorktreesDeleting(targets.map((target) => target.id))
|
||||
useAppStore.getState().markWorktreesDeleting(uniqueTargets.map((target) => target.id))
|
||||
// Why: worktree remove/prune/branch -D race on shared ref locks; group by repoId to serialize per repo (cross-repo stays parallel).
|
||||
const groups = new Map<string, (typeof targets)[number][]>()
|
||||
for (const target of targets) {
|
||||
const groups = new Map<string, (typeof uniqueTargets)[number][]>()
|
||||
for (const target of uniqueTargets) {
|
||||
const group = groups.get(target.repoId)
|
||||
if (group) {
|
||||
group.push(target)
|
||||
@@ -95,7 +97,7 @@ export async function runWorktreeDeletesInParallel(
|
||||
if (activeWorktreeIdBefore && deletedSet.has(activeWorktreeIdBefore)) {
|
||||
commitBatchFocus?.()
|
||||
}
|
||||
return targets.filter((target) => deletedSet.has(target.id)).map((target) => target.id)
|
||||
return uniqueTargets.filter((target) => deletedSet.has(target.id)).map((target) => target.id)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,8 +251,8 @@ export function runWorktreeDelete(worktreeId: string): void {
|
||||
}
|
||||
|
||||
const hasLineageChildren =
|
||||
getWorkspaceDeleteLineage(target, state.allWorktrees(), state.worktreeLineageById).descendants
|
||||
.length > 0
|
||||
getWorkspaceDeleteLineage(target, getAllWorktreesFromState(state), state.worktreeLineageById)
|
||||
.descendants.length > 0
|
||||
const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false
|
||||
if (skipConfirm && !hasLineageChildren) {
|
||||
void runWorktreeDeleteWithToast(worktreeId, target.displayName)
|
||||
@@ -268,7 +270,7 @@ export function runWorktreeBatchDelete(
|
||||
): boolean {
|
||||
const state = useAppStore.getState()
|
||||
const worktreeMap = getWorktreeMapFromState(state)
|
||||
const targets = worktreeIds
|
||||
const targets = Array.from(new Set(worktreeIds))
|
||||
.map((id) => worktreeMap.get(id) ?? null)
|
||||
.filter((worktree): worktree is Worktree => worktree != null && !worktree.isMainWorktree)
|
||||
|
||||
@@ -295,8 +297,11 @@ export function runWorktreeBatchDelete(
|
||||
// Why: bulk cleanup can destroy many directories at once, so batch/Space deletes keep an explicit confirmation step.
|
||||
const singleTargetHasLineageChildren =
|
||||
targets.length === 1 &&
|
||||
getWorkspaceDeleteLineage(targets[0], state.allWorktrees(), state.worktreeLineageById)
|
||||
.descendants.length > 0
|
||||
getWorkspaceDeleteLineage(
|
||||
targets[0],
|
||||
getAllWorktreesFromState(state),
|
||||
state.worktreeLineageById
|
||||
).descendants.length > 0
|
||||
const skipConfirm =
|
||||
!options.forceConfirm &&
|
||||
targets.length === 1 &&
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock('@/store', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
getAllWorktreesFromState: () => Array.from(mocks.state.worktreeMap.values()),
|
||||
getWorktreeMapFromState: () => mocks.state.worktreeMap
|
||||
}))
|
||||
|
||||
@@ -145,6 +146,25 @@ describe('runWorktreeDeletesInParallel', () => {
|
||||
expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'wt-2', true)
|
||||
})
|
||||
|
||||
it('deletes a duplicated target identity only once', async () => {
|
||||
const target = {
|
||||
id: 'wt-1',
|
||||
displayName: 'one',
|
||||
repoId: 'repo-a',
|
||||
path: '/workspaces/one'
|
||||
}
|
||||
mocks.state.removeWorktree
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ ok: false, error: 'selector_not_found' })
|
||||
|
||||
await expect(runWorktreeDeletesInParallel([target, target])).resolves.toEqual(['wt-1'])
|
||||
|
||||
expect(mocks.state.markWorktreesDeleting).toHaveBeenCalledWith(['wt-1'])
|
||||
expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false)
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a pending ancestor when a nested descendant delete fails', async () => {
|
||||
mocks.state.removeWorktree.mockImplementationOnce(async (worktreeId: string) => {
|
||||
mocks.state.deleteStateByWorktreeId[worktreeId] = {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
/* eslint-disable max-lines -- Why: marquee selection coordinates pointer capture, lane-scroll refresh, auto-scroll, preview cleanup, and final commit against one drag state. Splitting those phases would make the interaction easier to desynchronize. */
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
getAreaSelectionCardRects,
|
||||
type AreaSelectionCardRect
|
||||
} from './workspace-kanban-area-selection-card-rects'
|
||||
import {
|
||||
clearPreviewSelection,
|
||||
getAreaSelectionAutoScrollDelta,
|
||||
getAreaSelectionCardIds,
|
||||
getAreaSelectionCardRects,
|
||||
getAreaSelectionRect,
|
||||
getAreaSelectionScrollContainer,
|
||||
getAreaSelectionScrollStartContentYByElement,
|
||||
isScrollbarPointerDown,
|
||||
setOverlayRect,
|
||||
shouldIgnoreAreaSelectionStart,
|
||||
updatePreviewSelection,
|
||||
type AreaSelectionCardRect
|
||||
updatePreviewSelection
|
||||
} from './workspace-kanban-area-selection-dom'
|
||||
|
||||
type AreaSelectionDragState = {
|
||||
@@ -110,6 +112,20 @@ export function useWorkspaceKanbanAreaSelection({
|
||||
|
||||
state.started = true
|
||||
|
||||
// Why: deferred card mount can start a marquee against empty rects; pick up
|
||||
// virtual layout once lanes register without waiting for a scroll event.
|
||||
if (state.cardRects.length === 0) {
|
||||
const board = boardRef.current
|
||||
if (board) {
|
||||
state.boardRect = board.getBoundingClientRect()
|
||||
state.cardRects = getAreaSelectionCardRects(board)
|
||||
state.scrollStartContentYByElement = getAreaSelectionScrollStartContentYByElement(
|
||||
board,
|
||||
state.startY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const viewportRect = getAreaSelectionRect(
|
||||
state.startX,
|
||||
state.startY,
|
||||
@@ -153,7 +169,7 @@ export function useWorkspaceKanbanAreaSelection({
|
||||
state.additive,
|
||||
areaIds
|
||||
)
|
||||
}, [overlayRef])
|
||||
}, [boardRef, overlayRef])
|
||||
|
||||
const refreshAreaSelectionMeasurements = useCallback(() => {
|
||||
const state = dragRef.current
|
||||
@@ -233,6 +249,7 @@ export function useWorkspaceKanbanAreaSelection({
|
||||
window.cancelAnimationFrame(state.scrollFrameId)
|
||||
state.scrollFrameId = null
|
||||
}
|
||||
refreshAreaSelectionMeasurements()
|
||||
flushAreaSelectionDrag()
|
||||
if (shouldCommitWorkspaceKanbanAreaSelection(state)) {
|
||||
updateSelectionForAreaRef.current(
|
||||
@@ -246,7 +263,7 @@ export function useWorkspaceKanbanAreaSelection({
|
||||
dragRef.current = null
|
||||
setOverlayRect(overlayRef.current, null)
|
||||
},
|
||||
[flushAreaSelectionDrag, overlayRef]
|
||||
[flushAreaSelectionDrag, overlayRef, refreshAreaSelectionMeasurements]
|
||||
)
|
||||
|
||||
const handleAreaSelectionPointerDown = useCallback(
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from './workspace-kanban-card-pointer-drag-dom'
|
||||
import {
|
||||
createDragPreview,
|
||||
getDraggedCards,
|
||||
setDragDocumentStyles,
|
||||
setDraggedCardsDragging,
|
||||
updateDragPreviewPosition
|
||||
@@ -33,7 +32,6 @@ type DragState = {
|
||||
currentY: number
|
||||
worktreeIds: string[]
|
||||
sourceCard: HTMLElement
|
||||
draggedCards: HTMLElement[]
|
||||
preview: HTMLElement | null
|
||||
previewOffsetX: number
|
||||
previewOffsetY: number
|
||||
@@ -117,7 +115,11 @@ export function useWorkspaceKanbanCardPointerDrag({
|
||||
if (state.frameId !== null) {
|
||||
window.cancelAnimationFrame(state.frameId)
|
||||
}
|
||||
setDraggedCardsDragging(state.draggedCards, false)
|
||||
setDraggedCardsDragging({
|
||||
board: boardRef.current,
|
||||
worktreeIds: state.worktreeIds,
|
||||
enabled: false
|
||||
})
|
||||
removeCardDropIndicator()
|
||||
state.preview?.remove()
|
||||
setDragDocumentStyles(false)
|
||||
@@ -146,13 +148,20 @@ export function useWorkspaceKanbanCardPointerDrag({
|
||||
[boardRef, clearDragTarget]
|
||||
)
|
||||
|
||||
const startPointerDrag = useCallback((state: DragState) => {
|
||||
state.started = true
|
||||
isPointerDragActiveRef.current = true
|
||||
setDraggedCardsDragging(state.draggedCards, true)
|
||||
state.preview = createDragPreview(state)
|
||||
setDragDocumentStyles(true)
|
||||
}, [])
|
||||
const startPointerDrag = useCallback(
|
||||
(state: DragState) => {
|
||||
state.started = true
|
||||
isPointerDragActiveRef.current = true
|
||||
setDraggedCardsDragging({
|
||||
board: boardRef.current,
|
||||
worktreeIds: state.worktreeIds,
|
||||
enabled: true
|
||||
})
|
||||
state.preview = createDragPreview(state)
|
||||
setDragDocumentStyles(true)
|
||||
},
|
||||
[boardRef]
|
||||
)
|
||||
|
||||
const updatePointerDragTarget = useCallback(
|
||||
(state: DragState) => {
|
||||
@@ -289,10 +298,9 @@ export function useWorkspaceKanbanCardPointerDrag({
|
||||
return
|
||||
}
|
||||
|
||||
const selectedIds = selectedWorktreeIdsRef.current
|
||||
const selectedWorktrees = selectedWorktreesRef.current
|
||||
const worktreeIds =
|
||||
selectedIds.has(worktreeId) && selectedWorktrees.length > 1
|
||||
selectedWorktreeIdsRef.current.has(worktreeId) && selectedWorktrees.length > 1
|
||||
? selectedWorktrees.map((worktree) => worktree.id)
|
||||
: [worktreeId]
|
||||
dragRef.current = {
|
||||
@@ -303,7 +311,6 @@ export function useWorkspaceKanbanCardPointerDrag({
|
||||
currentY: event.clientY,
|
||||
worktreeIds,
|
||||
sourceCard: card,
|
||||
draggedCards: getDraggedCards(board, worktreeIds, card),
|
||||
preview: null,
|
||||
previewOffsetX: 0,
|
||||
previewOffsetY: 0,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { getWorkspaceKanbanVirtualLaneItemRects } from './workspace-kanban-virtual-lane-layout'
|
||||
|
||||
type AreaSelectionCardContentRect = {
|
||||
top: number
|
||||
bottom: number
|
||||
containerTop: number
|
||||
scrollTop: number
|
||||
}
|
||||
|
||||
export type AreaSelectionViewportRect = {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
export type AreaSelectionCardRect = {
|
||||
id: string
|
||||
element: HTMLElement | null
|
||||
rect: AreaSelectionViewportRect
|
||||
scrollContainer: HTMLElement | null
|
||||
contentRect: AreaSelectionCardContentRect | null
|
||||
}
|
||||
|
||||
export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]'
|
||||
|
||||
export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] {
|
||||
const cardRects = new Map<string, AreaSelectionCardRect>()
|
||||
const scrollMetrics = new Map<HTMLElement, { containerTop: number; scrollTop: number }>()
|
||||
const scrollContainers = board.querySelectorAll<HTMLElement>(
|
||||
AREA_SELECTION_SCROLL_CONTAINER_SELECTOR
|
||||
)
|
||||
for (const scrollContainer of scrollContainers) {
|
||||
const virtualRects = getWorkspaceKanbanVirtualLaneItemRects(scrollContainer)
|
||||
if (!virtualRects) {
|
||||
continue
|
||||
}
|
||||
const containerTop = scrollContainer.getBoundingClientRect().top
|
||||
const scrollTop = scrollContainer.scrollTop
|
||||
scrollMetrics.set(scrollContainer, { containerTop, scrollTop })
|
||||
for (const virtualRect of virtualRects) {
|
||||
cardRects.set(virtualRect.id, {
|
||||
id: virtualRect.id,
|
||||
element: null,
|
||||
rect: {
|
||||
left: virtualRect.left,
|
||||
top: virtualRect.top,
|
||||
right: virtualRect.right,
|
||||
bottom: virtualRect.bottom
|
||||
},
|
||||
scrollContainer,
|
||||
contentRect: {
|
||||
top: virtualRect.contentTop,
|
||||
bottom: virtualRect.contentBottom,
|
||||
containerTop,
|
||||
scrollTop
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const cards = board.querySelectorAll<HTMLElement>('[data-workspace-board-card-id]')
|
||||
for (const card of cards) {
|
||||
const id = card.dataset.workspaceBoardCardId
|
||||
if (!id) {
|
||||
continue
|
||||
}
|
||||
const rect = card.getBoundingClientRect()
|
||||
const scrollContainer = card.closest<HTMLElement>(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR)
|
||||
let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined
|
||||
if (scrollContainer && !metrics) {
|
||||
metrics = {
|
||||
containerTop: scrollContainer.getBoundingClientRect().top,
|
||||
scrollTop: scrollContainer.scrollTop
|
||||
}
|
||||
scrollMetrics.set(scrollContainer, metrics)
|
||||
}
|
||||
cardRects.set(id, {
|
||||
id,
|
||||
element: card,
|
||||
rect: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom
|
||||
},
|
||||
scrollContainer,
|
||||
contentRect: metrics
|
||||
? {
|
||||
top: rect.top - metrics.containerTop + metrics.scrollTop,
|
||||
bottom: rect.bottom - metrics.containerTop + metrics.scrollTop,
|
||||
containerTop: metrics.containerTop,
|
||||
scrollTop: metrics.scrollTop
|
||||
}
|
||||
: null
|
||||
})
|
||||
}
|
||||
return Array.from(cardRects.values())
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
AREA_SELECTION_SCROLL_CONTAINER_SELECTOR,
|
||||
type AreaSelectionCardRect
|
||||
} from './workspace-kanban-area-selection-card-rects'
|
||||
|
||||
export type AreaSelectionRect = {
|
||||
left: number
|
||||
top: number
|
||||
@@ -5,21 +10,6 @@ export type AreaSelectionRect = {
|
||||
height: number
|
||||
}
|
||||
|
||||
export type AreaSelectionCardRect = {
|
||||
id: string
|
||||
element: HTMLElement
|
||||
rect: DOMRect
|
||||
scrollContainer: HTMLElement | null
|
||||
contentRect: AreaSelectionCardContentRect | null
|
||||
}
|
||||
|
||||
type AreaSelectionCardContentRect = {
|
||||
top: number
|
||||
bottom: number
|
||||
containerTop: number
|
||||
scrollTop: number
|
||||
}
|
||||
|
||||
type AreaSelectionAutoScrollParams = {
|
||||
pointerY: number
|
||||
containerTop: number
|
||||
@@ -37,7 +27,6 @@ type AreaSelectionCardIdOptions = {
|
||||
}
|
||||
|
||||
const AREA_SELECTED_ATTR = 'data-workspace-board-card-area-selected'
|
||||
export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]'
|
||||
export const AREA_SELECTION_AUTO_SCROLL_EDGE_SIZE = 48
|
||||
export const AREA_SELECTION_AUTO_SCROLL_MAX_DELTA = 22
|
||||
|
||||
@@ -93,45 +82,6 @@ export function isScrollbarPointerDown(
|
||||
return hitsVerticalScrollbar || hitsHorizontalScrollbar
|
||||
}
|
||||
|
||||
export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] {
|
||||
const cardRects: AreaSelectionCardRect[] = []
|
||||
const seen = new Set<string>()
|
||||
const scrollMetrics = new Map<HTMLElement, { containerTop: number; scrollTop: number }>()
|
||||
const cards = board.querySelectorAll<HTMLElement>('[data-workspace-board-card-id]')
|
||||
for (const card of cards) {
|
||||
const id = card.dataset.workspaceBoardCardId
|
||||
if (!id || seen.has(id)) {
|
||||
continue
|
||||
}
|
||||
const rect = card.getBoundingClientRect()
|
||||
const scrollContainer = card.closest<HTMLElement>(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR)
|
||||
let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined
|
||||
if (scrollContainer && !metrics) {
|
||||
metrics = {
|
||||
containerTop: scrollContainer.getBoundingClientRect().top,
|
||||
scrollTop: scrollContainer.scrollTop
|
||||
}
|
||||
scrollMetrics.set(scrollContainer, metrics)
|
||||
}
|
||||
cardRects.push({
|
||||
id,
|
||||
element: card,
|
||||
rect,
|
||||
scrollContainer,
|
||||
contentRect: metrics
|
||||
? {
|
||||
top: rect.top - metrics.containerTop + metrics.scrollTop,
|
||||
bottom: rect.bottom - metrics.containerTop + metrics.scrollTop,
|
||||
containerTop: metrics.containerTop,
|
||||
scrollTop: metrics.scrollTop
|
||||
}
|
||||
: null
|
||||
})
|
||||
seen.add(id)
|
||||
}
|
||||
return cardRects
|
||||
}
|
||||
|
||||
export function getAreaSelectionCardIds(
|
||||
cardRects: readonly AreaSelectionCardRect[],
|
||||
selectionRect: AreaSelectionRect,
|
||||
@@ -256,7 +206,8 @@ export function clearPreviewSelection(
|
||||
previewIds: Set<string>
|
||||
): void {
|
||||
for (const card of cardRects) {
|
||||
if (previewIds.has(card.id)) {
|
||||
// Why: virtual remounts replace the element; clear via live connected node when present.
|
||||
if (card.element?.isConnected && previewIds.has(card.id)) {
|
||||
card.element.removeAttribute(AREA_SELECTED_ATTR)
|
||||
}
|
||||
}
|
||||
@@ -276,16 +227,29 @@ export function updatePreviewSelection(
|
||||
}
|
||||
|
||||
for (const card of cardRects) {
|
||||
const element = card.element?.isConnected ? card.element : null
|
||||
if (!element) {
|
||||
if (!nextIds.has(card.id)) {
|
||||
previewIds.delete(card.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const shouldPreview = nextIds.has(card.id)
|
||||
const isPreviewed = previewIds.has(card.id)
|
||||
// Why: virtualization can remount the same card id; trust the live attr, not previewIds alone.
|
||||
const isPreviewed = element.getAttribute(AREA_SELECTED_ATTR) === 'true'
|
||||
if (shouldPreview === isPreviewed) {
|
||||
if (shouldPreview) {
|
||||
previewIds.add(card.id)
|
||||
} else {
|
||||
previewIds.delete(card.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (shouldPreview) {
|
||||
card.element.setAttribute(AREA_SELECTED_ATTR, 'true')
|
||||
element.setAttribute(AREA_SELECTED_ATTR, 'true')
|
||||
previewIds.add(card.id)
|
||||
} else {
|
||||
card.element.removeAttribute(AREA_SELECTED_ATTR)
|
||||
element.removeAttribute(AREA_SELECTED_ATTR)
|
||||
previewIds.delete(card.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AreaSelectionCardRect } from './workspace-kanban-area-selection-card-rects'
|
||||
import {
|
||||
getAreaSelectionAutoScrollDelta,
|
||||
getAreaSelectionCardIds,
|
||||
type AreaSelectionCardRect
|
||||
updatePreviewSelection
|
||||
} from './workspace-kanban-area-selection-dom'
|
||||
import { shouldCommitWorkspaceKanbanAreaSelection } from './use-workspace-kanban-area-selection'
|
||||
|
||||
@@ -166,16 +168,52 @@ function makeRect({
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}): DOMRect {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect
|
||||
}): AreaSelectionCardRect['rect'] {
|
||||
return { left, top, right, bottom }
|
||||
}
|
||||
|
||||
describe('workspace kanban area selection preview remount', () => {
|
||||
it('re-applies the preview attribute when a card remounts under the same id', () => {
|
||||
const first = document.createElement('div')
|
||||
first.setAttribute('data-workspace-board-card-id', 'card-a')
|
||||
Object.defineProperty(first, 'isConnected', { value: true })
|
||||
const previewIds = new Set<string>()
|
||||
updatePreviewSelection(
|
||||
[
|
||||
{
|
||||
id: 'card-a',
|
||||
element: first,
|
||||
rect: makeRect({ left: 0, top: 0, right: 100, bottom: 40 }),
|
||||
scrollContainer: null,
|
||||
contentRect: null
|
||||
}
|
||||
],
|
||||
previewIds,
|
||||
new Set(),
|
||||
false,
|
||||
['card-a']
|
||||
)
|
||||
expect(first.getAttribute('data-workspace-board-card-area-selected')).toBe('true')
|
||||
expect(previewIds.has('card-a')).toBe(true)
|
||||
|
||||
const remounted = document.createElement('div')
|
||||
remounted.setAttribute('data-workspace-board-card-id', 'card-a')
|
||||
Object.defineProperty(remounted, 'isConnected', { value: true })
|
||||
updatePreviewSelection(
|
||||
[
|
||||
{
|
||||
id: 'card-a',
|
||||
element: remounted,
|
||||
rect: makeRect({ left: 0, top: 0, right: 100, bottom: 40 }),
|
||||
scrollContainer: null,
|
||||
contentRect: null
|
||||
}
|
||||
],
|
||||
previewIds,
|
||||
new Set(),
|
||||
false,
|
||||
['card-a']
|
||||
)
|
||||
expect(remounted.getAttribute('data-workspace-board-card-area-selected')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,12 +37,27 @@ export function getDraggedCards(
|
||||
return cards.length > 0 ? cards : [fallbackCard]
|
||||
}
|
||||
|
||||
export function setDraggedCardsDragging(cards: readonly HTMLElement[], enabled: boolean): void {
|
||||
for (const card of cards) {
|
||||
if (enabled) {
|
||||
card.setAttribute(POINTER_CARD_DRAGGING_ATTR, 'true')
|
||||
} else {
|
||||
export function setDraggedCardsDragging(args: {
|
||||
board: HTMLElement | null
|
||||
worktreeIds: readonly string[]
|
||||
enabled: boolean
|
||||
}): void {
|
||||
const { board, worktreeIds, enabled } = args
|
||||
if (!board) {
|
||||
return
|
||||
}
|
||||
// Why: virtual lanes remount cards during scroll; query live nodes by id
|
||||
// instead of holding HTMLElement references across unmounts.
|
||||
if (!enabled) {
|
||||
board.querySelectorAll<HTMLElement>(`[${POINTER_CARD_DRAGGING_ATTR}]`).forEach((card) => {
|
||||
card.removeAttribute(POINTER_CARD_DRAGGING_ATTR)
|
||||
})
|
||||
return
|
||||
}
|
||||
const ids = new Set(worktreeIds)
|
||||
for (const card of board.querySelectorAll<HTMLElement>(CARD_SELECTOR)) {
|
||||
if (ids.has(card.dataset.workspaceBoardCardId ?? '')) {
|
||||
card.setAttribute(POINTER_CARD_DRAGGING_ATTR, 'true')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveWorkspaceKanbanCardDropCommitTarget,
|
||||
resolveWorkspaceCardDropIndexFromRects,
|
||||
resolveWorkspaceCardDropIndicatorY,
|
||||
resolveWorkspaceStatusDropTargetFromRects
|
||||
} from './workspace-kanban-card-pointer-drag-dom'
|
||||
|
||||
@@ -41,6 +42,47 @@ describe('workspace kanban pointer drag card drop index', () => {
|
||||
it('inserts at the end when the pointer is below every card midpoint', () => {
|
||||
expect(resolveWorkspaceCardDropIndexFromRects(cardRects, 140)).toBe(3)
|
||||
})
|
||||
|
||||
// Why: a virtualized lane only renders its window, so rendered position is
|
||||
// not lane position. Without the carried index a drop into a scrolled lane
|
||||
// would land near the top of the list.
|
||||
it('uses the lane index of a rendered card rather than its rendered position', () => {
|
||||
const virtualized = [
|
||||
{ top: 0, bottom: 40, index: 20 },
|
||||
{ top: 48, bottom: 88, index: 21 },
|
||||
{ top: 96, bottom: 136, index: 22 }
|
||||
]
|
||||
expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 10)).toBe(20)
|
||||
expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 70)).toBe(22)
|
||||
expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 140)).toBe(23)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace kanban drop indicator placement', () => {
|
||||
const cardRects = [
|
||||
{ top: 100, bottom: 140 },
|
||||
{ top: 148, bottom: 188 }
|
||||
]
|
||||
|
||||
it('places the line above, between, and below rendered cards', () => {
|
||||
expect(resolveWorkspaceCardDropIndicatorY(cardRects, 0, 90)).toBe(95)
|
||||
expect(resolveWorkspaceCardDropIndicatorY(cardRects, 1, 90)).toBe(144)
|
||||
expect(resolveWorkspaceCardDropIndicatorY(cardRects, 2, 90)).toBe(193)
|
||||
})
|
||||
|
||||
it('falls back to the lane top when the lane renders no cards', () => {
|
||||
expect(resolveWorkspaceCardDropIndicatorY([], 0, 90)).toBe(104)
|
||||
})
|
||||
|
||||
it('anchors to the rendered card that owns the lane index', () => {
|
||||
const virtualized = [
|
||||
{ top: 100, bottom: 140, index: 20 },
|
||||
{ top: 148, bottom: 188, index: 21 }
|
||||
]
|
||||
expect(resolveWorkspaceCardDropIndicatorY(virtualized, 20, 90)).toBe(95)
|
||||
expect(resolveWorkspaceCardDropIndicatorY(virtualized, 21, 90)).toBe(144)
|
||||
expect(resolveWorkspaceCardDropIndicatorY(virtualized, 22, 90)).toBe(193)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace kanban pointer drag commit target', () => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { WorkspaceStatus } from '../../../../shared/types'
|
||||
import {
|
||||
resolveWorkspaceKanbanVirtualLaneDropIndex,
|
||||
resolveWorkspaceKanbanVirtualLaneDropIndicatorY
|
||||
} from './workspace-kanban-virtual-lane-layout'
|
||||
|
||||
export const CARD_SELECTOR = '[data-workspace-board-card-id]'
|
||||
export const STATUS_DROP_TARGET = '[data-workspace-status-drop-target]'
|
||||
@@ -19,6 +23,9 @@ export type WorkspaceKanbanStatusDropRect = {
|
||||
export type WorkspaceKanbanCardDropRect = {
|
||||
top: number
|
||||
bottom: number
|
||||
// Why: lanes virtualize, so a rendered card's DOM position is not its index
|
||||
// in the lane. Carry the lane index when the card advertises one.
|
||||
index?: number
|
||||
}
|
||||
|
||||
export type WorkspaceKanbanLaneDropRect = {
|
||||
@@ -31,6 +38,7 @@ export type WorkspaceKanbanCardDropTarget = {
|
||||
status: WorkspaceStatus | null
|
||||
isPinDrop: boolean
|
||||
dropIndex: number
|
||||
dropIndicatorY?: number
|
||||
laneRect?: WorkspaceKanbanLaneDropRect
|
||||
cardRects?: readonly WorkspaceKanbanCardDropRect[]
|
||||
}
|
||||
@@ -75,10 +83,51 @@ export function resolveWorkspaceCardDropIndexFromRects(
|
||||
for (let index = 0; index < rects.length; index++) {
|
||||
const rect = rects[index]!
|
||||
if (y < (rect.top + rect.bottom) / 2) {
|
||||
return index
|
||||
return rect.index ?? index
|
||||
}
|
||||
}
|
||||
return rects.length
|
||||
const last = rects.at(-1)
|
||||
return last?.index !== undefined ? last.index + 1 : rects.length
|
||||
}
|
||||
|
||||
function getRenderedCardLanePosition(rect: WorkspaceKanbanCardDropRect, fallback: number): number {
|
||||
return rect.index ?? fallback
|
||||
}
|
||||
|
||||
export function resolveWorkspaceCardDropIndicatorY(
|
||||
rects: readonly WorkspaceKanbanCardDropRect[],
|
||||
dropIndex: number,
|
||||
laneTop: number
|
||||
): number {
|
||||
if (rects.length === 0) {
|
||||
return laneTop + 14
|
||||
}
|
||||
// Why: with a virtualized lane the drop index counts unrendered cards too,
|
||||
// so place the line relative to the rendered card that owns that slot.
|
||||
const slot = rects.findIndex(
|
||||
(rect, index) => getRenderedCardLanePosition(rect, index) >= dropIndex
|
||||
)
|
||||
if (slot === -1) {
|
||||
return rects.at(-1)!.bottom + 5
|
||||
}
|
||||
if (slot === 0) {
|
||||
return rects[0]!.top - 5
|
||||
}
|
||||
return (rects[slot - 1]!.bottom + rects[slot]!.top) / 2
|
||||
}
|
||||
|
||||
function getLaneCardDropRects(lane: HTMLElement): WorkspaceKanbanCardDropRect[] {
|
||||
return Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR))
|
||||
.filter((card) => card.offsetParent !== null)
|
||||
.map((card) => {
|
||||
const rect = card.getBoundingClientRect()
|
||||
const index = Number.parseInt(card.dataset.workspaceBoardCardIndex ?? '', 10)
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
...(Number.isInteger(index) ? { index } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hasWorkspaceKanbanCardDropTarget(target: WorkspaceKanbanCardDropTarget): boolean {
|
||||
@@ -176,16 +225,18 @@ export function getCardDropTarget(
|
||||
|
||||
const laneContent = lane.querySelector<HTMLElement>('[data-workspace-board-lane-scroll]')
|
||||
const laneClientRect = (laneContent ?? lane).getBoundingClientRect()
|
||||
const cardRects = Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR))
|
||||
.filter((card) => card.offsetParent !== null)
|
||||
.map((card) => {
|
||||
const rect = card.getBoundingClientRect()
|
||||
return { top: rect.top, bottom: rect.bottom }
|
||||
})
|
||||
const dropIndex = resolveWorkspaceCardDropIndexFromRects(cardRects, y)
|
||||
const cardRects = getLaneCardDropRects(lane)
|
||||
const virtualDropIndex = laneContent
|
||||
? resolveWorkspaceKanbanVirtualLaneDropIndex(laneContent, y)
|
||||
: null
|
||||
const dropIndex = virtualDropIndex ?? resolveWorkspaceCardDropIndexFromRects(cardRects, y)
|
||||
const dropIndicatorY = laneContent
|
||||
? resolveWorkspaceKanbanVirtualLaneDropIndicatorY(laneContent, dropIndex)
|
||||
: null
|
||||
return {
|
||||
...target,
|
||||
dropIndex,
|
||||
...(dropIndicatorY !== null ? { dropIndicatorY } : {}),
|
||||
laneRect: {
|
||||
left: laneClientRect.left,
|
||||
top: laneClientRect.top,
|
||||
@@ -245,23 +296,10 @@ export function updateCardDropIndicator(
|
||||
width: fallbackLaneRect.width
|
||||
}
|
||||
: { left: 0, top: 0, width: 0 })
|
||||
const cardRects =
|
||||
target.cardRects ??
|
||||
Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR))
|
||||
.filter((card) => card.offsetParent !== null)
|
||||
.map((card) => {
|
||||
const rect = card.getBoundingClientRect()
|
||||
return { top: rect.top, bottom: rect.bottom }
|
||||
})
|
||||
const boundedDropIndex = Math.max(0, Math.min(cardRects.length, target.dropIndex))
|
||||
const cardRects = target.cardRects ?? getLaneCardDropRects(lane)
|
||||
const y =
|
||||
cardRects.length === 0
|
||||
? laneRect.top + 14
|
||||
: boundedDropIndex === 0
|
||||
? cardRects[0]!.top - 5
|
||||
: boundedDropIndex >= cardRects.length
|
||||
? cardRects.at(-1)!.bottom + 5
|
||||
: (cardRects[boundedDropIndex - 1]!.bottom + cardRects[boundedDropIndex]!.top) / 2
|
||||
target.dropIndicatorY ??
|
||||
resolveWorkspaceCardDropIndicatorY(cardRects, Math.max(0, target.dropIndex), laneRect.top)
|
||||
const horizontalInset = 8
|
||||
const indicator = getOrCreateDropIndicator()
|
||||
indicator.dataset.workspaceStatus = target.status
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
getWorkspaceKanbanSidebarDropGroups,
|
||||
getWorkspaceKanbanSidebarDropTarget,
|
||||
isWorkspaceKanbanSidebarDropPointInBoard,
|
||||
registerWorkspaceKanbanSidebarDropGroups,
|
||||
resolveWorkspaceKanbanSidebarFullLaneDropIndex,
|
||||
updateWorkspaceKanbanSidebarDropTargetVisual
|
||||
} from './workspace-kanban-sidebar-drop'
|
||||
import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout'
|
||||
|
||||
const workspaceStatuses: WorkspaceStatusDefinition[] = [
|
||||
{ id: 'todo', label: 'Todo' },
|
||||
@@ -29,6 +31,7 @@ class FakeElement extends FakeNode {
|
||||
}
|
||||
}
|
||||
offsetParent: FakeElement | null = null
|
||||
scrollTop = 0
|
||||
private readonly attributes = new Map<string, string>()
|
||||
private rect: Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom' | 'width' | 'height'> = {
|
||||
left: 0,
|
||||
@@ -159,6 +162,7 @@ class FakeDocument {
|
||||
}
|
||||
|
||||
let fakeDocument: FakeDocument
|
||||
let unregisterSidebarDropGroups: (() => void) | null = null
|
||||
|
||||
function setRect(
|
||||
element: HTMLElement,
|
||||
@@ -221,6 +225,8 @@ function worktree(args: {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSidebarDropGroups?.()
|
||||
unregisterSidebarDropGroups = null
|
||||
clearWorkspaceKanbanSidebarDropTargetVisual()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
@@ -249,6 +255,71 @@ describe('workspace kanban sidebar drop DOM bridge', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers complete registered groups and restores the DOM fallback on cleanup', () => {
|
||||
appendBoard()
|
||||
unregisterSidebarDropGroups = registerWorkspaceKanbanSidebarDropGroups([
|
||||
{ key: 'doing', worktreeIds: ['doing-a', 'doing-b', 'doing-c', 'doing-d'] }
|
||||
])
|
||||
|
||||
expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([
|
||||
{ key: 'doing', worktreeIds: ['doing-a', 'doing-b', 'doing-c', 'doing-d'] }
|
||||
])
|
||||
|
||||
unregisterSidebarDropGroups()
|
||||
unregisterSidebarDropGroups = null
|
||||
expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([
|
||||
{ key: 'doing', worktreeIds: ['doing-a', 'doing-b'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the full virtual layout when the mounted card window is stale', () => {
|
||||
const { lane } = appendBoard()
|
||||
const laneScroll = document.createElement('div')
|
||||
laneScroll.setAttribute('data-workspace-board-lane-scroll', '')
|
||||
setRect(laneScroll, {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 200,
|
||||
bottom: 220,
|
||||
width: 200,
|
||||
height: 220
|
||||
})
|
||||
const spacer = document.createElement('div')
|
||||
setRect(spacer, {
|
||||
left: 8,
|
||||
top: -4260,
|
||||
right: 192,
|
||||
bottom: 220,
|
||||
width: 184,
|
||||
height: 4480
|
||||
})
|
||||
laneScroll.append(spacer)
|
||||
lane.append(laneScroll)
|
||||
setElementFromPoint(lane)
|
||||
const unregister = registerWorkspaceKanbanVirtualLaneLayout({
|
||||
scrollElement: laneScroll,
|
||||
spacerElement: spacer,
|
||||
getItemIds: () => Array.from({ length: 102 }, (_, index) => `doing-${index}`),
|
||||
getMeasurements: () =>
|
||||
Array.from({ length: 102 }, (_, index) => ({
|
||||
index,
|
||||
start: index * 44,
|
||||
end: index * 44 + 36
|
||||
}))
|
||||
})
|
||||
|
||||
expect(getWorkspaceKanbanSidebarDropTarget(24, 210)).toMatchObject({
|
||||
status: 'doing',
|
||||
dropIndex: 102,
|
||||
dropIndicatorY: 225,
|
||||
cardRects: [
|
||||
{ top: 8, bottom: 48 },
|
||||
{ top: 56, bottom: 96 }
|
||||
]
|
||||
})
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('prefers the published full lane membership over the rendered card scan', () => {
|
||||
const { lane } = appendBoard()
|
||||
lane.dataset.workspaceLaneFullIds =
|
||||
|
||||
@@ -29,6 +29,7 @@ const BOARD_SHEET_SELECTOR = '[data-workspace-board-sheet]'
|
||||
const EXTERNAL_DRAG_TARGET_ATTR = 'data-workspace-board-external-drag-target'
|
||||
|
||||
let externalDragTargetElement: HTMLElement | null = null
|
||||
let logicalDropGroupsRegistration: { groups: readonly WorktreeDragGroup[] } | null = null
|
||||
|
||||
function getWorkspaceKanbanBoardElement(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>(BOARD_SELECTOR)
|
||||
@@ -97,7 +98,26 @@ export function clearWorkspaceKanbanSidebarDropTargetVisual(): void {
|
||||
removeCardDropIndicator()
|
||||
}
|
||||
|
||||
export function registerWorkspaceKanbanSidebarDropGroups(
|
||||
groups: readonly WorktreeDragGroup[]
|
||||
): () => void {
|
||||
const registration = { groups }
|
||||
logicalDropGroupsRegistration = registration
|
||||
return () => {
|
||||
if (logicalDropGroupsRegistration === registration) {
|
||||
logicalDropGroupsRegistration = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] {
|
||||
if (logicalDropGroupsRegistration) {
|
||||
return logicalDropGroupsRegistration.groups.map((group) => ({
|
||||
key: group.key,
|
||||
worktreeIds: [...group.worktreeIds]
|
||||
}))
|
||||
}
|
||||
|
||||
const board = getWorkspaceKanbanBoardElement()
|
||||
if (!board) {
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAreaSelectionCardRects } from './workspace-kanban-area-selection-card-rects'
|
||||
import { getAreaSelectionCardIds } from './workspace-kanban-area-selection-dom'
|
||||
import {
|
||||
getWorkspaceKanbanVirtualLaneItemRects,
|
||||
registerWorkspaceKanbanVirtualLaneLayout,
|
||||
resolveWorkspaceKanbanVirtualLaneDropIndex,
|
||||
resolveWorkspaceKanbanVirtualLaneDropIndicatorY
|
||||
} from './workspace-kanban-virtual-lane-layout'
|
||||
|
||||
const ITEM_COUNT = 102
|
||||
const ITEM_STRIDE = 44
|
||||
const ITEM_HEIGHT = 36
|
||||
|
||||
function createLayoutElements(): {
|
||||
scrollElement: HTMLElement
|
||||
spacerElement: HTMLElement
|
||||
} {
|
||||
const scrollElement = {
|
||||
scrollTop: 0,
|
||||
getBoundingClientRect: () => ({
|
||||
left: 20,
|
||||
top: 100,
|
||||
right: 220,
|
||||
bottom: 600,
|
||||
width: 200,
|
||||
height: 500
|
||||
})
|
||||
} as unknown as HTMLElement
|
||||
const spacerElement = {
|
||||
getBoundingClientRect: () => ({
|
||||
left: 26,
|
||||
top: 108 - scrollElement.scrollTop,
|
||||
right: 214,
|
||||
bottom: 108 - scrollElement.scrollTop + ITEM_COUNT * ITEM_STRIDE,
|
||||
width: 188,
|
||||
height: ITEM_COUNT * ITEM_STRIDE
|
||||
})
|
||||
} as unknown as HTMLElement
|
||||
return { scrollElement, spacerElement }
|
||||
}
|
||||
|
||||
function registerLayout(scrollElement: HTMLElement, spacerElement: HTMLElement): () => void {
|
||||
return registerWorkspaceKanbanVirtualLaneLayout({
|
||||
scrollElement,
|
||||
spacerElement,
|
||||
getItemIds: () => Array.from({ length: ITEM_COUNT }, (_, index) => `w${index}`),
|
||||
getMeasurements: () =>
|
||||
Array.from({ length: ITEM_COUNT }, (_, index) => ({
|
||||
index,
|
||||
start: index * ITEM_STRIDE,
|
||||
end: index * ITEM_STRIDE + ITEM_HEIGHT
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
describe('workspace kanban virtual lane layout', () => {
|
||||
it('resolves mid-lane and bottom drops immediately after a large scroll jump', () => {
|
||||
const { scrollElement, spacerElement } = createLayoutElements()
|
||||
const unregister = registerLayout(scrollElement, spacerElement)
|
||||
scrollElement.scrollTop = 3_980
|
||||
|
||||
expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 370)).toBe(97)
|
||||
expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 590)).toBe(102)
|
||||
expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 102)).toBe(613)
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('exposes every logical item even when no card is mounted', () => {
|
||||
const { scrollElement, spacerElement } = createLayoutElements()
|
||||
const unregister = registerLayout(scrollElement, spacerElement)
|
||||
scrollElement.scrollTop = 3_980
|
||||
|
||||
const rects = getWorkspaceKanbanVirtualLaneItemRects(scrollElement)
|
||||
expect(rects).toHaveLength(ITEM_COUNT)
|
||||
expect(rects?.[50]).toMatchObject({
|
||||
id: 'w50',
|
||||
index: 50,
|
||||
contentTop: 2_208,
|
||||
contentBottom: 2_244
|
||||
})
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('selects intermediate never-mounted cards across a jumped lane range', () => {
|
||||
const { scrollElement, spacerElement } = createLayoutElements()
|
||||
const unregister = registerLayout(scrollElement, spacerElement)
|
||||
scrollElement.scrollTop = 3_980
|
||||
const board = {
|
||||
querySelectorAll: (selector: string) =>
|
||||
selector === '[data-workspace-board-lane-scroll]' ? [scrollElement] : []
|
||||
} as unknown as HTMLElement
|
||||
|
||||
const cardRects = getAreaSelectionCardRects(board)
|
||||
const selectedIds = getAreaSelectionCardIds(
|
||||
cardRects,
|
||||
{ left: 0, top: 110, width: 300, height: 480 },
|
||||
{
|
||||
scrollStartContentYByElement: new Map([[scrollElement, 18]]),
|
||||
currentY: 590
|
||||
}
|
||||
)
|
||||
|
||||
expect(cardRects.every((card) => card.element === null)).toBe(true)
|
||||
expect(selectedIds).toHaveLength(ITEM_COUNT)
|
||||
expect(selectedIds).toContain('w50')
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('does not let stale cleanup remove a newer lane registration', () => {
|
||||
const { scrollElement, spacerElement } = createLayoutElements()
|
||||
const unregisterFirst = registerWorkspaceKanbanVirtualLaneLayout({
|
||||
scrollElement,
|
||||
spacerElement,
|
||||
getItemIds: () => ['old'],
|
||||
getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }]
|
||||
})
|
||||
const unregisterSecond = registerWorkspaceKanbanVirtualLaneLayout({
|
||||
scrollElement,
|
||||
spacerElement,
|
||||
getItemIds: () => ['new'],
|
||||
getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }]
|
||||
})
|
||||
|
||||
unregisterFirst()
|
||||
expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)?.map((item) => item.id)).toEqual([
|
||||
'new'
|
||||
])
|
||||
unregisterSecond()
|
||||
expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull()
|
||||
})
|
||||
|
||||
it('places empty-lane and boundary drop indicators from the spacer', () => {
|
||||
const { scrollElement, spacerElement } = createLayoutElements()
|
||||
const unregisterEmpty = registerWorkspaceKanbanVirtualLaneLayout({
|
||||
scrollElement,
|
||||
spacerElement,
|
||||
getItemIds: () => [],
|
||||
getMeasurements: () => []
|
||||
})
|
||||
expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 200)).toBe(0)
|
||||
expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 0)).toBe(114)
|
||||
unregisterEmpty()
|
||||
|
||||
const unregister = registerLayout(scrollElement, spacerElement)
|
||||
expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 90)).toBe(0)
|
||||
expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 0)).toBe(103)
|
||||
expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 1)).toBe(148)
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('returns null when measurements are incomplete or mismatched', () => {
|
||||
const { scrollElement, spacerElement } = createLayoutElements()
|
||||
const unregisterShort = registerWorkspaceKanbanVirtualLaneLayout({
|
||||
scrollElement,
|
||||
spacerElement,
|
||||
getItemIds: () => ['a', 'b'],
|
||||
getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }]
|
||||
})
|
||||
expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull()
|
||||
expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 120)).toBeNull()
|
||||
unregisterShort()
|
||||
|
||||
const unregisterBadIndex = registerWorkspaceKanbanVirtualLaneLayout({
|
||||
scrollElement,
|
||||
spacerElement,
|
||||
getItemIds: () => ['a'],
|
||||
getMeasurements: () => [{ index: 3, start: 0, end: ITEM_HEIGHT }]
|
||||
})
|
||||
expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull()
|
||||
unregisterBadIndex()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { VirtualItem } from '@tanstack/react-virtual'
|
||||
|
||||
type VirtualLaneLayoutRegistration = {
|
||||
spacerElement: HTMLElement
|
||||
getItemIds: () => readonly string[]
|
||||
getMeasurements: () => readonly Pick<VirtualItem, 'index' | 'start' | 'end'>[]
|
||||
}
|
||||
|
||||
type VirtualLaneLayoutSnapshot = {
|
||||
registration: VirtualLaneLayoutRegistration
|
||||
itemIds: readonly string[]
|
||||
measurements: readonly Pick<VirtualItem, 'index' | 'start' | 'end'>[]
|
||||
}
|
||||
|
||||
export type WorkspaceKanbanVirtualLaneItemRect = {
|
||||
id: string
|
||||
index: number
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
contentTop: number
|
||||
contentBottom: number
|
||||
}
|
||||
|
||||
const virtualLaneLayouts = new WeakMap<HTMLElement, VirtualLaneLayoutRegistration>()
|
||||
|
||||
export function registerWorkspaceKanbanVirtualLaneLayout(args: {
|
||||
scrollElement: HTMLElement
|
||||
spacerElement: HTMLElement
|
||||
getItemIds: () => readonly string[]
|
||||
getMeasurements: () => readonly Pick<VirtualItem, 'index' | 'start' | 'end'>[]
|
||||
}): () => void {
|
||||
const registration = {
|
||||
spacerElement: args.spacerElement,
|
||||
getItemIds: args.getItemIds,
|
||||
getMeasurements: args.getMeasurements
|
||||
}
|
||||
virtualLaneLayouts.set(args.scrollElement, registration)
|
||||
return () => {
|
||||
if (virtualLaneLayouts.get(args.scrollElement) === registration) {
|
||||
virtualLaneLayouts.delete(args.scrollElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceKanbanVirtualLaneItemRects(
|
||||
scrollElement: HTMLElement
|
||||
): WorkspaceKanbanVirtualLaneItemRect[] | null {
|
||||
const snapshot = getVirtualLaneLayoutSnapshot(scrollElement)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const spacerRect = snapshot.registration.spacerElement.getBoundingClientRect()
|
||||
const containerRect = scrollElement.getBoundingClientRect()
|
||||
const contentOffset = spacerRect.top - containerRect.top + scrollElement.scrollTop
|
||||
const rects: WorkspaceKanbanVirtualLaneItemRect[] = []
|
||||
for (let index = 0; index < snapshot.itemIds.length; index++) {
|
||||
const measurement = snapshot.measurements[index]
|
||||
if (!isValidMeasurement(measurement, index)) {
|
||||
return null
|
||||
}
|
||||
rects.push({
|
||||
id: snapshot.itemIds[index]!,
|
||||
index,
|
||||
left: spacerRect.left,
|
||||
top: spacerRect.top + measurement.start,
|
||||
right: spacerRect.right,
|
||||
bottom: spacerRect.top + measurement.end,
|
||||
contentTop: contentOffset + measurement.start,
|
||||
contentBottom: contentOffset + measurement.end
|
||||
})
|
||||
}
|
||||
return rects
|
||||
}
|
||||
|
||||
export function resolveWorkspaceKanbanVirtualLaneDropIndex(
|
||||
scrollElement: HTMLElement,
|
||||
pointerY: number
|
||||
): number | null {
|
||||
const snapshot = getVirtualLaneLayoutSnapshot(scrollElement)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const spacerTop = snapshot.registration.spacerElement.getBoundingClientRect().top
|
||||
let low = 0
|
||||
let high = snapshot.itemIds.length
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2)
|
||||
const measurement = snapshot.measurements[middle]
|
||||
if (!isValidMeasurement(measurement, middle)) {
|
||||
return null
|
||||
}
|
||||
if (pointerY < spacerTop + (measurement.start + measurement.end) / 2) {
|
||||
high = middle
|
||||
} else {
|
||||
low = middle + 1
|
||||
}
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
export function resolveWorkspaceKanbanVirtualLaneDropIndicatorY(
|
||||
scrollElement: HTMLElement,
|
||||
dropIndex: number
|
||||
): number | null {
|
||||
const snapshot = getVirtualLaneLayoutSnapshot(scrollElement)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
if (snapshot.itemIds.length === 0) {
|
||||
return scrollElement.getBoundingClientRect().top + 14
|
||||
}
|
||||
|
||||
const spacerTop = snapshot.registration.spacerElement.getBoundingClientRect().top
|
||||
const boundedIndex = Math.max(0, Math.min(snapshot.itemIds.length, dropIndex))
|
||||
if (boundedIndex === 0) {
|
||||
const first = snapshot.measurements[0]
|
||||
return isValidMeasurement(first, 0) ? spacerTop + first.start - 5 : null
|
||||
}
|
||||
if (boundedIndex === snapshot.itemIds.length) {
|
||||
const lastIndex = snapshot.itemIds.length - 1
|
||||
const last = snapshot.measurements[lastIndex]
|
||||
return isValidMeasurement(last, lastIndex) ? spacerTop + last.end + 5 : null
|
||||
}
|
||||
const previous = snapshot.measurements[boundedIndex - 1]
|
||||
const next = snapshot.measurements[boundedIndex]
|
||||
if (!isValidMeasurement(previous, boundedIndex - 1) || !isValidMeasurement(next, boundedIndex)) {
|
||||
return null
|
||||
}
|
||||
return spacerTop + (previous.end + next.start) / 2
|
||||
}
|
||||
|
||||
function getVirtualLaneLayoutSnapshot(
|
||||
scrollElement: HTMLElement
|
||||
): VirtualLaneLayoutSnapshot | null {
|
||||
const registration = virtualLaneLayouts.get(scrollElement)
|
||||
if (!registration) {
|
||||
return null
|
||||
}
|
||||
const itemIds = registration.getItemIds()
|
||||
const measurements = registration.getMeasurements()
|
||||
return measurements.length >= itemIds.length ? { registration, itemIds, measurements } : null
|
||||
}
|
||||
|
||||
function isValidMeasurement(
|
||||
measurement: Pick<VirtualItem, 'index' | 'start' | 'end'> | undefined,
|
||||
index: number
|
||||
): measurement is Pick<VirtualItem, 'index' | 'start' | 'end'> {
|
||||
return Boolean(
|
||||
measurement &&
|
||||
measurement.index === index &&
|
||||
Number.isFinite(measurement.start) &&
|
||||
Number.isFinite(measurement.end) &&
|
||||
measurement.end >= measurement.start
|
||||
)
|
||||
}
|
||||
@@ -1866,7 +1866,10 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
stateStartedAt: NOW - 1_000,
|
||||
agentType: 'codex',
|
||||
paneKey: hostPaneKey,
|
||||
tabId: 'host-tab-1',
|
||||
worktreeId: WT,
|
||||
terminalTitle: 'codex [working]',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
@@ -1882,6 +1885,9 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
prompt: 'fix web parity',
|
||||
agentType: 'codex',
|
||||
paneKey: mirroredPaneKey,
|
||||
tabId: mirroredId,
|
||||
worktreeId: WT,
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
terminalTitle: 'codex [working]'
|
||||
})
|
||||
expect(patch.agentStatusByPaneKey?.[hostPaneKey]).toBeUndefined()
|
||||
@@ -1889,7 +1895,7 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
expect(patch.sortEpoch).toBe(1)
|
||||
})
|
||||
|
||||
it('bumps aggregate epochs when a mirrored same-state entry gains attribution', () => {
|
||||
it('repairs mirrored same-state attribution and retains identity from an older snapshot', () => {
|
||||
const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID)
|
||||
const snapshot = makeSnapshot([
|
||||
{
|
||||
@@ -1910,6 +1916,7 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
paneKey: hostPaneKey,
|
||||
worktreeId: WT,
|
||||
tabId: 'host-tab-1',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
@@ -1922,11 +1929,15 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
) as Partial<WebSessionTabsSyncState>
|
||||
const mirroredPaneKey = Object.keys(initial.agentStatusByPaneKey ?? {})[0]!
|
||||
const existing = initial.agentStatusByPaneKey![mirroredPaneKey]!
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
const attributionPatch = applyWebSessionTabsSnapshot(
|
||||
makeState({
|
||||
...initial,
|
||||
agentStatusByPaneKey: {
|
||||
[mirroredPaneKey]: { ...existing, worktreeId: 'stale-worktree', tabId: 'stale-tab' }
|
||||
[mirroredPaneKey]: {
|
||||
...existing,
|
||||
worktreeId: 'stale-worktree',
|
||||
tabId: 'stale-tab'
|
||||
}
|
||||
},
|
||||
agentStatusEpoch: 7,
|
||||
sortEpoch: 11
|
||||
@@ -1936,8 +1947,109 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
NOW
|
||||
) as Partial<WebSessionTabsSyncState>
|
||||
|
||||
expect(patch.agentStatusEpoch).toBe(8)
|
||||
expect(patch.sortEpoch).toBe(12)
|
||||
expect(attributionPatch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({
|
||||
worktreeId: existing.worktreeId,
|
||||
tabId: existing.tabId
|
||||
})
|
||||
expect(attributionPatch.agentStatusEpoch).toBe(8)
|
||||
expect(attributionPatch.sortEpoch).toBe(12)
|
||||
|
||||
const fresherAttributionPatch = applyWebSessionTabsSnapshot(
|
||||
makeState({
|
||||
...initial,
|
||||
agentStatusByPaneKey: {
|
||||
[mirroredPaneKey]: {
|
||||
...existing,
|
||||
updatedAt: NOW,
|
||||
worktreeId: 'stale-worktree',
|
||||
tabId: 'stale-tab',
|
||||
providerSession: undefined
|
||||
}
|
||||
},
|
||||
agentStatusEpoch: 7,
|
||||
sortEpoch: 11
|
||||
}),
|
||||
{ ...snapshot, snapshotVersion: 3 },
|
||||
ENV,
|
||||
NOW
|
||||
) as Partial<WebSessionTabsSyncState>
|
||||
|
||||
expect(fresherAttributionPatch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({
|
||||
worktreeId: existing.worktreeId,
|
||||
tabId: existing.tabId,
|
||||
updatedAt: NOW,
|
||||
providerSession: { key: 'session_id', id: 'session-1' }
|
||||
})
|
||||
expect(fresherAttributionPatch.agentStatusEpoch).toBe(8)
|
||||
expect(fresherAttributionPatch.sortEpoch).toBe(12)
|
||||
|
||||
const identityPatch = applyWebSessionTabsSnapshot(
|
||||
makeState({
|
||||
...initial,
|
||||
...attributionPatch,
|
||||
agentStatusByPaneKey: {
|
||||
[mirroredPaneKey]: {
|
||||
...attributionPatch.agentStatusByPaneKey![mirroredPaneKey]!,
|
||||
updatedAt: NOW,
|
||||
providerSession: undefined
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ ...snapshot, snapshotVersion: 4 },
|
||||
ENV,
|
||||
NOW
|
||||
) as Partial<WebSessionTabsSyncState>
|
||||
|
||||
expect(identityPatch.agentStatusByPaneKey?.[mirroredPaneKey]?.providerSession).toEqual({
|
||||
key: 'session_id',
|
||||
id: 'session-1'
|
||||
})
|
||||
expect(identityPatch.agentStatusByPaneKey?.[mirroredPaneKey]?.updatedAt).toBe(NOW)
|
||||
expect(identityPatch.agentStatusEpoch).toBe(8)
|
||||
expect(identityPatch.sortEpoch).toBe(12)
|
||||
|
||||
const nextTurnPatch = applyWebSessionTabsSnapshot(
|
||||
makeState({
|
||||
...initial,
|
||||
agentStatusByPaneKey: {
|
||||
[mirroredPaneKey]: {
|
||||
...existing,
|
||||
state: 'working',
|
||||
updatedAt: NOW,
|
||||
stateStartedAt: NOW,
|
||||
worktreeId: 'stale-worktree',
|
||||
tabId: 'stale-tab',
|
||||
providerSession: undefined
|
||||
}
|
||||
}
|
||||
}),
|
||||
{
|
||||
...snapshot,
|
||||
snapshotVersion: 5,
|
||||
tabs: snapshot.tabs.map((tab) =>
|
||||
tab.type === 'terminal' && tab.agentStatus
|
||||
? {
|
||||
...tab,
|
||||
agentStatus: {
|
||||
...tab.agentStatus,
|
||||
state: 'done',
|
||||
providerSession: { key: 'session_id', id: 'previous-session' }
|
||||
}
|
||||
}
|
||||
: tab
|
||||
)
|
||||
},
|
||||
ENV,
|
||||
NOW
|
||||
) as Partial<WebSessionTabsSyncState>
|
||||
|
||||
expect(nextTurnPatch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({
|
||||
state: 'working',
|
||||
stateStartedAt: NOW,
|
||||
worktreeId: WT,
|
||||
tabId: existing.tabId
|
||||
})
|
||||
expect(nextTurnPatch.agentStatusByPaneKey?.[mirroredPaneKey]?.providerSession).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps mirrored OMP tabs from repainting to Pi-compatible titles', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry
|
||||
} from '../../../shared/agent-status-types'
|
||||
import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionBrowserTab,
|
||||
@@ -674,7 +675,8 @@ function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null
|
||||
})
|
||||
return {
|
||||
...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent),
|
||||
paneKey
|
||||
paneKey,
|
||||
tabId: toWebTerminalSurfaceTabId(surface.parentTabId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,10 +713,23 @@ function buildMirroredAgentStatusPatch(
|
||||
continue
|
||||
}
|
||||
const existing = state.agentStatusByPaneKey[entry.paneKey]
|
||||
// Why: an active web stream can report a fresher OSC 9999 status before the next host snapshot, so don't rewind it with an older host publication.
|
||||
// Why: keep fresher OSC state while taking remapped ownership metadata from the authoritative host snapshot.
|
||||
const hostIdentityPredatesCurrentTurn =
|
||||
existing !== undefined &&
|
||||
entry.state === 'done' &&
|
||||
existing.state !== 'done' &&
|
||||
existing.stateStartedAt > entry.stateStartedAt
|
||||
const nextEntry =
|
||||
existing && existing.updatedAt > entry.updatedAt
|
||||
? normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType)
|
||||
? {
|
||||
...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType),
|
||||
paneKey: entry.paneKey,
|
||||
worktreeId: entry.worktreeId ?? existing.worktreeId,
|
||||
tabId: entry.tabId,
|
||||
providerSession:
|
||||
existing.providerSession ??
|
||||
(hostIdentityPredatesCurrentTurn ? undefined : entry.providerSession)
|
||||
}
|
||||
: entry
|
||||
nextByPaneKey.set(entry.paneKey, nextEntry)
|
||||
}
|
||||
@@ -1346,6 +1361,7 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn
|
||||
a.lastAssistantMessage === b.lastAssistantMessage &&
|
||||
a.interrupted === b.interrupted &&
|
||||
a.promptInteractionKey === b.promptInteractionKey &&
|
||||
agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) &&
|
||||
sameAgentStateHistory(a.stateHistory, b.stateHistory)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,14 +23,29 @@ const remoteRepo: Repo = {
|
||||
addedAt: 1
|
||||
}
|
||||
|
||||
const staleRemoteRepo: Repo = {
|
||||
...remoteRepo,
|
||||
path: '/remote-stale',
|
||||
displayName: 'Remote stale'
|
||||
}
|
||||
|
||||
const freshRemoteRepo: Repo = {
|
||||
...remoteRepo,
|
||||
path: '/remote-fresh',
|
||||
displayName: 'Remote fresh'
|
||||
}
|
||||
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const reposList = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
reposList.mockReset()
|
||||
reposList.mockResolvedValue([localRepo])
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
repos: { list: vi.fn().mockResolvedValue([localRepo]) },
|
||||
repos: { list: reposList },
|
||||
projects: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
listHostSetups: vi.fn().mockResolvedValue([])
|
||||
@@ -46,6 +61,185 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('fetchReposForAllHosts generation', () => {
|
||||
it('settles startup hydration after the local catalog without waiting for remotes', async () => {
|
||||
let resolveRemote!: (value: unknown) => void
|
||||
let markRemoteStarted!: () => void
|
||||
const remote = new Promise((resolve) => {
|
||||
resolveRemote = resolve
|
||||
})
|
||||
const remoteStarted = new Promise<void>((resolve) => {
|
||||
markRemoteStarted = resolve
|
||||
})
|
||||
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
if (args.method === 'repo.list') {
|
||||
markRemoteStarted()
|
||||
return remote
|
||||
}
|
||||
return {
|
||||
id: 'rpc-other',
|
||||
ok: true,
|
||||
result: { projects: [], setups: [] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
|
||||
const load = store.getState().fetchReposForAllHosts()
|
||||
await remoteStarted
|
||||
await store.getState().awaitLocalRepoCatalogSettlement()
|
||||
|
||||
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo'])
|
||||
|
||||
resolveRemote({
|
||||
id: 'rpc-repo-list',
|
||||
ok: true,
|
||||
result: { repos: [remoteRepo] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
await load
|
||||
})
|
||||
|
||||
it('does not let a remote refresh supersede or delay local startup settlement', async () => {
|
||||
let resolveLocal!: (repos: Repo[]) => void
|
||||
let resolveRemote!: (value: unknown) => void
|
||||
const local = new Promise<Repo[]>((resolve) => {
|
||||
resolveLocal = resolve
|
||||
})
|
||||
const remote = new Promise((resolve) => {
|
||||
resolveRemote = resolve
|
||||
})
|
||||
reposList.mockReturnValueOnce(local)
|
||||
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
if (args.method === 'repo.list') {
|
||||
return remote
|
||||
}
|
||||
return {
|
||||
id: 'rpc-other',
|
||||
ok: true,
|
||||
result: { projects: [], setups: [] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
|
||||
|
||||
const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
|
||||
const remoteRefresh = store.getState().fetchRepos()
|
||||
let remoteSettled = false
|
||||
void remoteRefresh.then(() => {
|
||||
remoteSettled = true
|
||||
})
|
||||
|
||||
resolveLocal([localRepo])
|
||||
await startup
|
||||
await store.getState().awaitLocalRepoCatalogSettlement()
|
||||
|
||||
expect(remoteSettled).toBe(false)
|
||||
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo'])
|
||||
|
||||
resolveRemote({
|
||||
id: 'rpc-repo-list',
|
||||
ok: true,
|
||||
result: { repos: [remoteRepo] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
await remoteRefresh
|
||||
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo'])
|
||||
})
|
||||
|
||||
it('keeps a newer Connect-flow catalog when an older all-host response resolves last', async () => {
|
||||
let resolveOlder!: (value: unknown) => void
|
||||
let markOlderStarted!: () => void
|
||||
const older = new Promise((resolve) => {
|
||||
resolveOlder = resolve
|
||||
})
|
||||
const olderStarted = new Promise<void>((resolve) => {
|
||||
markOlderStarted = resolve
|
||||
})
|
||||
let repoListCalls = 0
|
||||
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
if (args.method !== 'repo.list') {
|
||||
return {
|
||||
id: 'rpc-other',
|
||||
ok: true,
|
||||
result: { projects: [], setups: [] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
}
|
||||
repoListCalls++
|
||||
if (repoListCalls === 1) {
|
||||
markOlderStarted()
|
||||
return older
|
||||
}
|
||||
return {
|
||||
id: 'rpc-fresh',
|
||||
ok: true,
|
||||
result: { repos: [freshRemoteRepo] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
|
||||
const allHosts = store.getState().fetchReposForAllHosts()
|
||||
await olderStarted
|
||||
await store.getState().fetchRuntimeEnvironmentRepos('env-1')
|
||||
|
||||
resolveOlder({
|
||||
id: 'rpc-stale',
|
||||
ok: true,
|
||||
result: { repos: [staleRemoteRepo] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
await allHosts
|
||||
|
||||
expect(store.getState().repos.find((repo) => repo.id === remoteRepo.id)?.path).toBe(
|
||||
freshRemoteRepo.path
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a newer all-host catalog when an older Connect-flow response resolves last', async () => {
|
||||
let resolveOlder!: (value: unknown) => void
|
||||
const older = new Promise((resolve) => {
|
||||
resolveOlder = resolve
|
||||
})
|
||||
let repoListCalls = 0
|
||||
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
if (args.method !== 'repo.list') {
|
||||
return {
|
||||
id: 'rpc-other',
|
||||
ok: true,
|
||||
result: { projects: [], setups: [] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
}
|
||||
repoListCalls++
|
||||
return repoListCalls === 1
|
||||
? older
|
||||
: {
|
||||
id: 'rpc-fresh',
|
||||
ok: true,
|
||||
result: { repos: [freshRemoteRepo] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
|
||||
const connect = store.getState().fetchRuntimeEnvironmentRepos('env-1')
|
||||
await store.getState().fetchReposForAllHosts()
|
||||
|
||||
resolveOlder({
|
||||
id: 'rpc-stale',
|
||||
ok: true,
|
||||
result: { repos: [staleRemoteRepo] },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
await connect
|
||||
|
||||
expect(store.getState().repos.find((repo) => repo.id === remoteRepo.id)?.path).toBe(
|
||||
freshRemoteRepo.path
|
||||
)
|
||||
})
|
||||
|
||||
it('does not validate repo UI from a superseded refresh', async () => {
|
||||
let resolveOlderRemote!: (value: unknown) => void
|
||||
let resolveNewerRemote!: (value: unknown) => void
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { createTestStore, makeLayout, makeTab } from './store-test-helpers'
|
||||
import type { Repo, WorkspaceSessionState } from '../../../../shared/types'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
|
||||
const localRepo: Repo = {
|
||||
@@ -20,6 +21,34 @@ const remoteRepo: Repo = {
|
||||
}
|
||||
|
||||
const reposList = vi.fn()
|
||||
const promotionWorktreeId = 'local-repo::/local'
|
||||
const promotionTabId = 'restored-tab'
|
||||
const promotionPtyId = 'original-daemon-pty'
|
||||
|
||||
function makePromotionSession(): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: localRepo.id,
|
||||
activeWorktreeId: promotionWorktreeId,
|
||||
activeTabId: promotionTabId,
|
||||
activeWorktreeIdsOnShutdown: [promotionWorktreeId],
|
||||
tabsByWorktree: {
|
||||
[promotionWorktreeId]: [
|
||||
makeTab({
|
||||
id: promotionTabId,
|
||||
worktreeId: promotionWorktreeId,
|
||||
ptyId: promotionPtyId
|
||||
})
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[promotionTabId]: {
|
||||
...makeLayout(),
|
||||
ptyIdsByLeafId: { 'pane:1': promotionPtyId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
@@ -74,4 +103,96 @@ describe('repos slice stale-fetch race (#7020)', () => {
|
||||
await stale
|
||||
expect(store.getState().repos).toEqual([])
|
||||
})
|
||||
|
||||
it('waits for the superseding catalog fetch before startup hydration', async () => {
|
||||
let resolveStartup!: (repos: Repo[]) => void
|
||||
let resolveRefresh!: (repos: Repo[]) => void
|
||||
const startupRepos = new Promise<Repo[]>((resolve) => {
|
||||
resolveStartup = resolve
|
||||
})
|
||||
const refreshedRepos = new Promise<Repo[]>((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})
|
||||
reposList.mockReturnValueOnce(startupRepos).mockReturnValueOnce(refreshedRepos)
|
||||
const store = createTestStore()
|
||||
|
||||
const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
|
||||
const refresh = store.getState().fetchRepos()
|
||||
resolveStartup([localRepo])
|
||||
await startup
|
||||
|
||||
let settled = false
|
||||
const settlement = store
|
||||
.getState()
|
||||
.awaitLocalRepoCatalogSettlement()
|
||||
.then(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
resolveRefresh([localRepo])
|
||||
await Promise.all([refresh, settlement])
|
||||
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo'])
|
||||
|
||||
store.getState().hydrateWorkspaceSession(makePromotionSession())
|
||||
|
||||
expect(store.getState().activeWorktreeId).toBe(promotionWorktreeId)
|
||||
expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({
|
||||
[promotionTabId]: promotionPtyId
|
||||
})
|
||||
expect(store.getState().terminalLayoutsByTabId[promotionTabId]?.ptyIdsByLeafId).toEqual({
|
||||
'pane:1': promotionPtyId
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for a refresh started after initial settlement before startup hydration', async () => {
|
||||
let resolveRefresh!: (repos: Repo[]) => void
|
||||
const refreshedRepos = new Promise<Repo[]>((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})
|
||||
reposList.mockResolvedValueOnce([localRepo]).mockReturnValueOnce(refreshedRepos)
|
||||
const store = createTestStore()
|
||||
|
||||
await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
|
||||
await store.getState().awaitLocalRepoCatalogSettlement()
|
||||
const refresh = store.getState().fetchRepos()
|
||||
let hydrated = false
|
||||
const hydration = (async () => {
|
||||
await store.getState().awaitLocalRepoCatalogSettlement()
|
||||
store.getState().hydrateWorkspaceSession(makePromotionSession())
|
||||
hydrated = true
|
||||
})()
|
||||
|
||||
await Promise.resolve()
|
||||
expect(hydrated).toBe(false)
|
||||
|
||||
resolveRefresh([localRepo])
|
||||
await Promise.all([refresh, hydration])
|
||||
expect(store.getState().activeWorktreeId).toBe(promotionWorktreeId)
|
||||
expect(store.getState().pendingReconnectPtyIdByTabId[promotionTabId]).toBe(promotionPtyId)
|
||||
})
|
||||
|
||||
it('rejects startup settlement when the superseding local catalog fails', async () => {
|
||||
let resolveStartup!: (repos: Repo[]) => void
|
||||
const startupRepos = new Promise<Repo[]>((resolve) => {
|
||||
resolveStartup = resolve
|
||||
})
|
||||
reposList.mockReturnValueOnce(startupRepos).mockRejectedValueOnce(new Error('catalog failed'))
|
||||
const store = createTestStore()
|
||||
|
||||
const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' })
|
||||
const refresh = store.getState().fetchRepos()
|
||||
resolveStartup([localRepo])
|
||||
await Promise.all([startup, refresh])
|
||||
|
||||
const hydration = async (): Promise<void> => {
|
||||
await store.getState().awaitLocalRepoCatalogSettlement()
|
||||
store.getState().hydrateWorkspaceSession(makePromotionSession())
|
||||
}
|
||||
await expect(hydration()).rejects.toThrow('catalog failed')
|
||||
expect(store.getState().tabsByWorktree).toEqual({})
|
||||
expect(store.getState().terminalLayoutsByTabId).toEqual({})
|
||||
expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1530,12 +1530,13 @@ export type RepoSlice = {
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
folderWorkspacePathStatuses: Record<string, FolderWorkspacePathStatusCacheEntry>
|
||||
activeRepoId: string | null
|
||||
// Monotonic sequence so an overlapping fetchRepos can drop its own stale result (#7020).
|
||||
// Monotonic sequence so overlapping catalog fetches can drop stale same-host results (#7020).
|
||||
reposFetchGeneration: number
|
||||
pendingSshRepoReadoptions: SshRepoReadoption[]
|
||||
recordSshRepoReadoptions: (readoptions: SshRepoReadoption[]) => void
|
||||
fetchRepos: () => Promise<void>
|
||||
fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
|
||||
awaitLocalRepoCatalogSettlement: () => Promise<void>
|
||||
fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise<Repo[]>
|
||||
fetchProjectGroups: () => Promise<void>
|
||||
fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
|
||||
@@ -1633,6 +1634,63 @@ export type RepoSlice = {
|
||||
reorderRepos: (orderedIds: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
type LocalRepoCatalogFetchOutcome =
|
||||
| { status: 'fulfilled' }
|
||||
| { status: 'rejected'; reason: unknown }
|
||||
|
||||
const latestLocalRepoCatalogFetchByStore = new WeakMap<
|
||||
() => AppState,
|
||||
Promise<LocalRepoCatalogFetchOutcome>
|
||||
>()
|
||||
const latestRepoCatalogGenerationByHostByStore = new WeakMap<() => AppState, Map<string, number>>()
|
||||
const latestAllHostRepoCatalogGenerationByStore = new WeakMap<() => AppState, number>()
|
||||
|
||||
function startLocalRepoCatalogFetch(
|
||||
get: () => AppState
|
||||
): (outcome: LocalRepoCatalogFetchOutcome) => void {
|
||||
let settle: (outcome: LocalRepoCatalogFetchOutcome) => void = () => undefined
|
||||
const settlement = new Promise<LocalRepoCatalogFetchOutcome>((resolve) => {
|
||||
settle = resolve
|
||||
})
|
||||
latestLocalRepoCatalogFetchByStore.set(get, settlement)
|
||||
return settle
|
||||
}
|
||||
|
||||
async function awaitLatestLocalRepoCatalogFetch(get: () => AppState): Promise<void> {
|
||||
while (true) {
|
||||
const pending = latestLocalRepoCatalogFetchByStore.get(get)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
const outcome = await pending
|
||||
if (latestLocalRepoCatalogFetchByStore.get(get) === pending) {
|
||||
if (outcome.status === 'rejected') {
|
||||
throw outcome.reason
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function claimRepoCatalogGeneration(get: () => AppState, hostId: string, generation: number): void {
|
||||
let generations = latestRepoCatalogGenerationByHostByStore.get(get)
|
||||
if (!generations) {
|
||||
generations = new Map()
|
||||
latestRepoCatalogGenerationByHostByStore.set(get, generations)
|
||||
}
|
||||
if ((generations.get(hostId) ?? 0) < generation) {
|
||||
generations.set(hostId, generation)
|
||||
}
|
||||
}
|
||||
|
||||
function isLatestRepoCatalogGeneration(
|
||||
get: () => AppState,
|
||||
hostId: string,
|
||||
generation: number
|
||||
): boolean {
|
||||
return latestRepoCatalogGenerationByHostByStore.get(get)?.get(hostId) === generation
|
||||
}
|
||||
|
||||
export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, get) => ({
|
||||
repos: [],
|
||||
projects: [],
|
||||
@@ -1670,17 +1728,22 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}),
|
||||
|
||||
fetchRepos: async () => {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const settleLocalCatalog: (outcome: LocalRepoCatalogFetchOutcome) => void =
|
||||
target.kind === 'local' ? startLocalRepoCatalogFetch(get) : () => undefined
|
||||
let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' }
|
||||
// Why: overlapping repos:changed fetches can resolve out of order; a stale one must not overwrite a newer result and resurrect deleted projects (#7020).
|
||||
let generation = 0
|
||||
set((s) => {
|
||||
generation = s.reposFetchGeneration + 1
|
||||
return { reposFetchGeneration: generation }
|
||||
})
|
||||
const targetHostId = getRuntimeTargetHostId(target)
|
||||
claimRepoCatalogGeneration(get, targetHostId, generation)
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const catalog = await fetchRepoCatalogForTarget(target)
|
||||
// A newer fetchRepos superseded us while we awaited — drop this stale result.
|
||||
if (get().reposFetchGeneration !== generation) {
|
||||
// A newer same-host fetch superseded us while we awaited — drop this stale result.
|
||||
if (!isLatestRepoCatalogGeneration(get, targetHostId, generation)) {
|
||||
return
|
||||
}
|
||||
let finalizedHostRepos: Repo[] = []
|
||||
@@ -1731,7 +1794,10 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
})
|
||||
scheduleSafeAutoForkSync(get, finalizedHostRepos)
|
||||
} catch (err) {
|
||||
localCatalogOutcome = { status: 'rejected', reason: err }
|
||||
console.error('Failed to fetch repos:', err)
|
||||
} finally {
|
||||
settleLocalCatalog(localCatalogOutcome)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1740,11 +1806,19 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
runtimeRepoFetchGenerationByEnvironment.set(environmentId, requestGeneration)
|
||||
const connectionGeneration = getEnvironmentSshStateGeneration(environmentId)
|
||||
const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId)
|
||||
let catalogGeneration = 0
|
||||
set((s) => {
|
||||
catalogGeneration = s.reposFetchGeneration + 1
|
||||
return { reposFetchGeneration: catalogGeneration }
|
||||
})
|
||||
const target = { kind: 'environment' as const, environmentId }
|
||||
const targetHostId = getRuntimeTargetHostId(target)
|
||||
claimRepoCatalogGeneration(get, targetHostId, catalogGeneration)
|
||||
try {
|
||||
const target = { kind: 'environment' as const, environmentId }
|
||||
const catalog = await fetchRepoCatalogForTarget(target)
|
||||
if (
|
||||
runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration ||
|
||||
!isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) ||
|
||||
getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration ||
|
||||
getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration
|
||||
) {
|
||||
@@ -1754,6 +1828,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
set((s) => {
|
||||
if (
|
||||
runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration ||
|
||||
!isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) ||
|
||||
getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration ||
|
||||
getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration
|
||||
) {
|
||||
@@ -1810,15 +1885,21 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
},
|
||||
|
||||
fetchReposForAllHosts: async (options) => {
|
||||
const settleLocalCatalog = startLocalRepoCatalogFetch(get)
|
||||
let generation = 0
|
||||
set((s) => {
|
||||
generation = s.reposFetchGeneration + 1
|
||||
return { reposFetchGeneration: generation }
|
||||
})
|
||||
latestAllHostRepoCatalogGenerationByStore.set(get, generation)
|
||||
claimRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation)
|
||||
// Why: fetching only the active host hides every other host's repos ("my projects vanished"); load local + all runtime envs, each failing soft.
|
||||
const applyCatalog = (catalog: FetchedRepoCatalog): void => {
|
||||
// Why: a concurrent all-host refresh must not let the older catalog resurrect a migrated SSH owner.
|
||||
if (get().reposFetchGeneration !== generation) {
|
||||
if (
|
||||
latestAllHostRepoCatalogGenerationByStore.get(get) !== generation ||
|
||||
!isLatestRepoCatalogGeneration(get, catalog.hostId, generation)
|
||||
) {
|
||||
return
|
||||
}
|
||||
let hostRepos: Repo[] = []
|
||||
@@ -1880,13 +1961,20 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
|
||||
// Local first so local repos are present even if a remote fetch stalls.
|
||||
let failed = false
|
||||
let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' }
|
||||
try {
|
||||
applyCatalog(await fetchRepoCatalogForTarget({ kind: 'local' }))
|
||||
} catch (err) {
|
||||
failed = true
|
||||
localCatalogOutcome = { status: 'rejected', reason: err }
|
||||
console.error('Failed to fetch local repos for all-host load:', err)
|
||||
}
|
||||
if (get().reposFetchGeneration !== generation) {
|
||||
// Why: startup hydration needs the newest local catalog, not unreachable remote hosts.
|
||||
settleLocalCatalog(localCatalogOutcome)
|
||||
if (
|
||||
get().reposFetchGeneration !== generation &&
|
||||
!isLatestRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (options?.remoteHosts === 'skip') {
|
||||
@@ -1897,13 +1985,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
// Why: unreachable remotes can spend the full connect timeout; merge each resolved host via the state updater so parallel loads don't clobber.
|
||||
await Promise.all(
|
||||
environments.map(async (environment) => {
|
||||
const target = {
|
||||
kind: 'environment' as const,
|
||||
environmentId: environment.id
|
||||
}
|
||||
claimRepoCatalogGeneration(get, getRuntimeTargetHostId(target), generation)
|
||||
try {
|
||||
applyCatalog(
|
||||
await fetchRepoCatalogForTarget({
|
||||
kind: 'environment',
|
||||
environmentId: environment.id
|
||||
})
|
||||
)
|
||||
applyCatalog(await fetchRepoCatalogForTarget(target))
|
||||
} catch (err) {
|
||||
failed = true
|
||||
console.warn(`Skipped repos for runtime environment ${environment.id}:`, err)
|
||||
@@ -1916,6 +2004,8 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
},
|
||||
|
||||
awaitLocalRepoCatalogSettlement: () => awaitLatestLocalRepoCatalogFetch(get),
|
||||
|
||||
fetchProjectGroups: async () => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
|
||||
@@ -30,6 +30,8 @@ import type {
|
||||
RuntimeTerminalRead
|
||||
} from '../../src/shared/runtime-types'
|
||||
import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
|
||||
import { parsePaneKey } from '../../src/shared/stable-pane-id'
|
||||
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
|
||||
|
||||
const electronPackageDir = path.join(process.cwd(), 'node_modules', 'electron')
|
||||
const electronPath = path.join(
|
||||
@@ -68,6 +70,35 @@ function readDaemonPid(userDataDir: string): number {
|
||||
return parsed.pid
|
||||
}
|
||||
|
||||
function readPersistedPromotionBinding(
|
||||
userDataDir: string,
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
leafId: string
|
||||
): { tabId: string; leafId: string; ptyId: string } | null {
|
||||
try {
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(
|
||||
path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as {
|
||||
workspaceSession?: {
|
||||
tabsByWorktree?: Record<string, { id?: string; ptyId?: string | null }[]>
|
||||
terminalLayoutsByTabId?: Record<string, { ptyIdsByLeafId?: Record<string, string | null> }>
|
||||
}
|
||||
}
|
||||
const tab = persisted.workspaceSession?.tabsByWorktree?.[worktreeId]?.find(
|
||||
(candidate) => candidate.id === tabId
|
||||
)
|
||||
const ptyId =
|
||||
persisted.workspaceSession?.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId?.[leafId]
|
||||
return tab && ptyId ? { tabId, leafId, ptyId } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return true
|
||||
@@ -146,8 +177,11 @@ test('promotes the headless owner without replacing its daemon terminal', async
|
||||
title: 'Serve promotion continuity'
|
||||
})
|
||||
const terminal = created.result.terminal
|
||||
if (!terminal.ptyId) {
|
||||
throw new Error('Headless terminal did not expose its daemon PTY id')
|
||||
const originalPtyId = terminal.ptyId
|
||||
const originalTabId = terminal.tabId
|
||||
const paneIdentity = terminal.paneKey ? parsePaneKey(terminal.paneKey) : null
|
||||
if (!originalPtyId || !originalTabId || !paneIdentity) {
|
||||
throw new Error('Headless terminal did not expose its durable pane and daemon PTY identity')
|
||||
}
|
||||
|
||||
const beforeMarker = `SERVE_PROMOTION_BEFORE_${Date.now()}`
|
||||
@@ -190,6 +224,26 @@ test('promotes the headless owner without replacing its daemon terminal', async
|
||||
})
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
readPersistedPromotionBinding(
|
||||
userDataDir,
|
||||
terminal.worktreeId,
|
||||
originalTabId,
|
||||
paneIdentity.leafId
|
||||
),
|
||||
{
|
||||
timeout: 15_000,
|
||||
message: 'headless terminal binding was not persisted during desktop promotion'
|
||||
}
|
||||
)
|
||||
.toEqual({
|
||||
tabId: originalTabId,
|
||||
leafId: paneIdentity.leafId,
|
||||
ptyId: originalPtyId
|
||||
})
|
||||
|
||||
const page = await serveApp.firstWindow({ timeout: 60_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
|
||||
@@ -223,6 +277,6 @@ test('promotes the headless owner without replacing its daemon terminal', async
|
||||
await closeElectronAppForE2E(serveApp)
|
||||
}
|
||||
await cleanupE2EDaemons(userDataDir)
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
const SEEDED_WORKSPACE_COUNT = 300
|
||||
const MARQUEE_WORKSPACE_COUNT = 102
|
||||
|
||||
/**
|
||||
* Why: the board used to mount every workspace card in every lane in one
|
||||
* commit, which blocked the sheet open for seconds on a large workspace set.
|
||||
* These assert the lane renders a window instead, and that each rendered card
|
||||
* still carries its true lane index so drop targeting stays correct.
|
||||
*/
|
||||
test.describe('Workspace board lane virtualization', () => {
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
})
|
||||
|
||||
test('mounts a window of cards for a large lane and keeps lane indexes', async ({ orcaPage }) => {
|
||||
await orcaPage.evaluate((count) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const state = store.getState()
|
||||
const repo = state.repos[0]
|
||||
if (!repo) {
|
||||
throw new Error('Expected a seeded e2e repo')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const seeded = state.worktreesByRepo[repo.id] ?? []
|
||||
const synthetic = Array.from({ length: count }, (_, index) => {
|
||||
const suffix = String(index).padStart(3, '0')
|
||||
return {
|
||||
id: `${repo.id}::/virtual-board-${suffix}`,
|
||||
instanceId: `virtual-board-${suffix}`,
|
||||
repoId: repo.id,
|
||||
path: `${repo.path}/../virtual-board-${suffix}`,
|
||||
displayName: `Virtual board ${suffix}`,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 10_000 - index,
|
||||
lastActivityAt: now - index - 100,
|
||||
head: '0000000000000000000000000000000000000000',
|
||||
branch: `virtual-board-${suffix}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
workspaceStatus: 'in-progress'
|
||||
}
|
||||
})
|
||||
|
||||
state.setSidebarOpen(true)
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setHideDefaultBranchWorkspace(false)
|
||||
state.setFilterRepoIds([])
|
||||
store.setState({
|
||||
sortBy: 'manual',
|
||||
worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] }
|
||||
})
|
||||
}, SEEDED_WORKSPACE_COUNT)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Workspace board' }).click()
|
||||
|
||||
const cards = orcaPage.locator('[data-workspace-board-card-id]')
|
||||
// Why: an empty window would also satisfy "fewer than seeded"; the point of
|
||||
// the change is a filled window, not a blank board.
|
||||
await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3)
|
||||
expect(await cards.count()).toBeLessThan(SEEDED_WORKSPACE_COUNT / 2)
|
||||
|
||||
const indexes = await cards.evaluateAll((elements) =>
|
||||
elements.map((element) =>
|
||||
Number((element as HTMLElement).dataset.workspaceBoardCardIndex ?? -1)
|
||||
)
|
||||
)
|
||||
expect(indexes.every((index) => Number.isInteger(index) && index >= 0)).toBe(true)
|
||||
expect(new Set(indexes).size).toBe(indexes.length)
|
||||
})
|
||||
|
||||
test('renders later lane indexes after the lane scrolls', async ({ orcaPage }) => {
|
||||
await orcaPage.evaluate((count) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const state = store.getState()
|
||||
const repo = state.repos[0]
|
||||
if (!repo) {
|
||||
throw new Error('Expected a seeded e2e repo')
|
||||
}
|
||||
const now = Date.now()
|
||||
const seeded = state.worktreesByRepo[repo.id] ?? []
|
||||
const synthetic = Array.from({ length: count }, (_, index) => ({
|
||||
id: `${repo.id}::/virtual-scroll-${index}`,
|
||||
instanceId: `virtual-scroll-${index}`,
|
||||
repoId: repo.id,
|
||||
path: `${repo.path}/../virtual-scroll-${index}`,
|
||||
displayName: `Virtual scroll ${index}`,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 10_000 - index,
|
||||
lastActivityAt: now - index - 100,
|
||||
head: '0000000000000000000000000000000000000000',
|
||||
branch: `virtual-scroll-${index}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
workspaceStatus: 'in-progress'
|
||||
}))
|
||||
state.setSidebarOpen(true)
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setFilterRepoIds([])
|
||||
store.setState({
|
||||
sortBy: 'manual',
|
||||
worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] }
|
||||
})
|
||||
}, SEEDED_WORKSPACE_COUNT)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Workspace board' }).click()
|
||||
|
||||
const cards = orcaPage.locator('[data-workspace-board-card-id]')
|
||||
await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3)
|
||||
|
||||
const readMaxIndex = (): Promise<number> =>
|
||||
cards.evaluateAll((elements) =>
|
||||
Math.max(
|
||||
...elements.map((element) =>
|
||||
Number((element as HTMLElement).dataset.workspaceBoardCardIndex ?? -1)
|
||||
)
|
||||
)
|
||||
)
|
||||
const before = await readMaxIndex()
|
||||
|
||||
await orcaPage
|
||||
.locator('[data-workspace-status="in-progress"] [data-workspace-board-lane-scroll]')
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
element.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
})
|
||||
|
||||
await expect.poll(readMaxIndex, { timeout: 15_000 }).toBeGreaterThan(before)
|
||||
})
|
||||
|
||||
test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => {
|
||||
const statusId = 'virtual-marquee'
|
||||
await orcaPage.evaluate(
|
||||
({ count, status }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const state = store.getState()
|
||||
const repo = state.repos[0]
|
||||
if (!repo) {
|
||||
throw new Error('Expected a seeded e2e repo')
|
||||
}
|
||||
const now = Date.now()
|
||||
const seeded = state.worktreesByRepo[repo.id] ?? []
|
||||
const synthetic = Array.from({ length: count }, (_, index) => ({
|
||||
id: `${repo.id}::/virtual-marquee-${index}`,
|
||||
instanceId: `virtual-marquee-${index}`,
|
||||
repoId: repo.id,
|
||||
path: `${repo.path}/../virtual-marquee-${index}`,
|
||||
displayName: `Virtual marquee ${index}`,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 10_000 - index,
|
||||
manualOrder: 10_000 - index,
|
||||
lastActivityAt: now - index - 100,
|
||||
head: '0000000000000000000000000000000000000000',
|
||||
branch: `virtual-marquee-${index}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
workspaceStatus: status
|
||||
}))
|
||||
|
||||
state.setSidebarOpen(true)
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setFilterRepoIds([])
|
||||
store.setState({
|
||||
sortBy: 'manual',
|
||||
worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] }
|
||||
})
|
||||
state.setWorkspaceStatuses([
|
||||
{ id: status, label: 'Virtual marquee' },
|
||||
...state.workspaceStatuses.filter((entry) => entry.id !== status)
|
||||
])
|
||||
},
|
||||
{ count: MARQUEE_WORKSPACE_COUNT, status: statusId }
|
||||
)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Workspace board' }).click()
|
||||
|
||||
const lane = orcaPage.locator(`[data-workspace-status="${statusId}"]`)
|
||||
await expect(lane.getByText(String(MARQUEE_WORKSPACE_COUNT), { exact: true })).toBeVisible()
|
||||
const laneCards = lane.locator('[data-workspace-board-card-id]')
|
||||
await expect.poll(() => laneCards.count(), { timeout: 15_000 }).toBeGreaterThan(3)
|
||||
const laneScroll = lane.locator('[data-workspace-board-lane-scroll]')
|
||||
const box = await laneScroll.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error('Expected the marquee lane to have a bounding box')
|
||||
}
|
||||
|
||||
await orcaPage.mouse.move(box.x + 2, box.y + 12)
|
||||
await orcaPage.mouse.down()
|
||||
await orcaPage.mouse.move(box.x + box.width - 18, box.y + 80)
|
||||
await laneScroll.evaluate(async (element) => {
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
element.scrollTop = element.scrollHeight
|
||||
element.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
}
|
||||
})
|
||||
await orcaPage.mouse.move(box.x + box.width - 18, box.y + box.height - 12)
|
||||
await orcaPage.mouse.up()
|
||||
|
||||
await expect(
|
||||
orcaPage.getByText(`${MARQUEE_WORKSPACE_COUNT} selected`, { exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user